Merge remote-tracking branch 'origin/master' into feat/workflow

This commit is contained in:
Typer_Body
2026-05-07 00:57:56 +08:00
89 changed files with 10746 additions and 4545 deletions
+8 -5
View File
@@ -174,11 +174,14 @@ export default function BotDetailContent({ id }: { id: string }) {
</div>
)}
</div>
{activeTab === 'config' && (
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
)}
<Button
type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
</div>
{/* Horizontal Tabs */}
@@ -576,6 +576,8 @@ export default function BotForm({
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
}}
/>
)}
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/app/utils/clipboard';
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
@@ -31,36 +32,19 @@ export function BotLogCard({
function copySessionId() {
const text = botLog.message_session_id;
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
copyToClipboard(text)
.then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
})
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
} catch {
toast.error(t('common.copyFailed'));
}
document.body.removeChild(ta);
} else {
toast.error(t('common.copyFailed'));
}
})
.catch(() => {
toast.error(t('common.copyFailed'));
});
}
function formatTime(timestamp: number) {
@@ -19,6 +19,7 @@ import {
ThumbsUp,
ThumbsDown,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
MessageChainComponent,
Plain,
@@ -108,10 +109,9 @@ const BotSessionMonitor = forwardRef<
};
const copyUserId = (userId: string) => {
navigator.clipboard.writeText(userId).then(() => {
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
});
copyToClipboard(userId).catch(() => {});
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
};
const loadSessions = useCallback(async () => {
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
@@ -86,6 +86,9 @@ export default function ApiIntegrationDialog({
const [newWebhookDescription, setNewWebhookDescription] = useState('');
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Sync URL with dialog state
@@ -182,10 +185,29 @@ export default function ApiIntegrationDialog({
}
};
const copyToClipboard = (text: string) => {
const el = document.createElement('span');
el.textContent = text;
el.style.cssText =
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
document.body.appendChild(el);
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('copy');
sel?.removeAllRanges();
document.body.removeChild(el);
};
const handleCopyKey = (key: string) => {
navigator.clipboard.writeText(key);
try {
copyToClipboard(key);
} catch {}
clearTimeout(copiedTimerRef.current);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
};
const maskApiKey = (key: string) => {
@@ -330,21 +352,21 @@ export default function ApiIntegrationDialog({
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{key.description}
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(key.key)}
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
@@ -352,10 +374,11 @@ export default function ApiIntegrationDialog({
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyKey(key.key)}
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === key.key ? (
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
@@ -364,7 +387,7 @@ export default function ApiIntegrationDialog({
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(key.id)}
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
@@ -630,44 +653,6 @@ export default function ApiIntegrationDialog({
</DialogContent>
</Dialog>
{/* Delete API Key Confirmation Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
{copiedKey === createdKey?.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>
@@ -18,6 +18,7 @@ import {
resolveI18nLabel,
maybeTranslateKey,
} from '@/app/home/workflows/components/workflow-editor/workflow-i18n';
import { extractI18nObject } from '@/i18n/I18nProvider';
// Helper function to translate i18n key if the value is an i18n key string
const translateIfKey = (value: string | undefined): string | undefined => {
@@ -38,6 +39,7 @@ import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http';
/**
@@ -67,6 +69,54 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
/**
* Display-only component for embed code fields with copy animation.
*/
function EmbedCodeField({
label,
description,
snippet,
}: {
label: string;
description?: string;
snippet: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
copyToClipboard(snippet).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium leading-none">{label}</label>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="flex items-center gap-2">
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
<code>{snippet}</code>
</pre>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
);
}
/**
* Display-only component for webhook URL fields.
* Rendered outside of react-hook-form binding since the value is
@@ -88,15 +138,9 @@ function WebhookUrlField({
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setter(true);
setTimeout(() => setter(false), 2000);
})
.catch(() => {});
}
copyToClipboard(text).catch(() => {});
setter(true);
setTimeout(() => setter(false), 2000);
};
return (
@@ -259,10 +303,13 @@ export default function DynamicFormComponent({
return value;
};
// Filter out display-only field types (e.g. webhook-url) that should not
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
// participate in form state, validation, or value emission.
const editableItems = useMemo(
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
() =>
itemConfigList.filter(
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
),
[itemConfigList],
);
@@ -510,6 +557,38 @@ export default function DynamicFormComponent({
);
}
if (config.type === 'embed-code') {
const botUuid = (systemContext?.bot_uuid as string) || '';
if (!botUuid) return null;
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
const widgetTitle =
((systemContext?.adapter_config as Record<string, unknown>)
?.title as string) || 'LangBot';
const safeTitle = widgetTitle
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
return (
<EmbedCodeField
key={config.id}
label={extractAndTranslateI18n(config.label)}
description={
config.description
? typeof config.description === 'string'
? translateIfKey(config.description) || config.description
: extractI18nObject(config.description)
: undefined
}
snippet={embedSnippet}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (
@@ -26,6 +26,7 @@ import {
Store,
Github,
Zap,
HardDrive,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -55,6 +56,7 @@ import AccountSettingsDialog from '@/app/home/components/account-settings-dialog
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner';
@@ -705,87 +707,103 @@ function NavItems({
>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={false}
onClick={() => {
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}}
tooltip={config.name}
className="group/category-header"
>
{config.icon}
<span>{config.name}</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
role="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 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<div
role="button"
tabIndex={0}
onClick={() => {
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}
}}
>
{config.icon}
<span>{config.name}</span>
<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 && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
setPendingPluginInstallAction('local');
navigate('/home/plugins');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
role="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 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</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>
))}
<CollapsibleTrigger asChild>
<button
type="button"
className="p-1 rounded-sm hover:bg-sidebar-accent"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</div>
))}
<CollapsibleTrigger asChild>
<div
role="button"
className="p-1 rounded-sm hover:bg-sidebar-accent cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</div>
</CollapsibleTrigger>
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button>
</CollapsibleTrigger>
</div>
</div>
</SidebarMenuButton>
<CollapsibleContent>
@@ -1029,6 +1047,127 @@ function PluginItemMenu({
);
}
// Plugin pages navigation section — grouped by plugin
function PluginPagesNav() {
const { pluginPages } = useSidebarData();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
if (pluginPages.length === 0) return null;
const pathname = location.pathname;
const currentId =
pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
// Group pages by plugin (author/name)
const grouped = new Map<
string,
{ label: string; iconURL: string; pages: typeof pluginPages }
>();
for (const page of pluginPages) {
const key = `${page.pluginAuthor}/${page.pluginName}`;
if (!grouped.has(key)) {
grouped.set(key, {
label: page.pluginLabel,
iconURL: page.pluginIconURL,
pages: [],
});
}
grouped.get(key)!.pages.push(page);
}
return (
<SidebarGroup>
<SidebarGroupLabel title={t('sidebar.pluginPagesTooltip')}>
{t('sidebar.pluginPages')}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Array.from(grouped.entries()).map(
([pluginKey, { label, iconURL, pages }]) => {
const hasActivePage = pages.some((p) => p.id === currentId);
const pluginIcon = (
<img
src={iconURL}
alt=""
className="size-4 rounded-sm object-cover shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
);
// Single page — render directly without nesting
if (pages.length === 1) {
const page = pages[0];
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuItem key={page.id}>
<SidebarMenuButton
isActive={isActive}
tooltip={page.name}
onClick={() => navigate(route)}
className="select-none"
>
{pluginIcon}
<span>{page.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
// Multiple pages — collapsible group
return (
<Collapsible
key={pluginKey}
defaultOpen={hasActivePage}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={label}
className="select-none"
>
{pluginIcon}
<span>{label}</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{pages.map((page) => {
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton
isActive={isActive}
onClick={() => navigate(route)}
className="select-none"
>
<span>{page.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
},
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
export default function HomeSidebar({
onSelectedChangeAction,
}: {
@@ -1054,6 +1193,9 @@ export default function HomeSidebar({
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
if (searchParams.get('action') === 'showStorageAnalysis') {
setStorageAnalysisOpen(true);
}
}, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
@@ -1069,6 +1211,7 @@ export default function HomeSidebar({
const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -1108,6 +1251,24 @@ export default function HomeSidebar({
}
}
function handleStorageAnalysisChange(open: boolean) {
setStorageAnalysisOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showStorageAnalysis');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
}
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
@@ -1291,6 +1452,7 @@ export default function HomeSidebar({
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<PluginPagesNav />
</SidebarContent>
{/* Footer */}
@@ -1413,6 +1575,15 @@ export default function HomeSidebar({
<Settings />
{t('account.settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
handleStorageAnalysisChange(true);
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
@@ -1513,6 +1684,10 @@ export default function HomeSidebar({
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</>
);
}
@@ -31,6 +31,18 @@ export interface SidebarEntityItem {
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
name: string; // display label
pluginAuthor: string;
pluginName: string;
pluginLabel: string; // human-readable plugin display name
pluginIconURL: string; // plugin icon URL
pageId: string;
path: string; // asset path (HTML file)
}
// Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue {
bots: SidebarEntityItem[];
@@ -39,6 +51,7 @@ export interface SidebarDataContextValue {
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
pluginPages: PluginPageItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshWorkflows: () => Promise<void>;
@@ -67,6 +80,7 @@ export function SidebarDataProvider({
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
@@ -158,33 +172,69 @@ export function SidebarDataProvider({
}
}
setPlugins(
pluginsResp.plugins.map((plugin) => {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const compositeKey = `${author}/${name}`;
const installedVersion = meta.version ?? '';
// Deduplicate plugins by composite key (prefer debug over installed)
const pluginMap = new Map<string, SidebarEntityItem>();
for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const compositeKey = `${author}/${name}`;
const installedVersion = meta.version ?? '';
let hasUpdate = false;
if (plugin.install_source === 'marketplace') {
const latestVersion = marketplaceVersions.get(compositeKey);
if (latestVersion) {
hasUpdate = isNewerVersion(latestVersion, installedVersion);
let hasUpdate = false;
if (plugin.install_source === 'marketplace') {
const latestVersion = marketplaceVersions.get(compositeKey);
if (latestVersion) {
hasUpdate = isNewerVersion(latestVersion, installedVersion);
}
}
const item: SidebarEntityItem = {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
debug: plugin.debug,
};
// If duplicate, prefer debug version
if (!pluginMap.has(compositeKey) || plugin.debug) {
pluginMap.set(compositeKey, item);
}
}
setPlugins(Array.from(pluginMap.values()));
// Extract plugin pages from spec.pages (deduplicate by id)
const pages: PluginPageItem[] = [];
const seenPageIds = new Set<string>();
for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const label = meta.label ? extractI18nObject(meta.label) : name;
const spec = plugin.manifest.manifest.spec;
if (spec?.pages && Array.isArray(spec.pages)) {
for (const page of spec.pages) {
const pageId = `${author}/${name}/${page.id}`;
if (page.id && page.path && !seenPageIds.has(pageId)) {
seenPageIds.add(pageId);
pages.push({
id: pageId,
name: page.label ? extractI18nObject(page.label) : page.id,
pluginAuthor: author,
pluginName: name,
pluginLabel: label,
pluginIconURL: httpClient.getPluginIconURL(author, name),
pageId: page.id,
path: page.path,
});
}
}
return {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
debug: plugin.debug,
};
}),
);
}
}
setPluginPages(pages);
} catch (error) {
console.error('Failed to fetch plugins for sidebar:', error);
}
@@ -238,6 +288,7 @@ export function SidebarDataProvider({
knowledgeBases,
plugins,
mcpServers,
pluginPages,
refreshBots,
refreshPipelines,
refreshWorkflows,
@@ -29,15 +29,36 @@ interface ModelsDialogProps {
onOpenChange: (open: boolean) => void;
}
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
function convertExtraArgsToObject(
args: ExtraArg[],
): Record<string, string | number | boolean> {
const obj: Record<string, string | number | boolean> = {};
): Record<string, ExtraArgValue> {
const obj: Record<string, ExtraArgValue> = {};
args.forEach((arg) => {
if (arg.key.trim()) {
if (arg.type === 'number') obj[arg.key] = Number(arg.value);
else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true';
else obj[arg.key] = arg.value;
if (!arg.key.trim()) return;
if (arg.type === 'number') {
obj[arg.key] = Number(arg.value);
} else if (arg.type === 'boolean') {
obj[arg.key] = arg.value === 'true';
} else if (arg.type === 'object') {
const raw = arg.value.trim() || '{}';
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
}
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
}
obj[arg.key] = parsed as Record<string, unknown>;
} else {
obj[arg.key] = arg.value;
}
});
return obj;
@@ -258,11 +258,16 @@ export default function AddModelPopover({
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
align="end"
side="left"
sideOffset={8}
collisionPadding={16}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
@@ -437,7 +442,7 @@ export default function AddModelPopover({
</div>
<div
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
className="h-64 overflow-y-auto overscroll-none rounded-md border"
onWheel={(e) => e.stopPropagation()}
>
<div className="p-3 space-y-2">
@@ -1,6 +1,7 @@
import { Plus, X, HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -47,9 +48,30 @@ export default function ExtraArgsEditor({
) => {
const newArgs = [...args];
newArgs[index] = { ...newArgs[index], [field]: value };
// When switching to object type, seed an empty JSON object so the textarea
// doesn't start with an unparseable empty string.
if (
field === 'type' &&
value === 'object' &&
!newArgs[index].value.trim()
) {
newArgs[index].value = '{}';
}
onChange(newArgs);
};
const isInvalidJson = (raw: string) => {
if (!raw.trim()) return false;
try {
const parsed = JSON.parse(raw);
return (
parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
);
} catch {
return true;
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -90,49 +112,79 @@ export default function ExtraArgsEditor({
{args.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('common.none')}</p>
) : (
args.map((arg, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder={t('models.keyName')}
value={arg.key}
className="flex-1"
disabled={disabled}
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
/>
<Select
value={arg.type}
disabled={disabled}
onValueChange={(value) => handleUpdate(index, 'type', value)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t('models.string')}</SelectItem>
<SelectItem value="number">{t('models.number')}</SelectItem>
<SelectItem value="boolean">{t('models.boolean')}</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
className="flex-1"
disabled={disabled}
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
/>
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))
args.map((arg, index) => {
const isObject = arg.type === 'object';
const jsonError = isObject && isInvalidJson(arg.value);
return (
<div key={index} className="space-y-1">
<div className="flex gap-2 items-start">
<Input
placeholder={t('models.keyName')}
value={arg.key}
className={isObject ? 'flex-[2]' : 'flex-1'}
disabled={disabled}
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
/>
<Select
value={arg.type}
disabled={disabled}
onValueChange={(value) => handleUpdate(index, 'type', value)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t('models.string')}</SelectItem>
<SelectItem value="number">{t('models.number')}</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
<SelectItem value="object">{t('models.object')}</SelectItem>
</SelectContent>
</Select>
{!isObject && (
<Input
placeholder={t('models.value')}
value={arg.value}
className="flex-1"
disabled={disabled}
onChange={(e) =>
handleUpdate(index, 'value', e.target.value)
}
/>
)}
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isObject && (
<Textarea
placeholder={t('models.objectJsonPlaceholder')}
value={arg.value}
className={`w-full font-mono text-xs min-h-[96px] resize-y ${
jsonError ? 'border-destructive' : ''
}`}
disabled={disabled}
spellCheck={false}
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
/>
)}
{jsonError && (
<p className="text-xs text-destructive pl-1">
{t('models.invalidJsonObject')}
</p>
)}
</div>
);
})
)}
</div>
);
@@ -46,10 +46,25 @@ interface ModelItemProps {
function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
if (!extraArgs) return [];
return Object.entries(extraArgs).map(([key, value]) => {
let type: 'string' | 'number' | 'boolean' = 'string';
if (typeof value === 'number') type = 'number';
else if (typeof value === 'boolean') type = 'boolean';
return { key, type, value: String(value) };
let type: ExtraArg['type'] = 'string';
let stringValue: string;
if (typeof value === 'number') {
type = 'number';
stringValue = String(value);
} else if (typeof value === 'boolean') {
type = 'boolean';
stringValue = String(value);
} else if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
type = 'object';
stringValue = JSON.stringify(value, null, 2);
} else {
stringValue = String(value);
}
return { key, type, value: stringValue };
});
}
@@ -209,7 +224,16 @@ export default function ModelItem({
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<PopoverContent
className="w-80 max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
align="start"
collisionPadding={16}
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<div className="space-y-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
@@ -9,7 +9,8 @@ import {
export type ExtraArg = {
key: string;
type: 'string' | 'number' | 'boolean';
type: 'string' | 'number' | 'boolean' | 'object';
// For 'object' type, value holds a JSON string that will be parsed on save.
value: string;
};
@@ -0,0 +1,410 @@
'use client';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
Clock,
Database,
FileWarning,
HardDrive,
RefreshCw,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
interface StorageSection {
key: string;
path: string;
exists: boolean;
size_bytes: number;
file_count: number;
}
interface CleanupCandidate {
key?: string;
name?: string;
size_bytes: number;
modified_at?: string;
date?: string;
}
interface StorageAnalysis {
generated_at: string;
cleanup_policy: {
uploaded_file_retention_days: number;
log_retention_days: number;
};
sections: StorageSection[];
database: {
type: string;
monitoring_counts: Record<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
interface StorageAnalysisDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return '-';
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function StorageAnalysisDialog({
open,
onOpenChange,
}: StorageAnalysisDialogProps) {
const { t } = useTranslation();
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/api/v1/system/storage-analysis',
);
setAnalysis(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnalysis();
}
}, [loadAnalysis, open]);
const totalBytes = useMemo(() => {
return (
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
);
}, [analysis]);
const uploadedCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.uploaded_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
const logCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.log_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<HardDrive className="size-5 text-blue-500" />
{t('storageAnalysis.dialogTitle')}
</DialogTitle>
<DialogDescription>
{t('storageAnalysis.description')}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -146,7 +146,12 @@ export default function KBDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form" disabled={!formDirty}>
<Button
type="submit"
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
</div>
+6 -1
View File
@@ -44,7 +44,12 @@ import {
} from '@/app/home/plugins/components/plugin-install-task';
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
const EXTENSIONS_ROUTES = [
'/home/plugins',
'/home/market',
'/home/mcp',
'/home/plugin-pages',
];
function isExtensionsRoute(pathname: string): boolean {
return EXTENSIONS_ROUTES.some(
@@ -80,7 +80,12 @@ export default function PipelineDetailContent({ id }: { id: string }) {
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
</div>
+192
View File
@@ -0,0 +1,192 @@
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
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';
/**
* Plugin page that renders a plugin-provided HTML page in an iframe.
* URL format: /home/plugin-pages?id=author/name/pageId
*
* The iframe communicates with the parent via postMessage:
*
* Parent → iframe:
* { type: 'langbot:context', theme: 'light'|'dark', language: 'zh-Hans'|'en-US' }
*
* iframe → Parent:
* { type: 'langbot:api', requestId: string, endpoint: string, method: string, body?: any }
*
* Parent → iframe (response):
* { type: 'langbot:api:response', requestId: string, data?: any, error?: string }
*/
export default function PluginPagesPage() {
const [searchParams] = useSearchParams();
const id = searchParams.get('id');
const { t } = useTranslation();
const { setDetailEntityName, pluginPages } = useSidebarData();
// Find the matching page for breadcrumb
const page = pluginPages.find((p) => p.id === id);
useEffect(() => {
setDetailEntityName(page?.name ?? id ?? '');
return () => setDetailEntityName(null);
}, [page, id, setDetailEntityName]);
if (!id) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('pluginPages.selectFromSidebar')}
</div>
);
}
// Parse "author/name/pageId"
const parts = id.split('/');
if (parts.length < 3) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('pluginPages.invalidPage')}
</div>
);
}
const author = parts[0];
const pluginName = parts[1];
// Use the asset path from the page manifest, not the page ID
const assetPath = page?.path ?? parts.slice(2).join('/');
const pageId = parts.slice(2).join('/');
return (
<PluginPageIframe
author={author}
pluginName={pluginName}
pagePath={assetPath}
pageId={pageId}
/>
);
}
function PluginPageIframe({
author,
pluginName,
pagePath,
pageId,
}: {
author: string;
pluginName: string;
pagePath: string;
pageId: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
// Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
const sendContext = useCallback(() => {
const iframe = iframeRef.current;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{
type: 'langbot:context',
theme: resolvedTheme,
language: i18n.language,
},
'*',
);
}
}, [resolvedTheme, i18n.language]);
// Re-send context when theme or language changes
useEffect(() => {
if (!loading) {
sendContext();
}
}, [resolvedTheme, i18n.language, loading, sendContext]);
// Handle messages from iframe (API calls)
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
// Validate source — only accept messages from our specific iframe window
// This is more secure than origin checking: works with sandboxed (null-origin) iframes
// and prevents spoofing from other windows/iframes
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data;
if (!data || typeof data !== 'object') return;
// Validate requestId format to prevent injection
if (data.type === 'langbot:api') {
const { requestId, endpoint, method, body } = data;
if (typeof requestId !== 'string' || typeof endpoint !== 'string')
return;
// Sanitize endpoint — must start with / and not contain ..
if (!endpoint.startsWith('/') || endpoint.includes('..')) return;
const normalizedMethod =
typeof method === 'string' ? method.toUpperCase() : 'POST';
if (
!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(normalizedMethod)
)
return;
try {
const result = await httpClient.pluginPageApi(
author,
pluginName,
pageId,
endpoint,
normalizedMethod,
body,
);
iframeRef.current?.contentWindow?.postMessage(
{
type: 'langbot:api:response',
requestId,
data: result,
},
'*',
);
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
iframeRef.current?.contentWindow?.postMessage(
{
type: 'langbot:api:response',
requestId,
error: errorMsg,
},
'*',
);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [author, pluginName, pageId]);
return (
<div className="flex flex-col h-full w-full">
{loading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
)}
<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>
);
}
@@ -85,7 +85,7 @@ function StageRow({
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
'flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg transition-all duration-300',
isActive &&
!isError &&
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
@@ -154,7 +154,7 @@ function StageRow({
{detail && (
<div
className={cn(
'text-xs mt-0.5',
'text-xs mt-0.5 break-words',
isCompleted
? 'text-green-600/70 dark:text-green-400/70'
: 'text-blue-600/70 dark:text-blue-400/70',
@@ -256,7 +256,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<>
<span>{parts.join(' · ')}</span>
<br />
<span className="opacity-70">{currentDep}</span>
<span className="opacity-70 break-words">{currentDep}</span>
</>
);
}
@@ -277,10 +277,10 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<div className="space-y-4">
{/* Overall progress bar — always blue */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'text-sm font-medium',
'text-sm font-medium shrink-0',
isDone
? 'text-green-700 dark:text-green-300'
: 'text-blue-700 dark:text-blue-300',
@@ -360,8 +360,8 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
{/* Done banner */}
{isDone && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
{t('plugins.installProgress.installComplete')}
</span>
</div>
@@ -406,13 +406,13 @@ export default function PluginInstallProgressDialog() {
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
hideCloseButton
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<Download className="size-5" />
<span className="truncate">
<DialogTitle className="flex items-start gap-3">
<Download className="size-5 shrink-0 mt-0.5" />
<span className="break-words">
{selectedTask
? t('plugins.installProgress.title', {
name: selectedTask.pluginName,
@@ -1,5 +1,12 @@
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import {
Wrench,
AudioWaveform,
Hash,
Book,
FileText,
PanelTop,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
@@ -23,6 +30,7 @@ export default function PluginComponentList({
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
Page: <PanelTop className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
@@ -16,6 +16,7 @@ import {
Hash,
Book,
FileText,
PanelTop,
} from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
@@ -53,6 +54,7 @@ function MarketPageContent({
'EventListener',
'KnowledgeEngine',
'Parser',
'Page',
];
const [searchQuery, setSearchQuery] = useState('');
@@ -530,6 +532,14 @@ function MarketPageContent({
<FileText className="h-4 w-4 mr-1" />
{t('plugins.componentName.Parser')}
</ToggleGroupItem>
<ToggleGroupItem
value="Page"
aria-label="Page"
className="text-xs sm:text-sm cursor-pointer"
>
<PanelTop className="h-4 w-4 mr-1" />
{t('plugins.componentName.Page')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
+8 -27
View File
@@ -18,6 +18,7 @@ import {
Check,
Bug,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
DropdownMenu,
DropdownMenuContent,
@@ -466,33 +467,13 @@ function PluginListView() {
};
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
try {
navigator.clipboard.writeText(text);
if (type === 'url') {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.select();
textArea.setSelectionRange(0, 99999);
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (success) {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
copyToClipboard(text).catch(() => {});
if (type === 'url') {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
};
@@ -47,6 +47,7 @@ export enum DynamicFormItemType {
BOT_SELECTOR = 'bot-selector',
TOOLS_SELECTOR = 'tools-selector',
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
}
export interface IFileConfig {
+22 -1
View File
@@ -122,7 +122,7 @@ export class BackendClient extends BaseHttpClient {
public scanProviderModels(
uuid: string,
modelType?: 'llm' | 'embedding',
modelType?: 'llm' | 'embedding' | 'rerank',
): Promise<ApiRespScannedProviderModels> {
const params = modelType ? { type: modelType } : {};
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
@@ -606,6 +606,27 @@ export class BackendClient extends BaseHttpClient {
);
}
public async pluginPageApi(
author: string,
name: string,
pageId: string,
endpoint: string,
method: string = 'POST',
body?: unknown,
): Promise<unknown> {
const resp = await this.instance.request({
url: `/api/v1/plugins/${author}/${name}/page-api`,
method: 'POST',
data: {
page_id: pageId,
endpoint,
method,
body,
},
});
return resp.data?.data;
}
public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') {
const url = window.location.href;
+39
View File
@@ -0,0 +1,39 @@
/**
* Copy text to clipboard with fallback support
* Tries to use modern Clipboard API first, falls back to execCommand if not available
*
* @param text - The text to copy to clipboard
* @returns Promise<boolean> - true if successful, false otherwise
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('[Clipboard] Modern API failed, trying fallback:', err);
// Fall through to legacy method
}
}
// Fallback to legacy execCommand method
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('[Clipboard] Fallback method failed:', err);
return false;
}
}
+4 -4
View File
@@ -572,9 +572,9 @@ export default function WizardPage() {
className={cn(
'w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center text-xs font-medium transition-colors',
idx < currentStep
? 'bg-primary text-primary-foreground'
? 'bg-blue-600 text-white'
: idx === currentStep
? 'bg-primary text-primary-foreground'
? 'bg-blue-600 text-white'
: 'bg-muted text-muted-foreground',
)}
>
@@ -588,7 +588,7 @@ export default function WizardPage() {
className={cn(
'text-sm hidden sm:inline',
idx === currentStep
? 'font-medium text-foreground'
? 'font-medium text-blue-600'
: 'text-muted-foreground',
)}
>
@@ -599,7 +599,7 @@ export default function WizardPage() {
<div
className={cn(
'w-4 sm:w-8 h-px',
idx < currentStep ? 'bg-primary' : 'bg-border',
idx < currentStep ? 'bg-blue-600' : 'bg-border',
)}
/>
)}
+45
View File
@@ -5,6 +5,8 @@ const enUS = {
installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace',
mcpServers: 'MCP Servers',
pluginPages: 'Plugin Pages',
pluginPagesTooltip: 'Visual pages provided by installed plugins',
quickStart: 'Quick Start',
},
common: {
@@ -209,6 +211,9 @@ const enUS = {
string: 'String',
number: 'Number',
boolean: 'Boolean',
object: 'Object',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Value must be a valid JSON object',
selectModelProvider: 'Select Model Provider',
modelProviderDescription:
'Please fill in the model name provided by the provider',
@@ -509,6 +514,7 @@ const enUS = {
Command: 'Command',
KnowledgeEngine: 'Knowledge Engine',
Parser: 'Parser',
Page: 'Page',
},
uploadLocal: 'Upload Local',
debugging: 'Debugging',
@@ -1240,6 +1246,41 @@ const enUS = {
feedback: 'User Feedback',
},
},
storageAnalysis: {
title: 'Storage Analysis',
description: 'Inspect storage usage and cleanup candidates',
openDialog: 'View Analysis',
dialogTitle: 'Storage Analysis',
generatedAt: 'Generated at {{time}}',
loading: 'Loading...',
refresh: 'Refresh',
totalSize: 'Total size',
binaryStorage: 'Binary storage',
uploadCleanup: 'Expired uploads',
logCleanup: 'Expired logs',
sections: 'Storage sections',
monitoringTables: 'Monitoring tables',
runtimeTasks: 'Runtime tasks',
cleanupPolicy: 'Cleanup policy',
uploadRetention: 'Upload retention',
logRetention: 'Log retention',
databaseType: 'Database type',
days: 'days',
missing: 'Missing',
expiredUploads: 'Expired uploads',
expiredLogs: 'Expired logs',
noExpiredUploads: 'No expired uploaded files',
noExpiredLogs: 'No expired log files',
sectionNames: {
database: 'Database',
logs: 'Logs',
storage: 'Uploaded files',
vector_store: 'Vector store',
plugins: 'Plugins',
mcp: 'MCP',
temp: 'Temporary files',
},
},
limitation: {
maxBotsReached:
'Maximum number of bots ({{max}}) reached. Please remove an existing bot before creating a new one.',
@@ -2101,6 +2142,10 @@ const enUS = {
createPipeline: 'Create Pipeline',
createWorkflow: 'Create Workflow',
},
pluginPages: {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',
},
};
export default enUS;
+47
View File
@@ -5,6 +5,9 @@ const esES = {
installedPlugins: 'Plugins instalados',
pluginMarket: 'Tienda',
mcpServers: 'Servidores MCP',
pluginPages: 'Páginas de plugins',
pluginPagesTooltip:
'Páginas visuales proporcionadas por los plugins instalados',
quickStart: 'Inicio rápido',
},
common: {
@@ -202,6 +205,9 @@ const esES = {
string: 'Cadena',
number: 'Número',
boolean: 'Booleano',
object: 'Objeto',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'El valor debe ser un objeto JSON válido',
selectModelProvider: 'Seleccionar proveedor del modelo',
modelProviderDescription:
'Por favor, introduce el nombre del modelo proporcionado por el proveedor',
@@ -500,6 +506,7 @@ const esES = {
Command: 'Comando',
KnowledgeEngine: 'Motor de conocimiento',
Parser: 'Analizador',
Page: 'Página',
},
uploadLocal: 'Subir local',
debugging: 'Depuración',
@@ -1252,6 +1259,42 @@ const esES = {
feedback: 'Comentarios de usuarios',
},
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
description:
'Inspecciona el uso de almacenamiento y los candidatos de limpieza',
openDialog: 'Ver análisis',
dialogTitle: 'Análisis de almacenamiento',
generatedAt: 'Generado el {{time}}',
loading: 'Cargando...',
refresh: 'Actualizar',
totalSize: 'Tamaño total',
binaryStorage: 'Almacenamiento binario de plugins',
uploadCleanup: 'Subidas caducadas',
logCleanup: 'Registros caducados',
sections: 'Secciones de almacenamiento',
monitoringTables: 'Tablas de monitoreo',
runtimeTasks: 'Tareas en ejecución',
cleanupPolicy: 'Política de limpieza',
uploadRetention: 'Retención de subidas',
logRetention: 'Retención de registros',
databaseType: 'Tipo de base de datos',
days: 'días',
missing: 'Falta',
expiredUploads: 'Subidas caducadas',
expiredLogs: 'Registros caducados',
noExpiredUploads: 'No hay archivos subidos caducados',
noExpiredLogs: 'No hay registros caducados',
sectionNames: {
database: 'Base de datos',
logs: 'Registros',
storage: 'Archivos subidos',
vector_store: 'Almacén vectorial',
plugins: 'Plugins',
mcp: 'MCP',
temp: 'Archivos temporales',
},
},
limitation: {
maxBotsReached:
'Se ha alcanzado el número máximo de Bots ({{max}}). Por favor, elimina un Bot existente antes de crear uno nuevo.',
@@ -2048,6 +2091,10 @@ const esES = {
},
},
pluginPages: {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
};
export default esES;
+45
View File
@@ -5,6 +5,8 @@
installedPlugins: 'インストール済みプラグイン',
pluginMarket: 'プラグインマーケット',
mcpServers: 'MCPサーバー',
pluginPages: 'プラグインページ',
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
quickStart: 'クイックスタート',
},
common: {
@@ -201,6 +203,9 @@
string: '文字列',
number: '数値',
boolean: 'ブール値',
object: 'オブジェクト',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '値は有効なJSONオブジェクトである必要があります',
selectModelProvider: 'モデルプロバイダーを選択',
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
modelManufacturer: 'モデルメーカー',
@@ -492,6 +497,7 @@
Command: 'コマンド',
KnowledgeEngine: '知識エンジン',
Parser: 'パーサー',
Page: 'ページ',
},
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',
@@ -1224,6 +1230,41 @@
feedback: 'ユーザーフィードバック',
},
},
storageAnalysis: {
title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します',
openDialog: '分析を表示',
dialogTitle: 'ストレージ分析',
generatedAt: '生成日時 {{time}}',
loading: '読み込み中...',
refresh: '更新',
totalSize: '合計サイズ',
binaryStorage: 'プラグインバイナリストレージ',
uploadCleanup: '期限切れアップロード',
logCleanup: '期限切れログ',
sections: 'ストレージセクション',
monitoringTables: '監視テーブル',
runtimeTasks: '実行タスク',
cleanupPolicy: 'クリーンアップポリシー',
uploadRetention: 'アップロード保持期間',
logRetention: 'ログ保持期間',
databaseType: 'データベース種別',
days: '日',
missing: 'なし',
expiredUploads: '期限切れアップロード',
expiredLogs: '期限切れログ',
noExpiredUploads: '期限切れのアップロードファイルはありません',
noExpiredLogs: '期限切れのログファイルはありません',
sectionNames: {
database: 'データベース',
logs: 'ログ',
storage: 'アップロードファイル',
vector_store: 'ベクターストア',
plugins: 'プラグイン',
mcp: 'MCP',
temp: '一時ファイル',
},
},
limitation: {
maxBotsReached:
'ボット数が上限({{max}}個)に達しました。新しいボットを作成するには、既存のボットを削除してください。',
@@ -2018,6 +2059,10 @@
},
},
pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
},
};
export default jaJP;
+46
View File
@@ -5,6 +5,9 @@ const ruRU = {
installedPlugins: 'Установленные плагины',
pluginMarket: 'Маркетплейс',
mcpServers: 'MCP-серверы',
pluginPages: 'Страницы плагинов',
pluginPagesTooltip:
'Визуальные страницы, предоставляемые установленными плагинами',
quickStart: 'Быстрый старт',
},
common: {
@@ -199,6 +202,9 @@ const ruRU = {
string: 'Строка',
number: 'Число',
boolean: 'Логический',
object: 'Объект',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Значение должно быть допустимым объектом JSON',
selectModelProvider: 'Выберите провайдера модели',
modelProviderDescription:
'Пожалуйста, введите название модели, предоставленное провайдером',
@@ -497,6 +503,7 @@ const ruRU = {
Command: 'Команда',
KnowledgeEngine: 'Движок знаний',
Parser: 'Парсер',
Page: 'Страница',
},
uploadLocal: 'Загрузить локально',
debugging: 'Отладка',
@@ -1227,6 +1234,41 @@ const ruRU = {
feedback: 'Отзывы пользователей',
},
},
storageAnalysis: {
title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку',
openDialog: 'Открыть анализ',
dialogTitle: 'Анализ хранилища',
generatedAt: 'Создано {{time}}',
loading: 'Загрузка...',
refresh: 'Обновить',
totalSize: 'Общий размер',
binaryStorage: 'Бинарное хранилище плагинов',
uploadCleanup: 'Просроченные загрузки',
logCleanup: 'Просроченные журналы',
sections: 'Разделы хранилища',
monitoringTables: 'Таблицы мониторинга',
runtimeTasks: 'Задачи runtime',
cleanupPolicy: 'Политика очистки',
uploadRetention: 'Хранение загрузок',
logRetention: 'Хранение журналов',
databaseType: 'Тип базы данных',
days: 'дн.',
missing: 'Нет',
expiredUploads: 'Просроченные загрузки',
expiredLogs: 'Просроченные журналы',
noExpiredUploads: 'Нет просроченных загруженных файлов',
noExpiredLogs: 'Нет просроченных журналов',
sectionNames: {
database: 'База данных',
logs: 'Журналы',
storage: 'Загруженные файлы',
vector_store: 'Векторное хранилище',
plugins: 'Плагины',
mcp: 'MCP',
temp: 'Временные файлы',
},
},
limitation: {
maxBotsReached:
'Достигнуто максимальное количество ботов ({{max}}). Удалите существующего бота перед созданием нового.',
@@ -2020,6 +2062,10 @@ const ruRU = {
},
},
pluginPages: {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
};
export default ruRU;
+45
View File
@@ -5,6 +5,8 @@ const thTH = {
installedPlugins: 'ปลั๊กอินที่ติดตั้ง',
pluginMarket: 'ตลาดปลั๊กอิน',
mcpServers: 'เซิร์ฟเวอร์ MCP',
pluginPages: 'หน้าปลั๊กอิน',
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
},
common: {
@@ -196,6 +198,9 @@ const thTH = {
string: 'สตริง',
number: 'ตัวเลข',
boolean: 'บูลีน',
object: 'อ็อบเจกต์',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'ค่าต้องเป็นอ็อบเจกต์ JSON ที่ถูกต้อง',
selectModelProvider: 'เลือกผู้ให้บริการโมเดล',
modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้',
modelManufacturer: 'ผู้ผลิตโมเดล',
@@ -482,6 +487,7 @@ const thTH = {
Command: 'คำสั่ง',
KnowledgeEngine: 'เครื่องมือความรู้',
Parser: 'ตัวแยกวิเคราะห์',
Page: 'หน้า',
},
uploadLocal: 'อัปโหลดจากเครื่อง',
debugging: 'ดีบัก',
@@ -1199,6 +1205,41 @@ const thTH = {
feedback: 'ความคิดเห็นผู้ใช้',
},
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
openDialog: 'ดูการวิเคราะห์',
dialogTitle: 'วิเคราะห์พื้นที่จัดเก็บ',
generatedAt: 'สร้างเมื่อ {{time}}',
loading: 'กำลังโหลด...',
refresh: 'รีเฟรช',
totalSize: 'ขนาดรวม',
binaryStorage: 'พื้นที่จัดเก็บไบนารีของปลั๊กอิน',
uploadCleanup: 'ไฟล์อัปโหลดที่หมดอายุ',
logCleanup: 'บันทึกที่หมดอายุ',
sections: 'ส่วนพื้นที่จัดเก็บ',
monitoringTables: 'ตารางการตรวจสอบ',
runtimeTasks: 'งาน runtime',
cleanupPolicy: 'นโยบายการล้างข้อมูล',
uploadRetention: 'ระยะเวลาเก็บไฟล์อัปโหลด',
logRetention: 'ระยะเวลาเก็บบันทึก',
databaseType: 'ชนิดฐานข้อมูล',
days: 'วัน',
missing: 'ไม่มี',
expiredUploads: 'ไฟล์อัปโหลดที่หมดอายุ',
expiredLogs: 'บันทึกที่หมดอายุ',
noExpiredUploads: 'ไม่มีไฟล์อัปโหลดที่หมดอายุ',
noExpiredLogs: 'ไม่มีบันทึกที่หมดอายุ',
sectionNames: {
database: 'ฐานข้อมูล',
logs: 'บันทึก',
storage: 'ไฟล์อัปโหลด',
vector_store: 'คลังเวกเตอร์',
plugins: 'ปลั๊กอิน',
mcp: 'MCP',
temp: 'ไฟล์ชั่วคราว',
},
},
limitation: {
maxBotsReached:
'จำนวน Bot สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Bot ที่มีอยู่ก่อนสร้างใหม่',
@@ -1990,6 +2031,10 @@ const thTH = {
},
},
pluginPages: {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
};
export default thTH;
+46
View File
@@ -5,6 +5,9 @@ const viVN = {
installedPlugins: 'Plugin đã cài đặt',
pluginMarket: 'Chợ ứng dụng',
mcpServers: 'Máy chủ MCP',
pluginPages: 'Trang plugin',
pluginPagesTooltip:
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
quickStart: 'Bắt đầu nhanh',
},
common: {
@@ -199,6 +202,9 @@ const viVN = {
string: 'Chuỗi',
number: 'Số',
boolean: 'Boolean',
object: 'Đối tượng',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Giá trị phải là một đối tượng JSON hợp lệ',
selectModelProvider: 'Chọn nhà cung cấp mô hình',
modelProviderDescription:
'Vui lòng điền tên mô hình do nhà cung cấp cung cấp',
@@ -492,6 +498,7 @@ const viVN = {
Command: 'Lệnh',
KnowledgeEngine: 'Công cụ tri thức',
Parser: 'Trình phân tích',
Page: 'Trang',
},
uploadLocal: 'Tải lên cục bộ',
debugging: 'Gỡ lỗi',
@@ -1220,6 +1227,41 @@ const viVN = {
feedback: 'Phản hồi người dùng',
},
},
storageAnalysis: {
title: 'Phân tích lưu trữ',
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
openDialog: 'Xem phân tích',
dialogTitle: 'Phân tích lưu trữ',
generatedAt: 'Tạo lúc {{time}}',
loading: 'Đang tải...',
refresh: 'Làm mới',
totalSize: 'Tổng dung lượng',
binaryStorage: 'Lưu trữ nhị phân plugin',
uploadCleanup: 'Tệp tải lên hết hạn',
logCleanup: 'Nhật ký hết hạn',
sections: 'Khu vực lưu trữ',
monitoringTables: 'Bảng giám sát',
runtimeTasks: 'Tác vụ runtime',
cleanupPolicy: 'Chính sách dọn dẹp',
uploadRetention: 'Thời gian giữ tệp tải lên',
logRetention: 'Thời gian giữ nhật ký',
databaseType: 'Loại cơ sở dữ liệu',
days: 'ngày',
missing: 'Thiếu',
expiredUploads: 'Tệp tải lên hết hạn',
expiredLogs: 'Nhật ký hết hạn',
noExpiredUploads: 'Không có tệp tải lên hết hạn',
noExpiredLogs: 'Không có nhật ký hết hạn',
sectionNames: {
database: 'Cơ sở dữ liệu',
logs: 'Nhật ký',
storage: 'Tệp tải lên',
vector_store: 'Kho vector',
plugins: 'Plugin',
mcp: 'MCP',
temp: 'Tệp tạm',
},
},
limitation: {
maxBotsReached:
'Đã đạt số lượng Bot tối đa ({{max}}). Vui lòng xóa một Bot hiện có trước khi tạo mới.',
@@ -2011,6 +2053,10 @@ const viVN = {
},
},
pluginPages: {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
};
export default viVN;
+45
View File
@@ -5,6 +5,8 @@ const zhHans = {
installedPlugins: '已安装插件',
pluginMarket: '插件市场',
mcpServers: 'MCP 服务器',
pluginPages: '插件页面',
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
quickStart: '快速开始向导',
},
common: {
@@ -202,6 +204,9 @@ const zhHans = {
string: '字符串',
number: '数字',
boolean: '布尔值',
object: '对象',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '值必须是有效的 JSON 对象',
selectModelProvider: '选择模型供应商',
modelProviderDescription: '请填写供应商向您提供的模型名称',
modelManufacturer: '模型厂商',
@@ -489,6 +494,7 @@ const zhHans = {
Command: '命令',
KnowledgeEngine: '知识引擎',
Parser: '解析器',
Page: '页面',
},
uploadLocal: '本地上传',
debugging: '调试中',
@@ -1189,6 +1195,41 @@ const zhHans = {
feedback: '用户反馈',
},
},
storageAnalysis: {
title: '存储分析',
description: '查看存储占用和可清理文件',
openDialog: '查看分析',
dialogTitle: '存储分析',
generatedAt: '生成时间 {{time}}',
loading: '加载中...',
refresh: '刷新',
totalSize: '总占用',
binaryStorage: '插件二进制存储',
uploadCleanup: '过期上传文件',
logCleanup: '过期日志',
sections: '存储分区',
monitoringTables: '监控表',
runtimeTasks: '运行任务',
cleanupPolicy: '清理策略',
uploadRetention: '上传文件保留',
logRetention: '日志保留',
databaseType: '数据库类型',
days: '天',
missing: '不存在',
expiredUploads: '过期上传文件',
expiredLogs: '过期日志',
noExpiredUploads: '暂无过期上传文件',
noExpiredLogs: '暂无过期日志',
sectionNames: {
database: '数据库',
logs: '日志',
storage: '上传文件',
vector_store: '向量库',
plugins: '插件',
mcp: 'MCP',
temp: '临时文件',
},
},
limitation: {
maxBotsReached:
'已达到机器人数量上限({{max}}个)。请先删除已有机器人后再创建新的。',
@@ -2101,6 +2142,10 @@ const zhHans = {
createPipeline: '创建流水线',
createWorkflow: '创建工作流',
},
pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',
},
};
export default zhHans;
+45
View File
@@ -5,6 +5,8 @@ const zhHant = {
installedPlugins: '已安裝外掛',
pluginMarket: '外掛市場',
mcpServers: 'MCP 伺服器',
pluginPages: '插件頁面',
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
quickStart: '快速開始',
},
common: {
@@ -190,6 +192,9 @@ const zhHant = {
string: '字串',
number: '數字',
boolean: '布林值',
object: '物件',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '值必須是有效的 JSON 物件',
selectModelProvider: '選擇模型供應商',
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
modelManufacturer: '模型廠商',
@@ -466,6 +471,7 @@ const zhHant = {
Command: '命令',
KnowledgeEngine: '知識引擎',
Parser: '解析器',
Page: '擴展頁',
},
uploadLocal: '本地上傳',
debugging: '調試中',
@@ -1165,6 +1171,41 @@ const zhHant = {
feedback: '使用者回饋',
},
},
storageAnalysis: {
title: '儲存分析',
description: '查看儲存占用和可清理檔案',
openDialog: '查看分析',
dialogTitle: '儲存分析',
generatedAt: '生成時間 {{time}}',
loading: '載入中...',
refresh: '重新整理',
totalSize: '總占用',
binaryStorage: '插件二進位儲存',
uploadCleanup: '過期上傳檔案',
logCleanup: '過期日誌',
sections: '儲存分區',
monitoringTables: '監控表',
runtimeTasks: '執行任務',
cleanupPolicy: '清理策略',
uploadRetention: '上傳檔案保留',
logRetention: '日誌保留',
databaseType: '資料庫類型',
days: '天',
missing: '不存在',
expiredUploads: '過期上傳檔案',
expiredLogs: '過期日誌',
noExpiredUploads: '暫無過期上傳檔案',
noExpiredLogs: '暫無過期日誌',
sectionNames: {
database: '資料庫',
logs: '日誌',
storage: '上傳檔案',
vector_store: '向量庫',
plugins: '插件',
mcp: 'MCP',
temp: '暫存檔案',
},
},
limitation: {
maxBotsReached:
'已達到機器人數量上限({{max}}個)。請先刪除已有機器人後再建立新的。',
@@ -1953,6 +1994,10 @@ const zhHant = {
},
},
pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
};
export default zhHant;
+11
View File
@@ -22,6 +22,7 @@ import PluginsPage from '@/app/home/plugins/page';
import MarketPage from '@/app/home/market/page';
import MCPPage from '@/app/home/mcp/page';
import KnowledgePage from '@/app/home/knowledge/page';
import PluginPagesPage from '@/app/home/plugin-pages/page';
const Loading = () => <div>Loading...</div>;
@@ -152,4 +153,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: '/home/plugin-pages',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginPagesPage />
</HomeLayout>
</Suspense>
),
},
]);