mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-30 06:07:14 +00:00
Merge remote-tracking branch 'origin/master' into feat/workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user