mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
Merge remote-tracking branch 'origin/master' into deploy/prod-quota-2389
This commit is contained in:
@@ -49,6 +49,8 @@ import type {
|
|||||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
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';
|
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||||
|
|
||||||
@@ -154,6 +156,12 @@ function AddExtensionContent() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
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.
|
// Localized label for an extension type, used in the install dialog.
|
||||||
const extensionTypeLabel = (type: string) =>
|
const extensionTypeLabel = (type: string) =>
|
||||||
@@ -344,23 +352,28 @@ function AddExtensionContent() {
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
const handleInstallPlugin = useCallback(
|
||||||
setInstallInfo({
|
async (plugin: PluginV4) => {
|
||||||
plugin_author: plugin.author,
|
if (extensionsReached) return;
|
||||||
plugin_name: plugin.name,
|
setInstallInfo({
|
||||||
plugin_version: plugin.latest_version,
|
plugin_author: plugin.author,
|
||||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
plugin_name: plugin.name,
|
||||||
plugin_description: extractI18nObject(plugin.description) || '',
|
plugin_version: plugin.latest_version,
|
||||||
plugin_icon: plugin.icon || '',
|
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||||
});
|
plugin_description: extractI18nObject(plugin.description) || '',
|
||||||
setInstallExtensionType(plugin.type || 'plugin');
|
plugin_icon: plugin.icon || '',
|
||||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
});
|
||||||
setInstallError(null);
|
setInstallExtensionType(plugin.type || 'plugin');
|
||||||
setInstallIconFailed(false);
|
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||||
setModalOpen(true);
|
setInstallError(null);
|
||||||
}, []);
|
setInstallIconFailed(false);
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
[extensionsReached],
|
||||||
|
);
|
||||||
|
|
||||||
function handleModalConfirm() {
|
function handleModalConfirm() {
|
||||||
|
if (extensionsReached) return;
|
||||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||||
httpClient
|
httpClient
|
||||||
@@ -402,6 +415,7 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
if (extensionsReached) return;
|
||||||
if (!validateFileType(file)) {
|
if (!validateFileType(file)) {
|
||||||
toast.error(t('addExtension.unsupportedFileType'));
|
toast.error(t('addExtension.unsupportedFileType'));
|
||||||
return;
|
return;
|
||||||
@@ -421,14 +435,15 @@ function AddExtensionContent() {
|
|||||||
setSkillUploadPreviewOpen(true);
|
setSkillUploadPreviewOpen(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, setSelectedTaskId],
|
[extensionsReached, t, setSelectedTaskId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
|
if (extensionsReached) return;
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click();
|
fileInputRef.current.click();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [extensionsReached]);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -455,12 +470,13 @@ function AddExtensionContent() {
|
|||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
if (extensionsReached) return;
|
||||||
const files = Array.from(event.dataTransfer.files);
|
const files = Array.from(event.dataTransfer.files);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
uploadFile(files[0]);
|
uploadFile(files[0]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploadFile],
|
[extensionsReached, uploadFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleMCPCreated(_serverName: string) {
|
function handleMCPCreated(_serverName: string) {
|
||||||
@@ -490,7 +506,8 @@ function AddExtensionContent() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't check, let backend handle it
|
toast.error(t('limitation.quotaCheckFailed'));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -630,9 +647,11 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
async function handleGithubConfirm() {
|
async function handleGithubConfirm() {
|
||||||
if (!selectedAsset || !selectedRelease) return;
|
if (!selectedAsset || !selectedRelease) return;
|
||||||
if (!(await checkExtensionsLimit())) return;
|
|
||||||
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
||||||
|
if (!(await checkExtensionsLimit())) {
|
||||||
|
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||||
httpClient
|
httpClient
|
||||||
.installPluginFromGithub(
|
.installPluginFromGithub(
|
||||||
@@ -664,9 +683,11 @@ function AddExtensionContent() {
|
|||||||
|
|
||||||
async function handleGithubSkillConfirm() {
|
async function handleGithubSkillConfirm() {
|
||||||
if (!githubSkillInfo) return;
|
if (!githubSkillInfo) return;
|
||||||
if (!(await checkExtensionsLimit())) return;
|
|
||||||
|
|
||||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||||
|
if (!(await checkExtensionsLimit())) {
|
||||||
|
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await httpClient.installSkillFromGithub(
|
await httpClient.installSkillFromGithub(
|
||||||
githubURL.trim(),
|
githubURL.trim(),
|
||||||
@@ -726,17 +747,24 @@ function AddExtensionContent() {
|
|||||||
setPopoverOpen(open);
|
setPopoverOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<WorkspaceQuotaTooltip
|
||||||
<Button
|
quota={extensionQuota}
|
||||||
variant="default"
|
resource={t('sidebar.extensions')}
|
||||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
>
|
||||||
>
|
<PopoverTrigger asChild>
|
||||||
<PlusIcon className="w-4 h-4" />
|
<Button
|
||||||
<span className="whitespace-nowrap">
|
variant="default"
|
||||||
{t('addExtension.manualAdd')}
|
disabled={extensionsReached}
|
||||||
</span>
|
aria-disabled={extensionsReached}
|
||||||
</Button>
|
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"
|
||||||
</PopoverTrigger>
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{t('addExtension.manualAdd')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</WorkspaceQuotaTooltip>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
forceMount
|
forceMount
|
||||||
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
||||||
@@ -745,9 +773,19 @@ function AddExtensionContent() {
|
|||||||
{/* ===== Menu View ===== */}
|
{/* ===== Menu View ===== */}
|
||||||
{popoverView === 'menu' && (
|
{popoverView === 'menu' && (
|
||||||
<div className="space-y-4 p-4">
|
<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 */}
|
{/* File upload area */}
|
||||||
<div
|
<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
|
isDragOver
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||||
@@ -777,7 +815,8 @@ function AddExtensionContent() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="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')}
|
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">
|
<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
|
<button
|
||||||
type="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')}
|
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">
|
<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
|
<button
|
||||||
type="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 () => {
|
onClick={async () => {
|
||||||
if (!(await checkExtensionsLimit())) return;
|
if (!(await checkExtensionsLimit())) return;
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
@@ -882,6 +923,7 @@ function AddExtensionContent() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="mcp-form"
|
form="mcp-form"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={extensionsReached}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
if (!(await checkExtensionsLimit())) {
|
if (!(await checkExtensionsLimit())) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -946,6 +988,7 @@ function AddExtensionContent() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubAddressSubmit}
|
onClick={handleGithubAddressSubmit}
|
||||||
disabled={
|
disabled={
|
||||||
|
extensionsReached ||
|
||||||
!githubURL.trim() ||
|
!githubURL.trim() ||
|
||||||
fetchingReleases ||
|
fetchingReleases ||
|
||||||
fetchingSkillPreview
|
fetchingSkillPreview
|
||||||
@@ -1102,7 +1145,11 @@ function AddExtensionContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button className="w-full" onClick={handleGithubConfirm}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGithubConfirm}
|
||||||
|
disabled={extensionsReached}
|
||||||
|
>
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1184,6 +1231,7 @@ function AddExtensionContent() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={handleGithubSkillConfirm}
|
onClick={handleGithubSkillConfirm}
|
||||||
|
disabled={extensionsReached}
|
||||||
>
|
>
|
||||||
{t('common.confirm')}
|
{t('common.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1240,6 +1288,8 @@ function AddExtensionContent() {
|
|||||||
<MarketPage
|
<MarketPage
|
||||||
installPlugin={handleInstallPlugin}
|
installPlugin={handleInstallPlugin}
|
||||||
headerActions={extensionActions}
|
headerActions={extensionActions}
|
||||||
|
installDisabled={extensionsReached}
|
||||||
|
installDisabledTooltip={extensionQuotaTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1325,9 +1375,17 @@ function AddExtensionContent() {
|
|||||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleModalConfirm}>
|
<WorkspaceQuotaTooltip
|
||||||
{t('common.confirm')}
|
quota={extensionQuota}
|
||||||
</Button>
|
resource={t('sidebar.extensions')}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={handleModalConfirm}
|
||||||
|
disabled={extensionsReached}
|
||||||
|
>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</WorkspaceQuotaTooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||||
@@ -1359,6 +1417,8 @@ function AddExtensionContent() {
|
|||||||
{pluginUploadPreviewFile && (
|
{pluginUploadPreviewFile && (
|
||||||
<PluginLocalPreviewPanel
|
<PluginLocalPreviewPanel
|
||||||
file={pluginUploadPreviewFile}
|
file={pluginUploadPreviewFile}
|
||||||
|
quota={extensionQuota}
|
||||||
|
quotaResource={t('sidebar.extensions')}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setPluginUploadPreviewOpen(false);
|
setPluginUploadPreviewOpen(false);
|
||||||
setPluginUploadPreviewFile(null);
|
setPluginUploadPreviewFile(null);
|
||||||
@@ -1392,6 +1452,8 @@ function AddExtensionContent() {
|
|||||||
{skillUploadPreviewFile && (
|
{skillUploadPreviewFile && (
|
||||||
<SkillZipPreviewPanel
|
<SkillZipPreviewPanel
|
||||||
file={skillUploadPreviewFile}
|
file={skillUploadPreviewFile}
|
||||||
|
quota={extensionQuota}
|
||||||
|
quotaResource={t('sidebar.extensions')}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setSkillUploadPreviewOpen(false);
|
setSkillUploadPreviewOpen(false);
|
||||||
setSkillUploadPreviewFile(null);
|
setSkillUploadPreviewFile(null);
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
||||||
import { FeedbackPopoverContent } from './FeedbackPopover';
|
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
|
// Compare two version strings, returns true if v1 > v2
|
||||||
function compareVersions(v1: string, v2: string): boolean {
|
function compareVersions(v1: string, v2: string): boolean {
|
||||||
@@ -279,6 +284,14 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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) {
|
async function waitForMCPRefreshTask(taskId: number) {
|
||||||
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||||
|
|
||||||
@@ -386,6 +399,7 @@ function NavItems({
|
|||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const sidebarData = useSidebarData();
|
const sidebarData = useSidebarData();
|
||||||
|
const quotaStatus = useWorkspaceQuotaStatus();
|
||||||
const { state: sidebarState, isMobile } = useSidebar();
|
const { state: sidebarState, isMobile } = useSidebar();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
@@ -529,19 +543,33 @@ function NavItems({
|
|||||||
if (config.id === 'add-extension' && !canManageResources) {
|
if (config.id === 'add-extension' && !canManageResources) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<SidebarMenuItem key={config.id}>
|
<SidebarMenuItem key={config.id}>
|
||||||
<SidebarMenuButton
|
<WorkspaceQuotaTooltip
|
||||||
isActive={selectedChild?.id === config.id}
|
quota={quota}
|
||||||
onClick={() => onChildClick(config)}
|
resource={config.name}
|
||||||
tooltip={config.name}
|
side="right"
|
||||||
>
|
>
|
||||||
{config.icon}
|
<SidebarMenuButton
|
||||||
<span className="cursor-pointer select-none">
|
isActive={selectedChild?.id === config.id}
|
||||||
{config.name}
|
onClick={() => {
|
||||||
</span>
|
if (!quota.disabled) onChildClick(config);
|
||||||
</SidebarMenuButton>
|
}}
|
||||||
|
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>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -575,6 +603,18 @@ function NavItems({
|
|||||||
const isSkill = categoryId === 'skills';
|
const isSkill = categoryId === 'skills';
|
||||||
const isBot = categoryId === 'bots';
|
const isBot = categoryId === 'bots';
|
||||||
const isMCP = categoryId === 'mcp';
|
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 => {
|
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
||||||
if (item.extensionType === 'mcp') {
|
if (item.extensionType === 'mcp') {
|
||||||
@@ -907,128 +947,144 @@ function NavItems({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1 px-2">
|
<div className="flex items-center justify-between mb-1 px-2">
|
||||||
<span className="text-sm font-medium">{config.name}</span>
|
<span className="text-sm font-medium">{config.name}</span>
|
||||||
{canCreate &&
|
{canCreate && (
|
||||||
(isPlugin ? (
|
<WorkspaceQuotaTooltip
|
||||||
<DropdownMenu>
|
quota={quota}
|
||||||
<DropdownMenuTrigger asChild>
|
resource={config.name}
|
||||||
<button
|
side="right"
|
||||||
type="button"
|
>
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
{isPlugin ? (
|
||||||
>
|
<DropdownMenu>
|
||||||
<Plus className="size-3.5" />
|
<DropdownMenuTrigger asChild>
|
||||||
</button>
|
<button
|
||||||
</DropdownMenuTrigger>
|
type="button"
|
||||||
<DropdownMenuContent align="end">
|
disabled={quota.disabled}
|
||||||
{systemInfo.enable_marketplace && (
|
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
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate('/home/add-extension');
|
navigate('/home/add-extension?manual=1');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store className="size-4" />
|
<Upload className="size-4" />
|
||||||
{t('plugins.goToMarketplace')}
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
navigate('/home/add-extension?manual=1');
|
||||||
navigate('/home/add-extension?manual=1');
|
setPopoverOpen((prev) => ({
|
||||||
setPopoverOpen((prev) => ({
|
...prev,
|
||||||
...prev,
|
[config.id]: false,
|
||||||
[config.id]: false,
|
}));
|
||||||
}));
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Github className="size-4" />
|
||||||
<Upload className="size-4" />
|
{t('plugins.installFromGithub')}
|
||||||
{t('plugins.uploadLocal')}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
</DropdownMenu>
|
||||||
onClick={(e) => {
|
) : isSkill ? (
|
||||||
e.stopPropagation();
|
<DropdownMenu>
|
||||||
navigate('/home/add-extension?manual=1');
|
<DropdownMenuTrigger asChild>
|
||||||
setPopoverOpen((prev) => ({
|
<button
|
||||||
...prev,
|
type="button"
|
||||||
[config.id]: false,
|
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"
|
||||||
<Github className="size-4" />
|
>
|
||||||
{t('plugins.installFromGithub')}
|
<Plus className="size-3.5" />
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenu>
|
<DropdownMenuContent align="end">
|
||||||
) : isSkill ? (
|
<DropdownMenuItem
|
||||||
<DropdownMenu>
|
onClick={(e) => {
|
||||||
<DropdownMenuTrigger asChild>
|
e.stopPropagation();
|
||||||
<button
|
navigate('/home/skills?action=create');
|
||||||
type="button"
|
setPopoverOpen((prev) => ({
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
...prev,
|
||||||
>
|
[config.id]: false,
|
||||||
<Plus className="size-3.5" />
|
}));
|
||||||
</button>
|
}}
|
||||||
</DropdownMenuTrigger>
|
>
|
||||||
<DropdownMenuContent align="end">
|
<FilePlus2 className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.createManually')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/skills?action=create');
|
onClick={(e) => {
|
||||||
setPopoverOpen((prev) => ({
|
e.stopPropagation();
|
||||||
...prev,
|
navigate('/home/add-extension?manual=1');
|
||||||
[config.id]: false,
|
setPopoverOpen((prev) => ({
|
||||||
}));
|
...prev,
|
||||||
}}
|
[config.id]: false,
|
||||||
>
|
}));
|
||||||
<FilePlus2 className="size-4" />
|
}}
|
||||||
{t('skills.createManually')}
|
>
|
||||||
</DropdownMenuItem>
|
<Upload className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.uploadZip')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/add-extension?manual=1');
|
onClick={(e) => {
|
||||||
setPopoverOpen((prev) => ({
|
e.stopPropagation();
|
||||||
...prev,
|
navigate('/home/add-extension?manual=1');
|
||||||
[config.id]: false,
|
setPopoverOpen((prev) => ({
|
||||||
}));
|
...prev,
|
||||||
}}
|
[config.id]: false,
|
||||||
>
|
}));
|
||||||
<Upload className="size-4" />
|
}}
|
||||||
{t('skills.uploadZip')}
|
>
|
||||||
</DropdownMenuItem>
|
<Github className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.importFromGithub')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
</DropdownMenuContent>
|
||||||
navigate('/home/add-extension?manual=1');
|
</DropdownMenu>
|
||||||
setPopoverOpen((prev) => ({
|
) : (
|
||||||
...prev,
|
<button
|
||||||
[config.id]: false,
|
type="button"
|
||||||
}));
|
disabled={quota.disabled}
|
||||||
}}
|
aria-disabled={quota.disabled}
|
||||||
>
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
<Github className="size-4" />
|
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||||
{t('skills.importFromGithub')}
|
onClick={() => {
|
||||||
</DropdownMenuItem>
|
navigate(`${routePrefix}?id=new`);
|
||||||
</DropdownMenuContent>
|
setPopoverOpen((prev) => ({
|
||||||
</DropdownMenu>
|
...prev,
|
||||||
) : (
|
[config.id]: false,
|
||||||
<button
|
}));
|
||||||
type="button"
|
}}
|
||||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
>
|
||||||
onClick={() => {
|
<Plus className="size-3.5" />
|
||||||
navigate(`${routePrefix}?id=new`);
|
</button>
|
||||||
setPopoverOpen((prev) => ({
|
)}
|
||||||
...prev,
|
</WorkspaceQuotaTooltip>
|
||||||
[config.id]: false,
|
)}
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||||
{renderEntityList(true)}
|
{renderEntityList(true)}
|
||||||
@@ -1096,103 +1152,119 @@ function NavItems({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canCreate &&
|
{canCreate && (
|
||||||
(isPlugin ? (
|
<WorkspaceQuotaTooltip
|
||||||
<DropdownMenu>
|
quota={quota}
|
||||||
<DropdownMenuTrigger asChild>
|
resource={config.name}
|
||||||
<button
|
side="right"
|
||||||
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"
|
{isPlugin ? (
|
||||||
onClick={(e) => e.stopPropagation()}
|
<DropdownMenu>
|
||||||
>
|
<DropdownMenuTrigger asChild>
|
||||||
<Plus className="size-3.5" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</DropdownMenuTrigger>
|
disabled={quota.disabled}
|
||||||
<DropdownMenuContent align="end">
|
aria-disabled={quota.disabled}
|
||||||
{systemInfo.enable_marketplace && (
|
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
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate('/home/add-extension');
|
navigate('/home/add-extension?manual=1');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store className="size-4" />
|
<Upload className="size-4" />
|
||||||
{t('plugins.goToMarketplace')}
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
navigate('/home/add-extension?manual=1');
|
||||||
navigate('/home/add-extension?manual=1');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Github className="size-4" />
|
||||||
<Upload className="size-4" />
|
{t('plugins.installFromGithub')}
|
||||||
{t('plugins.uploadLocal')}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuContent>
|
||||||
<DropdownMenuItem
|
</DropdownMenu>
|
||||||
onClick={(e) => {
|
) : isSkill ? (
|
||||||
e.stopPropagation();
|
<DropdownMenu>
|
||||||
navigate('/home/add-extension?manual=1');
|
<DropdownMenuTrigger asChild>
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Github className="size-4" />
|
disabled={quota.disabled}
|
||||||
{t('plugins.installFromGithub')}
|
aria-disabled={quota.disabled}
|
||||||
</DropdownMenuItem>
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
</DropdownMenuContent>
|
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"
|
||||||
</DropdownMenu>
|
onClick={(e) => e.stopPropagation()}
|
||||||
) : isSkill ? (
|
>
|
||||||
<DropdownMenu>
|
<Plus className="size-3.5" />
|
||||||
<DropdownMenuTrigger asChild>
|
</button>
|
||||||
<button
|
</DropdownMenuTrigger>
|
||||||
type="button"
|
<DropdownMenuContent align="end">
|
||||||
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"
|
<DropdownMenuItem
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<Plus className="size-3.5" />
|
navigate('/home/skills?action=create');
|
||||||
</button>
|
}}
|
||||||
</DropdownMenuTrigger>
|
>
|
||||||
<DropdownMenuContent align="end">
|
<FilePlus2 className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.createManually')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/skills?action=create');
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
navigate('/home/add-extension?manual=1');
|
||||||
<FilePlus2 className="size-4" />
|
}}
|
||||||
{t('skills.createManually')}
|
>
|
||||||
</DropdownMenuItem>
|
<Upload className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.uploadZip')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
navigate('/home/add-extension?manual=1');
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
navigate('/home/add-extension?manual=1');
|
||||||
<Upload className="size-4" />
|
}}
|
||||||
{t('skills.uploadZip')}
|
>
|
||||||
</DropdownMenuItem>
|
<Github className="size-4" />
|
||||||
<DropdownMenuItem
|
{t('skills.importFromGithub')}
|
||||||
onClick={(e) => {
|
</DropdownMenuItem>
|
||||||
e.stopPropagation();
|
</DropdownMenuContent>
|
||||||
navigate('/home/add-extension?manual=1');
|
</DropdownMenu>
|
||||||
}}
|
) : (
|
||||||
>
|
<button
|
||||||
<Github className="size-4" />
|
type="button"
|
||||||
{t('skills.importFromGithub')}
|
disabled={quota.disabled}
|
||||||
</DropdownMenuItem>
|
aria-disabled={quota.disabled}
|
||||||
</DropdownMenuContent>
|
aria-label={`${t('common.create')} ${config.name}`}
|
||||||
</DropdownMenu>
|
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) => {
|
||||||
<button
|
e.stopPropagation();
|
||||||
type="button"
|
navigate(`${routePrefix}?id=new`);
|
||||||
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" />
|
||||||
navigate(`${routePrefix}?id=new`);
|
</button>
|
||||||
}}
|
)}
|
||||||
>
|
</WorkspaceQuotaTooltip>
|
||||||
<Plus className="size-3.5" />
|
)}
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
|
|||||||
pipelines: SidebarEntityItem[];
|
pipelines: SidebarEntityItem[];
|
||||||
knowledgeBases: SidebarEntityItem[];
|
knowledgeBases: SidebarEntityItem[];
|
||||||
plugins: SidebarEntityItem[];
|
plugins: SidebarEntityItem[];
|
||||||
|
pluginCount: number;
|
||||||
mcpServers: SidebarEntityItem[];
|
mcpServers: SidebarEntityItem[];
|
||||||
skills: SidebarEntityItem[];
|
skills: SidebarEntityItem[];
|
||||||
pluginPages: PluginPageItem[];
|
pluginPages: PluginPageItem[];
|
||||||
|
quotaDataLoaded: boolean;
|
||||||
refreshBots: () => Promise<void>;
|
refreshBots: () => Promise<void>;
|
||||||
refreshPipelines: () => Promise<void>;
|
refreshPipelines: () => Promise<void>;
|
||||||
refreshKnowledgeBases: () => Promise<void>;
|
refreshKnowledgeBases: () => Promise<void>;
|
||||||
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
|
|||||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||||
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
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 [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||||
useState<boolean>(() => {
|
useState<boolean>(() => {
|
||||||
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshBots = useCallback(async () => {
|
const refreshBots = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.bots;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getBots();
|
const resp = await httpClient.getBots();
|
||||||
|
if (requestId !== refreshRequestIds.current.bots) return;
|
||||||
|
setQuotaResourceLoaded('bots', true);
|
||||||
setBots(
|
setBots(
|
||||||
resp.bots.map((bot) => ({
|
resp.bots.map((bot) => ({
|
||||||
id: bot.uuid || '',
|
id: bot.uuid || '',
|
||||||
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.bots) return;
|
||||||
|
setQuotaResourceLoaded('bots', false);
|
||||||
console.error('Failed to fetch bots for sidebar:', error);
|
console.error('Failed to fetch bots for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshPipelines = useCallback(async () => {
|
const refreshPipelines = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.pipelines;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getPipelines();
|
const resp = await httpClient.getPipelines();
|
||||||
|
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||||
|
setQuotaResourceLoaded('pipelines', true);
|
||||||
setPipelines(
|
setPipelines(
|
||||||
resp.pipelines.map((p) => ({
|
resp.pipelines.map((p) => ({
|
||||||
id: p.uuid || '',
|
id: p.uuid || '',
|
||||||
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||||
|
setQuotaResourceLoaded('pipelines', false);
|
||||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshKnowledgeBases = useCallback(async () => {
|
const refreshKnowledgeBases = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.knowledgeBases;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getKnowledgeBases();
|
const resp = await httpClient.getKnowledgeBases();
|
||||||
|
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||||
|
setQuotaResourceLoaded('knowledgeBases', true);
|
||||||
setKnowledgeBases(
|
setKnowledgeBases(
|
||||||
resp.bases.map((kb) => ({
|
resp.bases.map((kb) => ({
|
||||||
id: kb.uuid || '',
|
id: kb.uuid || '',
|
||||||
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||||
|
setQuotaResourceLoaded('knowledgeBases', false);
|
||||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshPlugins = useCallback(async () => {
|
const refreshPlugins = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.plugins;
|
||||||
try {
|
try {
|
||||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||||
httpClient.getPlugins(),
|
httpClient.getPlugins(),
|
||||||
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
|
|||||||
.getMarketplacePlugins(1, 100)
|
.getMarketplacePlugins(1, 100)
|
||||||
.catch(() => ({ plugins: [] })),
|
.catch(() => ({ plugins: [] })),
|
||||||
]);
|
]);
|
||||||
|
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||||
|
setQuotaResourceLoaded('plugins', true);
|
||||||
|
setPluginCount(pluginsResp.plugins?.length ?? 0);
|
||||||
|
|
||||||
// Build marketplace version lookup: "author/name" -> latest_version
|
// Build marketplace version lookup: "author/name" -> latest_version
|
||||||
const marketplaceVersions = new Map<string, string>();
|
const marketplaceVersions = new Map<string, string>();
|
||||||
@@ -241,13 +290,18 @@ export function SidebarDataProvider({
|
|||||||
}
|
}
|
||||||
setPluginPages(pages);
|
setPluginPages(pages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||||
|
setQuotaResourceLoaded('plugins', false);
|
||||||
console.error('Failed to fetch plugins for sidebar:', error);
|
console.error('Failed to fetch plugins for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshMCPServers = useCallback(async () => {
|
const refreshMCPServers = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.mcpServers;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getMCPServers();
|
const resp = await httpClient.getMCPServers();
|
||||||
|
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||||
|
setQuotaResourceLoaded('mcpServers', true);
|
||||||
setMCPServers(
|
setMCPServers(
|
||||||
resp.servers.map((server) => ({
|
resp.servers.map((server) => ({
|
||||||
id: server.name, // Keep __ for API calls
|
id: server.name, // Keep __ for API calls
|
||||||
@@ -257,13 +311,18 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||||
|
setQuotaResourceLoaded('mcpServers', false);
|
||||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshSkills = useCallback(async () => {
|
const refreshSkills = useCallback(async () => {
|
||||||
|
const requestId = ++refreshRequestIds.current.skills;
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getSkills();
|
const resp = await httpClient.getSkills();
|
||||||
|
if (requestId !== refreshRequestIds.current.skills) return;
|
||||||
|
setQuotaResourceLoaded('skills', true);
|
||||||
setSkills(
|
setSkills(
|
||||||
resp.skills.map((skill) => ({
|
resp.skills.map((skill) => ({
|
||||||
id: skill.name,
|
id: skill.name,
|
||||||
@@ -273,11 +332,22 @@ export function SidebarDataProvider({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== refreshRequestIds.current.skills) return;
|
||||||
|
setQuotaResourceLoaded('skills', false);
|
||||||
console.error('Failed to fetch skills for sidebar:', error);
|
console.error('Failed to fetch skills for sidebar:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setQuotaResourceLoaded]);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
|
quotaResourceLoaded.current = {
|
||||||
|
bots: false,
|
||||||
|
pipelines: false,
|
||||||
|
knowledgeBases: false,
|
||||||
|
plugins: false,
|
||||||
|
mcpServers: false,
|
||||||
|
skills: false,
|
||||||
|
};
|
||||||
|
setQuotaDataLoaded(false);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshBots(),
|
refreshBots(),
|
||||||
refreshPipelines(),
|
refreshPipelines(),
|
||||||
@@ -307,9 +377,11 @@ export function SidebarDataProvider({
|
|||||||
pipelines,
|
pipelines,
|
||||||
knowledgeBases,
|
knowledgeBases,
|
||||||
plugins,
|
plugins,
|
||||||
|
pluginCount,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
skills,
|
skills,
|
||||||
pluginPages,
|
pluginPages,
|
||||||
|
quotaDataLoaded,
|
||||||
refreshBots,
|
refreshBots,
|
||||||
refreshPipelines,
|
refreshPipelines,
|
||||||
refreshKnowledgeBases,
|
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 { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
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<
|
type PluginLocalPreview = Awaited<
|
||||||
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
||||||
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
|
|||||||
file: File;
|
file: File;
|
||||||
onInstallStarted?: () => void;
|
onInstallStarted?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
quota?: WorkspaceQuotaItem;
|
||||||
|
quotaResource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
|
|||||||
file,
|
file,
|
||||||
onInstallStarted,
|
onInstallStarted,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
quota,
|
||||||
|
quotaResource = '',
|
||||||
}: PluginLocalPreviewPanelProps) {
|
}: PluginLocalPreviewPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
||||||
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
|
|||||||
}, [loadPreview]);
|
}, [loadPreview]);
|
||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
|
if (quota?.disabled) return;
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
try {
|
try {
|
||||||
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{quota ? (
|
||||||
type="button"
|
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||||
onClick={handleInstall}
|
<Button
|
||||||
disabled={!preview || previewing || installing}
|
type="button"
|
||||||
>
|
onClick={handleInstall}
|
||||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
disabled={quota.disabled || !preview || previewing || installing}
|
||||||
</Button>
|
>
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,9 +80,13 @@ function loadMarketFilters(): MarketFilters {
|
|||||||
function MarketPageContent({
|
function MarketPageContent({
|
||||||
installPlugin,
|
installPlugin,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
installPlugin: (plugin: PluginV4) => void;
|
installPlugin: (plugin: PluginV4) => void;
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -847,6 +851,8 @@ function MarketPageContent({
|
|||||||
lists={recommendationLists}
|
lists={recommendationLists}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -876,6 +882,8 @@ function MarketPageContent({
|
|||||||
cardVO={plugin}
|
cardVO={plugin}
|
||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -915,9 +923,13 @@ function MarketPageContent({
|
|||||||
export default function MarketPage({
|
export default function MarketPage({
|
||||||
installPlugin,
|
installPlugin,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
installPlugin: (plugin: PluginV4) => void;
|
installPlugin: (plugin: PluginV4) => void;
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
@@ -932,6 +944,8 @@ export default function MarketPage({
|
|||||||
<MarketPageContent
|
<MarketPageContent
|
||||||
installPlugin={installPlugin}
|
installPlugin={installPlugin}
|
||||||
headerActions={headerActions}
|
headerActions={headerActions}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,11 +54,15 @@ function RecommendationListRow({
|
|||||||
list,
|
list,
|
||||||
tagNames,
|
tagNames,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
isLast,
|
isLast,
|
||||||
}: {
|
}: {
|
||||||
list: RecommendationList;
|
list: RecommendationList;
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -263,6 +267,8 @@ function RecommendationListRow({
|
|||||||
cardVO={pluginToVO(plugin, t)}
|
cardVO={pluginToVO(plugin, t)}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,10 +283,14 @@ export function RecommendationLists({
|
|||||||
lists,
|
lists,
|
||||||
tagNames,
|
tagNames,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
installDisabled,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
lists: RecommendationList[];
|
lists: RecommendationList[];
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!lists || lists.length === 0) return null;
|
if (!lists || lists.length === 0) return null;
|
||||||
|
|
||||||
@@ -292,6 +302,8 @@ export function RecommendationLists({
|
|||||||
list={list}
|
list={list}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
|
installDisabled={installDisabled}
|
||||||
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
isLast={index === lists.length - 1}
|
isLast={index === lists.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+26
-3
@@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({
|
|||||||
cardVO,
|
cardVO,
|
||||||
onInstall,
|
onInstall,
|
||||||
tagNames = {},
|
tagNames = {},
|
||||||
|
installDisabled = false,
|
||||||
|
installDisabledTooltip,
|
||||||
}: {
|
}: {
|
||||||
cardVO: PluginMarketCardVO;
|
cardVO: PluginMarketCardVO;
|
||||||
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
||||||
tagNames?: Record<string, string>;
|
tagNames?: Record<string, string>;
|
||||||
|
installDisabled?: boolean;
|
||||||
|
installDisabledTooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({
|
|||||||
|
|
||||||
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||||
const handleInstallClick = () => {
|
const handleInstallClick = () => {
|
||||||
|
if (installDisabled) return;
|
||||||
onInstall?.(cardVO);
|
onInstall?.(cardVO);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const cardContent = (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role={installDisabled ? 'group' : 'button'}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
aria-disabled={installDisabled}
|
||||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
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}
|
onClick={handleInstallClick}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (
|
if (
|
||||||
@@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</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 { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import type { Skill } from '@/app/infra/entities/api';
|
import type { Skill } from '@/app/infra/entities/api';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface PreviewSkill extends Skill {
|
||||||
source_path?: string;
|
source_path?: string;
|
||||||
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
|
|||||||
file: File;
|
file: File;
|
||||||
onImported: (skillNames: string[]) => void;
|
onImported: (skillNames: string[]) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
quota?: WorkspaceQuotaItem;
|
||||||
|
quotaResource?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
|
|||||||
file,
|
file,
|
||||||
onImported,
|
onImported,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
quota,
|
||||||
|
quotaResource = '',
|
||||||
}: SkillZipPreviewPanelProps) {
|
}: SkillZipPreviewPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
||||||
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
|
if (quota?.disabled) return;
|
||||||
if (selectedPaths.length === 0) return;
|
if (selectedPaths.length === 0) return;
|
||||||
|
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{quota ? (
|
||||||
type="button"
|
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||||
onClick={handleInstall}
|
<Button
|
||||||
disabled={
|
type="button"
|
||||||
previewing ||
|
onClick={handleInstall}
|
||||||
installing ||
|
disabled={
|
||||||
previewSkills.length === 0 ||
|
quota.disabled ||
|
||||||
selectedPaths.length === 0
|
previewing ||
|
||||||
}
|
installing ||
|
||||||
>
|
previewSkills.length === 0 ||
|
||||||
{installing ? (
|
selectedPaths.length === 0
|
||||||
<>
|
}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
>
|
||||||
{t('skills.installing')}
|
{installing ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<Loader2 className="size-4 animate-spin" />
|
||||||
<>
|
{t('skills.installing')}
|
||||||
<PackageOpen className="size-4" />
|
</>
|
||||||
{t('skills.confirmInstall')}
|
) : (
|
||||||
</>
|
<>
|
||||||
)}
|
<PackageOpen className="size-4" />
|
||||||
</Button>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ export interface SystemLimitation {
|
|||||||
max_bots: number;
|
max_bots: number;
|
||||||
max_pipelines: number;
|
max_pipelines: number;
|
||||||
max_extensions: number;
|
max_extensions: number;
|
||||||
|
max_knowledge_bases?: number;
|
||||||
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
||||||
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
||||||
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
|
* 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.',
|
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
|
'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: {
|
skills: {
|
||||||
title: '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.',
|
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
|
||||||
maxExtensionsReached:
|
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.',
|
'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: {
|
wizard: {
|
||||||
sidebarDescription: 'Crea un Bot con pasos guiados',
|
sidebarDescription: 'Crea un Bot con pasos guiados',
|
||||||
|
|||||||
@@ -1685,6 +1685,12 @@ const jaJP = {
|
|||||||
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'現在のワークスペース上限を確認できません。もう一度お試しください。',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'ガイド付きステップでボットを作成',
|
sidebarDescription: 'ガイド付きステップでボットを作成',
|
||||||
|
|||||||
@@ -1609,6 +1609,12 @@ const ruRU = {
|
|||||||
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'Создать бота с пошаговым руководством',
|
sidebarDescription: 'Создать бота с пошаговым руководством',
|
||||||
|
|||||||
@@ -1576,6 +1576,12 @@ const thTH = {
|
|||||||
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
||||||
|
quotaLoadingTooltip:
|
||||||
|
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
|
||||||
|
quotaCheckFailed:
|
||||||
|
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
|
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.',
|
'Đã đạ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:
|
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.',
|
'Đã đạ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: {
|
wizard: {
|
||||||
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
||||||
|
|||||||
@@ -1606,6 +1606,10 @@ const zhHans = {
|
|||||||
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
||||||
|
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
|
||||||
|
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
title: '技能',
|
title: '技能',
|
||||||
|
|||||||
@@ -1531,6 +1531,10 @@ const zhHant = {
|
|||||||
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
||||||
maxExtensionsReached:
|
maxExtensionsReached:
|
||||||
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
||||||
|
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
|
||||||
|
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
|
||||||
|
createDisabledTooltip:
|
||||||
|
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
sidebarDescription: '透過引導步驟建立機器人',
|
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