mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
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:
@@ -5,6 +5,7 @@ import {
|
||||
beginAuthenticatedSession,
|
||||
beginSupportAdminSession,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
getPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
import { toast } from 'sonner';
|
||||
@@ -66,6 +67,10 @@ function SpaceOAuthCallbackContent() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const isMountedRef = useRef(true);
|
||||
const directLaunchFragmentRef = useRef<{
|
||||
workspaceUuid: string | null;
|
||||
launchAssertion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
'loading' | 'confirm' | 'success' | 'error'
|
||||
@@ -108,8 +113,31 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
beginAuthenticatedSession(response.token, response.user);
|
||||
if (getPendingInvitationToken()) {
|
||||
navigate('/invitations/accept', { replace: true });
|
||||
const invitationToken = getPendingInvitationToken();
|
||||
if (invitationToken) {
|
||||
let invitation;
|
||||
try {
|
||||
invitation =
|
||||
await httpClient.acceptWorkspaceInvitation(invitationToken);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
const path = code
|
||||
? `/invitations/accept?error=${encodeURIComponent(code)}`
|
||||
: '/invitations/accept';
|
||||
navigate(path, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
beginAuthenticatedSession(invitation.token, response.user);
|
||||
clearPendingInvitationToken();
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: invitation.workspace_uuid,
|
||||
});
|
||||
if (workspaceResult.status === 'unavailable') {
|
||||
navigate('/workspace-unavailable', { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate('/home', { replace: true });
|
||||
return;
|
||||
}
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
@@ -220,8 +248,28 @@ function SpaceOAuthCallbackContent() {
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
const mode = searchParams.get('mode');
|
||||
const state = searchParams.get('state');
|
||||
const workspaceUuid = searchParams.get('workspace_uuid');
|
||||
const launchAssertion = searchParams.get('launch_assertion');
|
||||
if (directLaunchFragmentRef.current === null) {
|
||||
const fragmentParams = new URLSearchParams(
|
||||
window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash,
|
||||
);
|
||||
directLaunchFragmentRef.current = {
|
||||
workspaceUuid: fragmentParams.get('workspace_uuid'),
|
||||
launchAssertion: fragmentParams.get('launch_assertion'),
|
||||
};
|
||||
if (window.location.hash) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const workspaceUuid =
|
||||
directLaunchFragmentRef.current.workspaceUuid ??
|
||||
searchParams.get('workspace_uuid');
|
||||
const launchAssertion = directLaunchFragmentRef.current.launchAssertion;
|
||||
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
|
||||
@@ -49,6 +49,8 @@ import type {
|
||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
||||
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
|
||||
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||
|
||||
@@ -154,6 +156,12 @@ function AddExtensionContent() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||
const { extensions: extensionQuota, extensionsReached } =
|
||||
useWorkspaceQuotaStatus();
|
||||
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
|
||||
resource: t('sidebar.extensions'),
|
||||
max: extensionQuota.max,
|
||||
});
|
||||
|
||||
// Localized label for an extension type, used in the install dialog.
|
||||
const extensionTypeLabel = (type: string) =>
|
||||
@@ -344,23 +352,28 @@ function AddExtensionContent() {
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
plugin_icon: plugin.icon || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
if (extensionsReached) return;
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
plugin_icon: plugin.icon || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
},
|
||||
[extensionsReached],
|
||||
);
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (extensionsReached) return;
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||
httpClient
|
||||
@@ -402,6 +415,7 @@ function AddExtensionContent() {
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (extensionsReached) return;
|
||||
if (!validateFileType(file)) {
|
||||
toast.error(t('addExtension.unsupportedFileType'));
|
||||
return;
|
||||
@@ -421,14 +435,15 @@ function AddExtensionContent() {
|
||||
setSkillUploadPreviewOpen(true);
|
||||
}
|
||||
},
|
||||
[t, setSelectedTaskId],
|
||||
[extensionsReached, t, setSelectedTaskId],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
if (extensionsReached) return;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
}, [extensionsReached]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -455,12 +470,13 @@ function AddExtensionContent() {
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (extensionsReached) return;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[uploadFile],
|
||||
[extensionsReached, uploadFile],
|
||||
);
|
||||
|
||||
function handleMCPCreated(_serverName: string) {
|
||||
@@ -490,7 +506,8 @@ function AddExtensionContent() {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
toast.error(t('limitation.quotaCheckFailed'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -630,9 +647,11 @@ function AddExtensionContent() {
|
||||
|
||||
async function handleGithubConfirm() {
|
||||
if (!selectedAsset || !selectedRelease) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
|
||||
return;
|
||||
}
|
||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
@@ -664,9 +683,11 @@ function AddExtensionContent() {
|
||||
|
||||
async function handleGithubSkillConfirm() {
|
||||
if (!githubSkillInfo) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await httpClient.installSkillFromGithub(
|
||||
githubURL.trim(),
|
||||
@@ -726,17 +747,24 @@ function AddExtensionContent() {
|
||||
setPopoverOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{t('addExtension.manualAdd')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={extensionQuota}
|
||||
resource={t('sidebar.extensions')}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={extensionsReached}
|
||||
aria-disabled={extensionsReached}
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{t('addExtension.manualAdd')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</WorkspaceQuotaTooltip>
|
||||
<PopoverContent
|
||||
forceMount
|
||||
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
||||
@@ -745,9 +773,19 @@ function AddExtensionContent() {
|
||||
{/* ===== Menu View ===== */}
|
||||
{popoverView === 'menu' && (
|
||||
<div className="space-y-4 p-4">
|
||||
{extensionsReached && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
{extensionQuotaTooltip}
|
||||
</div>
|
||||
)}
|
||||
{/* File upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
aria-disabled={extensionsReached}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
extensionsReached
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||
@@ -777,7 +815,8 @@ function AddExtensionContent() {
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setPopoverView('mcp')}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
@@ -796,7 +835,8 @@ function AddExtensionContent() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setPopoverView('github')}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
@@ -815,7 +855,8 @@ function AddExtensionContent() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setPopoverOpen(false);
|
||||
@@ -882,6 +923,7 @@ function AddExtensionContent() {
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
size="sm"
|
||||
disabled={extensionsReached}
|
||||
onClick={async (e) => {
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
e.preventDefault();
|
||||
@@ -946,6 +988,7 @@ function AddExtensionContent() {
|
||||
className="w-full"
|
||||
onClick={handleGithubAddressSubmit}
|
||||
disabled={
|
||||
extensionsReached ||
|
||||
!githubURL.trim() ||
|
||||
fetchingReleases ||
|
||||
fetchingSkillPreview
|
||||
@@ -1102,7 +1145,11 @@ function AddExtensionContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleGithubConfirm}>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1184,6 +1231,7 @@ function AddExtensionContent() {
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubSkillConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
@@ -1240,6 +1288,8 @@ function AddExtensionContent() {
|
||||
<MarketPage
|
||||
installPlugin={handleInstallPlugin}
|
||||
headerActions={extensionActions}
|
||||
installDisabled={extensionsReached}
|
||||
installDisabledTooltip={extensionQuotaTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1325,9 +1375,17 @@ function AddExtensionContent() {
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleModalConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={extensionQuota}
|
||||
resource={t('sidebar.extensions')}
|
||||
>
|
||||
<Button
|
||||
onClick={handleModalConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
</>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
@@ -1359,6 +1417,8 @@ function AddExtensionContent() {
|
||||
{pluginUploadPreviewFile && (
|
||||
<PluginLocalPreviewPanel
|
||||
file={pluginUploadPreviewFile}
|
||||
quota={extensionQuota}
|
||||
quotaResource={t('sidebar.extensions')}
|
||||
onCancel={() => {
|
||||
setPluginUploadPreviewOpen(false);
|
||||
setPluginUploadPreviewFile(null);
|
||||
@@ -1392,6 +1452,8 @@ function AddExtensionContent() {
|
||||
{skillUploadPreviewFile && (
|
||||
<SkillZipPreviewPanel
|
||||
file={skillUploadPreviewFile}
|
||||
quota={extensionQuota}
|
||||
quotaResource={t('sidebar.extensions')}
|
||||
onCancel={() => {
|
||||
setSkillUploadPreviewOpen(false);
|
||||
setSkillUploadPreviewFile(null);
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function BotForm({
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
>([]);
|
||||
const [, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||
|
||||
@@ -409,271 +409,278 @@ export default function BotForm({
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-6"
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.adapterConfigDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="adapter"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.platformAdapter')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px] overflow-hidden">
|
||||
{field.value ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(field.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 shrink-0 rounded"
|
||||
/>
|
||||
{(() => {
|
||||
const selectedAdapter = adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedAdapter?.label ?? field.value}
|
||||
</span>
|
||||
{selectedAdapter?.legacy && (
|
||||
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
{t('bots.legacyAdapterBadge')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectAdapter')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedAdapters.map((group) => (
|
||||
<SelectGroup
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
>
|
||||
{group.categoryId && (
|
||||
<SelectLabel>
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<SelectItem
|
||||
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex min-w-0 w-full items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 shrink-0 rounded"
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
{legacyAdapters.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowLegacyAdapters((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowLegacyAdapters((v) => !v);
|
||||
}
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
|
||||
>
|
||||
{showLegacyAdapters ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('bots.legacyAdapters')}
|
||||
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
|
||||
{legacyAdapters.length}
|
||||
</span>
|
||||
</div>
|
||||
{showLegacyAdapters && (
|
||||
<>
|
||||
<p className="px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t('bots.legacyAdaptersHint')}
|
||||
</p>
|
||||
<SelectGroup>
|
||||
{legacyAdapters.map((item) => (
|
||||
<SelectItem
|
||||
key={`legacy:${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex min-w-0 w-full items-center gap-2 opacity-70">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 shrink-0 rounded grayscale"
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
{t('bots.legacyAdapterBadge')}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentAdapter &&
|
||||
(() => {
|
||||
const docUrl = getAdapterDocUrl(
|
||||
adapterHelpLinks[currentAdapter],
|
||||
i18n.language,
|
||||
);
|
||||
return docUrl ? (
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t('bots.viewAdapterDocs')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FormControl>
|
||||
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
||||
<FormDescription>
|
||||
{adapterDescriptionList[currentAdapter]}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Event Routing */}
|
||||
{currentAdapter && (
|
||||
<fieldset className="space-y-6" disabled={isLoading}>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.eventRoutingDescription')}
|
||||
{t('bots.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EventBindingsEditor
|
||||
form={form}
|
||||
botId={initBotId}
|
||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||
agentOptions={agentNameList}
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 2: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.adapterConfigDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="adapter"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.platformAdapter')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px] overflow-hidden">
|
||||
{field.value ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
field.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 shrink-0 rounded"
|
||||
/>
|
||||
{(() => {
|
||||
const selectedAdapter = adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedAdapter?.label ?? field.value}
|
||||
</span>
|
||||
{selectedAdapter?.legacy && (
|
||||
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
{t('bots.legacyAdapterBadge')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectAdapter')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedAdapters.map((group) => (
|
||||
<SelectGroup
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
>
|
||||
{group.categoryId && (
|
||||
<SelectLabel>
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<SelectItem
|
||||
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex min-w-0 w-full items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 shrink-0 rounded"
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
{legacyAdapters.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowLegacyAdapters((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowLegacyAdapters((v) => !v);
|
||||
}
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
|
||||
>
|
||||
{showLegacyAdapters ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('bots.legacyAdapters')}
|
||||
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
|
||||
{legacyAdapters.length}
|
||||
</span>
|
||||
</div>
|
||||
{showLegacyAdapters && (
|
||||
<>
|
||||
<p className="px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t('bots.legacyAdaptersHint')}
|
||||
</p>
|
||||
<SelectGroup>
|
||||
{legacyAdapters.map((item) => (
|
||||
<SelectItem
|
||||
key={`legacy:${item.value}`}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex min-w-0 w-full items-center gap-2 opacity-70">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 shrink-0 rounded grayscale"
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
{t('bots.legacyAdapterBadge')}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentAdapter &&
|
||||
(() => {
|
||||
const docUrl = getAdapterDocUrl(
|
||||
adapterHelpLinks[currentAdapter],
|
||||
i18n.language,
|
||||
);
|
||||
return docUrl ? (
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t('bots.viewAdapterDocs')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FormControl>
|
||||
{currentAdapter &&
|
||||
adapterDescriptionList[currentAdapter] && (
|
||||
<FormDescription>
|
||||
{adapterDescriptionList[currentAdapter]}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Event Routing */}
|
||||
{currentAdapter && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.eventRouting')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.eventRoutingDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EventBindingsEditor
|
||||
form={form}
|
||||
botId={initBotId}
|
||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||
agentOptions={agentNameList}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||
|
||||
/**
|
||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||
@@ -109,15 +110,15 @@ function PluginPageIframe({
|
||||
pageId: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const assetUrl = useMemo(() => {
|
||||
const url = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}_lb_page_v=${Date.now()}`;
|
||||
}, [author, pluginName, pagePath]);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
|
||||
author,
|
||||
pluginName,
|
||||
pagePath,
|
||||
);
|
||||
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
|
||||
|
||||
// Send context (theme + language) to iframe
|
||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||
@@ -203,23 +204,29 @@ function PluginPageIframe({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{loading && (
|
||||
{assetError ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('plugins.loadFailed')}
|
||||
</div>
|
||||
) : loading || !assetUrl ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : null}
|
||||
{!assetError && assetUrl && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoadedAssetUrl(assetUrl);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
|
||||
type PluginLocalPreview = Awaited<
|
||||
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
||||
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
|
||||
file: File;
|
||||
onInstallStarted?: () => void;
|
||||
onCancel?: () => void;
|
||||
quota?: WorkspaceQuotaItem;
|
||||
quotaResource?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
|
||||
file,
|
||||
onInstallStarted,
|
||||
onCancel,
|
||||
quota,
|
||||
quotaResource = '',
|
||||
}: PluginLocalPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
||||
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
|
||||
}, [loadPreview]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (quota?.disabled) return;
|
||||
setInstalling(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={!preview || previewing || installing}
|
||||
>
|
||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
{quota ? (
|
||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={quota.disabled || !preview || previewing || installing}
|
||||
>
|
||||
{installing
|
||||
? t('plugins.installing')
|
||||
: t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={!preview || previewing || installing}
|
||||
>
|
||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -101,9 +101,13 @@ function loadMarketFilters(): MarketFilters {
|
||||
function MarketPageContent({
|
||||
installPlugin,
|
||||
headerActions,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
headerActions?: React.ReactNode;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -882,6 +886,8 @@ function MarketPageContent({
|
||||
lists={recommendationLists}
|
||||
tagNames={tagNames}
|
||||
onInstall={handleInstallPlugin}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -911,6 +917,8 @@ function MarketPageContent({
|
||||
cardVO={plugin}
|
||||
onInstall={handleInstallPlugin}
|
||||
tagNames={tagNames}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -950,9 +958,13 @@ function MarketPageContent({
|
||||
export default function MarketPage({
|
||||
installPlugin,
|
||||
headerActions,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
headerActions?: React.ReactNode;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -967,6 +979,8 @@ export default function MarketPage({
|
||||
<MarketPageContent
|
||||
installPlugin={installPlugin}
|
||||
headerActions={headerActions}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -54,11 +54,15 @@ function RecommendationListRow({
|
||||
list,
|
||||
tagNames,
|
||||
onInstall,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
isLast,
|
||||
}: {
|
||||
list: RecommendationList;
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -263,6 +267,8 @@ function RecommendationListRow({
|
||||
cardVO={pluginToVO(plugin, t)}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -277,10 +283,14 @@ export function RecommendationLists({
|
||||
lists,
|
||||
tagNames,
|
||||
onInstall,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
lists: RecommendationList[];
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
if (!lists || lists.length === 0) return null;
|
||||
|
||||
@@ -292,6 +302,8 @@ export function RecommendationLists({
|
||||
list={list}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
isLast={index === lists.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
+26
-3
@@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({
|
||||
cardVO,
|
||||
onInstall,
|
||||
tagNames = {},
|
||||
installDisabled = false,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
cardVO: PluginMarketCardVO;
|
||||
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
||||
tagNames?: Record<string, string>;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
@@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({
|
||||
|
||||
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||
const handleInstallClick = () => {
|
||||
if (installDisabled) return;
|
||||
onInstall?.(cardVO);
|
||||
};
|
||||
|
||||
@@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const cardContent = (
|
||||
<div
|
||||
role="button"
|
||||
role={installDisabled ? 'group' : 'button'}
|
||||
tabIndex={0}
|
||||
aria-disabled={installDisabled}
|
||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
||||
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
|
||||
installDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)]'
|
||||
}`}
|
||||
onClick={handleInstallClick}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!installDisabled || !installDisabledTooltip) return cardContent;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72 text-left">
|
||||
{installDisabledTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { Skill } from '@/app/infra/entities/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
|
||||
interface PreviewSkill extends Skill {
|
||||
source_path?: string;
|
||||
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
|
||||
file: File;
|
||||
onImported: (skillNames: string[]) => void;
|
||||
onCancel?: () => void;
|
||||
quota?: WorkspaceQuotaItem;
|
||||
quotaResource?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
|
||||
file,
|
||||
onImported,
|
||||
onCancel,
|
||||
quota,
|
||||
quotaResource = '',
|
||||
}: SkillZipPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
||||
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
if (quota?.disabled) return;
|
||||
if (selectedPaths.length === 0) return;
|
||||
|
||||
setInstalling(true);
|
||||
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{quota ? (
|
||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
quota.disabled ||
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -437,6 +437,11 @@ export interface SystemLimitation {
|
||||
max_bots: number;
|
||||
max_pipelines: number;
|
||||
max_extensions: number;
|
||||
max_knowledge_bases?: number;
|
||||
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
||||
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
||||
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
|
||||
force_box_session_id_template?: string;
|
||||
}
|
||||
|
||||
export interface WizardProgress {
|
||||
|
||||
@@ -1252,6 +1252,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
public getAccountInfo(): Promise<{
|
||||
initialized: boolean;
|
||||
authenticated_invitation_acceptance_enabled?: boolean;
|
||||
password_login_enabled?: boolean;
|
||||
space_login_enabled?: boolean;
|
||||
}> {
|
||||
|
||||
@@ -93,6 +93,10 @@ export default function AcceptInvitationPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
useState(false);
|
||||
const [
|
||||
authenticatedInvitationAcceptanceEnabled,
|
||||
setAuthenticatedInvitationAcceptanceEnabled,
|
||||
] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setInvitationHash(window.location.hash);
|
||||
@@ -113,6 +117,9 @@ export default function AcceptInvitationPage() {
|
||||
.getAccountInfo()
|
||||
.then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
setAuthenticatedInvitationAcceptanceEnabled(
|
||||
info.authenticated_invitation_acceptance_enabled === true,
|
||||
);
|
||||
})
|
||||
.catch(() => setPasswordRegistrationEnabled(false));
|
||||
if (!invitationToken) {
|
||||
@@ -304,7 +311,18 @@ export default function AcceptInvitationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken ? (
|
||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => void finishAcceptance()}
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{t('workspace.acceptInvitation')}
|
||||
</Button>
|
||||
) : hasLoginToken ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t('workspace.authenticatedInvitationNotice')}
|
||||
|
||||
@@ -119,7 +119,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,41 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
type AuthenticatedResourceState = {
|
||||
key: string;
|
||||
url: string;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_RESOURCE: AuthenticatedResourceState = {
|
||||
key: '',
|
||||
url: '',
|
||||
error: false,
|
||||
};
|
||||
|
||||
export function useAuthenticatedPluginIcon(
|
||||
author: string,
|
||||
name: string,
|
||||
enabled = true,
|
||||
): { url: string; error: boolean } {
|
||||
const [url, setURL] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [resource, setResource] =
|
||||
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setURL('');
|
||||
setError(false);
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setURL('');
|
||||
setError(false);
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) setURL(nextURL);
|
||||
else URL.revokeObjectURL(nextURL);
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||
} else {
|
||||
URL.revokeObjectURL(nextURL);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: '', error: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, enabled, name]);
|
||||
}, [author, enabled, name, resourceKey]);
|
||||
|
||||
return { url, error };
|
||||
return {
|
||||
url: resource.key === resourceKey ? resource.url : '',
|
||||
error: resource.key === resourceKey && resource.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthenticatedPluginAsset(
|
||||
@@ -43,29 +65,39 @@ export function useAuthenticatedPluginAsset(
|
||||
name: string,
|
||||
filepath: string,
|
||||
): { url: string; error: boolean } {
|
||||
const [url, setURL] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [resource, setResource] =
|
||||
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setURL('');
|
||||
setError(false);
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
httpClient
|
||||
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) setURL(nextURL);
|
||||
else URL.revokeObjectURL(nextURL);
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||
} else {
|
||||
URL.revokeObjectURL(nextURL);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: '', error: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, name, filepath]);
|
||||
}, [author, name, filepath, resourceKey]);
|
||||
|
||||
return { url, error };
|
||||
return {
|
||||
url: resource.key === resourceKey ? resource.url : '',
|
||||
error: resource.key === resourceKey && resource.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1928,6 +1928,12 @@ const enUS = {
|
||||
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
||||
maxExtensionsReached:
|
||||
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
|
||||
quotaLoadingTooltip:
|
||||
'Workspace usage is still loading. Please wait before creating a resource.',
|
||||
quotaCheckFailed:
|
||||
'Unable to verify the current workspace quota. Please try again.',
|
||||
createDisabledTooltip:
|
||||
'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
|
||||
@@ -1693,6 +1693,12 @@ const esES = {
|
||||
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
|
||||
maxExtensionsReached:
|
||||
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
|
||||
quotaLoadingTooltip:
|
||||
'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.',
|
||||
quotaCheckFailed:
|
||||
'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.',
|
||||
createDisabledTooltip:
|
||||
'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Crea un Bot con pasos guiados',
|
||||
|
||||
@@ -1943,6 +1943,12 @@ const jaJP = {
|
||||
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
||||
maxExtensionsReached:
|
||||
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
||||
quotaLoadingTooltip:
|
||||
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
|
||||
quotaCheckFailed:
|
||||
'現在のワークスペース上限を確認できません。もう一度お試しください。',
|
||||
createDisabledTooltip:
|
||||
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'ガイド付きステップでボットを作成',
|
||||
|
||||
@@ -1666,6 +1666,12 @@ const ruRU = {
|
||||
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
||||
maxExtensionsReached:
|
||||
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
||||
quotaLoadingTooltip:
|
||||
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
|
||||
quotaCheckFailed:
|
||||
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
|
||||
createDisabledTooltip:
|
||||
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Создать бота с пошаговым руководством',
|
||||
|
||||
@@ -1633,6 +1633,12 @@ const thTH = {
|
||||
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
||||
maxExtensionsReached:
|
||||
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
||||
quotaLoadingTooltip:
|
||||
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
|
||||
quotaCheckFailed:
|
||||
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
|
||||
createDisabledTooltip:
|
||||
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
|
||||
|
||||
@@ -1659,6 +1659,12 @@ const viVN = {
|
||||
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
|
||||
maxExtensionsReached:
|
||||
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
|
||||
quotaLoadingTooltip:
|
||||
'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.',
|
||||
quotaCheckFailed:
|
||||
'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.',
|
||||
createDisabledTooltip:
|
||||
'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
||||
|
||||
@@ -1846,6 +1846,10 @@ const zhHans = {
|
||||
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
||||
maxExtensionsReached:
|
||||
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
||||
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
|
||||
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
|
||||
createDisabledTooltip:
|
||||
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
|
||||
},
|
||||
skills: {
|
||||
title: '技能',
|
||||
|
||||
@@ -1585,6 +1585,10 @@ const zhHant = {
|
||||
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
||||
maxExtensionsReached:
|
||||
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
||||
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
|
||||
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
|
||||
createDisabledTooltip:
|
||||
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: '透過引導步驟建立機器人',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
function wrapped(data: unknown) {
|
||||
return JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
test('Cloud never exposes or requests storage analysis', async ({ page }) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-cloud',
|
||||
'Cloud Workspace',
|
||||
'cloud_projection',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
await page.route(
|
||||
/\/api\/v1\/workspaces\/workspace-cloud\/(members|invitations)$/,
|
||||
async (route) => {
|
||||
const collection = route.request().url().endsWith('/members')
|
||||
? 'members'
|
||||
: 'invitations';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({ [collection]: [] }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let storageAnalysisRequests = 0;
|
||||
await page.route('**/api/v1/system/storage-analysis', async (route) => {
|
||||
storageAnalysisRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: /admin@example\.com/i }).click();
|
||||
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.goto('/home/bots?action=showStorageAnalysis');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Workspace' })).toBeVisible();
|
||||
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(storageAnalysisRequests).toBe(0);
|
||||
});
|
||||
@@ -170,7 +170,6 @@ export function makeWorkspaceEntry(
|
||||
'member.remove',
|
||||
'member.update_role',
|
||||
'member.view',
|
||||
'owner.transfer',
|
||||
'provider_secret.manage',
|
||||
'resource.manage',
|
||||
'resource.view',
|
||||
|
||||
@@ -165,3 +165,164 @@ test('an authenticated OSS invitation requires logout before registration', asyn
|
||||
invitation: 'logout-invitation',
|
||||
});
|
||||
});
|
||||
|
||||
test('an authenticated Cloud Account can accept its invitation directly', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
storage: {
|
||||
token: 'invited-account-token',
|
||||
userEmail: 'invited@example.com',
|
||||
},
|
||||
});
|
||||
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
initialized: true,
|
||||
authenticated_invitation_acceptance_enabled: true,
|
||||
password_login_enabled: false,
|
||||
space_login_enabled: true,
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'cloud-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let acceptanceAuthorization = '';
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'accepted-cloud-account-token',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=cloud-invitation');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Accept Invitation' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Accept Invitation' }).click();
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
expect(acceptanceAuthorization).toBe('Bearer invited-account-token');
|
||||
});
|
||||
|
||||
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: false,
|
||||
storage: {
|
||||
token: 'stale-other-account-token',
|
||||
userEmail: 'other@example.com',
|
||||
},
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
sessionStorage.setItem(
|
||||
'langbot_pending_invitation_token',
|
||||
'matching-invitation',
|
||||
);
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/user/space/callback', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'fresh-invited-account-token',
|
||||
user: 'invited@example.com',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/user/info', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
account_uuid: 'invited-account',
|
||||
user: 'invited@example.com',
|
||||
account_type: 'space',
|
||||
has_password: false,
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let acceptanceAuthorization = '';
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'accepted-invited-account-token',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token');
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
token: localStorage.getItem('token'),
|
||||
userEmail: localStorage.getItem('userEmail'),
|
||||
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||
})),
|
||||
).toEqual({
|
||||
token: 'accepted-invited-account-token',
|
||||
userEmail: 'invited@example.com',
|
||||
invitation: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
function wrapped(data: unknown) {
|
||||
return JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-cloud',
|
||||
'Cloud Workspace',
|
||||
'cloud_projection',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/plugins', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({
|
||||
plugins: [
|
||||
{
|
||||
install_source: 'marketplace',
|
||||
install_info: {},
|
||||
debug: false,
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: {
|
||||
author: 'langbot-team',
|
||||
name: 'LangRAG',
|
||||
version: '0.1.9',
|
||||
label: { en_US: 'LangRAG', zh_Hans: 'LangRAG' },
|
||||
},
|
||||
spec: {
|
||||
pages: [
|
||||
{
|
||||
id: 'observability',
|
||||
path: 'components/pages/observability.html',
|
||||
label: { en_US: 'Observability', zh_Hans: '观测面板' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let authenticatedAssetRequests = 0;
|
||||
await page.route(
|
||||
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
|
||||
async (route) => {
|
||||
authenticatedAssetRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('iframe')
|
||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||
).toBeVisible();
|
||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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).toBeEnabled();
|
||||
|
||||
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 addExtension.click();
|
||||
await expect(page).toHaveURL(/\/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();
|
||||
});
|
||||
@@ -46,4 +46,14 @@ test('provider card represents owner and member owner-bound states explicitly',
|
||||
assert.match(source, /ownerSpaceBound/);
|
||||
assert.match(source, /models\.ownerMustBindSpace/);
|
||||
assert.match(source, /models\.usesOwnerSpaceBilling/);
|
||||
assert.match(source, /isWorkspaceOwner && \(\s*<Button/);
|
||||
});
|
||||
|
||||
test('workspace member controls never offer ownership transfer', () => {
|
||||
const source = read(
|
||||
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
|
||||
);
|
||||
assert.doesNotMatch(source, /canTransferOwner/);
|
||||
assert.doesNotMatch(source, /workspace\.transferOwnership/);
|
||||
assert.doesNotMatch(source, /<SelectItem value="owner">/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(currentDirectory, '../..');
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
const homeSidebarSource = readSource(
|
||||
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||
);
|
||||
const botFormSource = readSource(
|
||||
'src/app/home/bots/components/bot-form/BotForm.tsx',
|
||||
);
|
||||
const kbFormSource = readSource(
|
||||
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
|
||||
);
|
||||
const settingsDialogSource = readSource(
|
||||
'src/app/home/components/settings-dialog/SettingsDialog.tsx',
|
||||
);
|
||||
const pluginPageSource = readSource('src/app/home/plugin-pages/page.tsx');
|
||||
const authenticatedPluginResourceSource = readSource(
|
||||
'src/hooks/useAuthenticatedPluginResource.ts',
|
||||
);
|
||||
|
||||
test('hides the entire workspace switcher slot for a singleton local workspace', () => {
|
||||
assert.match(homeSidebarSource, /useWorkspaceBootstrap/);
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
|
||||
);
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>\s*<WorkspaceSwitcher/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps bot cards at the same vertical spacing as knowledge-base cards', () => {
|
||||
assert.match(
|
||||
botFormSource,
|
||||
/<fieldset className="space-y-6" disabled=\{isLoading\}>/,
|
||||
);
|
||||
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
|
||||
});
|
||||
|
||||
test('does not expose storage analysis in Cloud settings or via a deep link', () => {
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/canViewStorageAnalysis\s*&&\s*\(\s*<DropdownMenuItem[\s\S]*?openSettings\('storageAnalysis'\)/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/const canViewStorageAnalysis\s*=\s*currentWorkspace\?\.workspace\.source\s*!==\s*'cloud_projection'\s*&&\s*canViewAudit/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/item\.id === 'storageAnalysis'[\s\S]*?return canViewStorageAnalysis/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/section === 'storageAnalysis' && !canViewStorageAnalysis/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/section === 'storageAnalysis' &&\s*canViewStorageAnalysis &&\s*\(\s*<StorageAnalysisPanel/,
|
||||
);
|
||||
});
|
||||
|
||||
test('loads plugin pages through the authenticated Workspace-scoped asset route', () => {
|
||||
assert.match(pluginPageSource, /useAuthenticatedPluginAsset/);
|
||||
assert.match(
|
||||
pluginPageSource,
|
||||
/useAuthenticatedPluginAsset\(\s*author,\s*pluginName,\s*pagePath,?\s*\)/,
|
||||
);
|
||||
assert.match(pluginPageSource, /src=\{assetUrl\}/);
|
||||
assert.doesNotMatch(pluginPageSource, /getPluginAssetURL\(/);
|
||||
assert.match(pluginPageSource, /plugins\.loadFailed/);
|
||||
assert.match(pluginPageSource, /loadedAssetUrl !== assetUrl/);
|
||||
});
|
||||
|
||||
test('revokes and reloads authenticated plugin resources when the Workspace changes', () => {
|
||||
assert.match(authenticatedPluginResourceSource, /useCurrentWorkspace/);
|
||||
assert.match(
|
||||
authenticatedPluginResourceSource,
|
||||
/const workspaceUuid = currentWorkspace\?\.workspace\.uuid;/,
|
||||
);
|
||||
assert.match(
|
||||
authenticatedPluginResourceSource,
|
||||
/\[author, name, filepath, resourceKey\]/,
|
||||
);
|
||||
assert.match(
|
||||
authenticatedPluginResourceSource,
|
||||
/resource\.key === resourceKey \? resource\.url : ''/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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 baseTooltipPath = path.join(root, 'src/components/ui/tooltip.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');
|
||||
const baseTooltip = fs.readFileSync(baseTooltipPath, '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(tooltip, /max-w-72 text-left/);
|
||||
assert.doesNotMatch(tooltip, /text-center/);
|
||||
assert.doesNotMatch(baseTooltip, /text-balance/);
|
||||
assert.match(source, /config\.id === 'add-extension'/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/config\.id === 'add-extension'\s*\?\s*quotaStatus\.extensions/,
|
||||
);
|
||||
});
|
||||
|
||||
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(card, /max-w-72 text-left/);
|
||||
assert.doesNotMatch(card, /max-w-72 text-center/);
|
||||
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, /删除.*后再/);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/app/auth/space/callback/page.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('direct launch assertion is fragment-only and removed before exchange', () => {
|
||||
assert.doesNotMatch(source, /searchParams\.get\(['"]launch_assertion['"]\)/);
|
||||
const readIndex = source.indexOf("fragmentParams.get('launch_assertion')");
|
||||
const clearIndex = source.indexOf('window.history.replaceState');
|
||||
const exchangeIndex = source.indexOf('handleOAuthCallback(', clearIndex);
|
||||
assert.ok(readIndex >= 0, 'fragment assertion read is missing');
|
||||
assert.ok(
|
||||
clearIndex > readIndex,
|
||||
'URL fragment is not cleared after copying the assertion',
|
||||
);
|
||||
assert.ok(
|
||||
exchangeIndex > clearIndex,
|
||||
'assertion exchange starts before the fragment is cleared',
|
||||
);
|
||||
});
|
||||
@@ -50,15 +50,22 @@ test('places WorkspaceSwitcher between the sidebar header and Home navigation',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
|
||||
test('hides WorkspaceSwitcher for the singleton local OSS workspace and keeps it for Cloud or multiple workspaces', () => {
|
||||
assert.match(
|
||||
workspaceSwitcherSource,
|
||||
/if \(!currentWorkspace\) return null;/,
|
||||
);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200}<WorkspaceSwitcher/,
|
||||
/const workspaces = useWorkspaceBootstrap\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
|
||||
);
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>[\s\S]*?<WorkspaceSwitcher/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user