import { useEffect, useState } from 'react'; import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { systemInfo, httpClient } from '@/app/infra/http/HttpClient'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { useTranslation } from 'react-i18next'; import { Moon, Sun, Monitor, ChevronsUpDown, CircleHelp, Lightbulb, LogOut, KeyRound, Settings, Star, Ellipsis, ArrowUp, ExternalLink, Trash, Bug, Upload, Store, Github, Zap, } from 'lucide-react'; import { useTheme } from '@/components/providers/theme-provider'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog'; 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 { GitHubRelease } from '@/app/infra/http/CloudServiceClient'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { toast } from 'sonner'; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from '@/components/ui/sidebar'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { ChevronRight, Plus } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; // Compare two version strings, returns true if v1 > v2 function compareVersions(v1: string, v2: string): boolean { const clean1 = v1.replace(/^v/, ''); const clean2 = v2.replace(/^v/, ''); const parts1 = clean1.split('.').map((p) => parseInt(p, 10) || 0); const parts2 = clean2.split('.').map((p) => parseInt(p, 10) || 0); const maxLen = Math.max(parts1.length, parts2.length); for (let i = 0; i < maxLen; i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return true; if (p1 < p2) return false; } return false; } // IDs of sidebar entries that have collapsible entity sub-items const ENTITY_CATEGORY_IDS = [ 'bots', 'pipelines', 'knowledge', 'plugins', 'mcp', ] as const; type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number]; // Categories that support detail pages via ?id= query param const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', 'plugins', 'mcp', ]; // Categories that support creating new entities from the sidebar const CREATABLE_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', 'mcp', 'plugins', ]; // Categories where clicking the parent only toggles collapse (no list page) const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', 'mcp', ]; function isEntityCategory(id: string): id is EntityCategoryId { return (ENTITY_CATEGORY_IDS as readonly string[]).includes(id); } // Map sidebar config IDs to SidebarDataContext keys const ENTITY_KEY_MAP: Record< EntityCategoryId, 'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' > = { bots: 'bots', pipelines: 'pipelines', knowledge: 'knowledgeBases', plugins: 'plugins', mcp: 'mcpServers', }; // Route prefix map for entity detail pages const ENTITY_ROUTE_MAP: Record = { bots: '/home/bots', pipelines: '/home/pipelines', knowledge: '/home/knowledge', plugins: '/home/plugins', mcp: '/home/mcp', }; // localStorage key for collapsible section open/closed state const SIDEBAR_SECTIONS_KEY = 'sidebar_sections'; function loadSectionState(): Record { if (typeof window === 'undefined') return {}; try { const stored = localStorage.getItem(SIDEBAR_SECTIONS_KEY); return stored ? JSON.parse(stored) : {}; } catch { return {}; } } function saveSectionState(state: Record) { try { localStorage.setItem(SIDEBAR_SECTIONS_KEY, JSON.stringify(state)); } catch { // Ignore storage errors } } // Maximum number of entity sub-items visible before "More" toggle const MAX_VISIBLE_ITEMS = 5; // Sort entity items by updatedAt descending (most recent first), items without updatedAt go last function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] { return [...items].sort((a, b) => { if (!a.updatedAt && !b.updatedAt) return 0; if (!a.updatedAt) return 1; if (!b.updatedAt) return -1; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); } // MCP status dot color: disabled → gray, error → red, connecting → yellow, connected → green function mcpStatusColor(item: SidebarEntityItem): string { if (item.enabled === false) return 'bg-muted-foreground/40'; switch (item.runtimeStatus) { case 'connected': return 'bg-green-500'; case 'connecting': return 'bg-yellow-500'; case 'error': return 'bg-red-500'; default: return 'bg-muted-foreground/40'; } } // Plugin operation type enum enum PluginOperationType { DELETE = 'DELETE', UPDATE = 'UPDATE', } // Renders sidebar navigation items with collapsible sub-items for entity categories function NavItems({ selectedChild, onChildClick, section, sectionOpenState, onSectionToggle, }: { selectedChild: SidebarChildVO | undefined; onChildClick: (child: SidebarChildVO) => void; section: 'home' | 'extensions'; sectionOpenState: Record; onSectionToggle: (id: string, open: boolean) => void; }) { const navigate = useNavigate(); const location = useLocation(); const pathname = location.pathname; const [searchParams] = useSearchParams(); const sidebarData = useSidebarData(); const { setPendingPluginInstallAction } = sidebarData; const { state: sidebarState, isMobile } = useSidebar(); const { t } = useTranslation(); // Track which entity categories have their full list expanded const [expandedLists, setExpandedLists] = useState>( {}, ); // Track popover open state for collapsed sidebar entity categories const [popoverOpen, setPopoverOpen] = useState>({}); // Plugin operation state const [showPluginOpModal, setShowPluginOpModal] = useState(false); const [pluginOpType, setPluginOpType] = useState( PluginOperationType.DELETE, ); const [targetPluginItem, setTargetPluginItem] = useState(null); const [deleteData, setDeleteData] = useState(false); const asyncTask = useAsyncTask({ onSuccess: () => { const msg = pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteSuccess') : t('plugins.updateSuccess'); toast.success(msg); setShowPluginOpModal(false); sidebarData.refreshPlugins(); }, }); function handlePluginDelete(item: SidebarEntityItem) { setTargetPluginItem(item); setPluginOpType(PluginOperationType.DELETE); setDeleteData(false); asyncTask.reset(); setShowPluginOpModal(true); } function handlePluginUpdate(item: SidebarEntityItem) { setTargetPluginItem(item); setPluginOpType(PluginOperationType.UPDATE); asyncTask.reset(); setShowPluginOpModal(true); } function executePluginOperation() { if (!targetPluginItem) return; const slashIdx = targetPluginItem.id.indexOf('/'); const author = slashIdx >= 0 ? targetPluginItem.id.substring(0, slashIdx) : ''; const name = slashIdx >= 0 ? targetPluginItem.id.substring(slashIdx + 1) : targetPluginItem.id; const apiCall = pluginOpType === PluginOperationType.DELETE ? httpClient.removePlugin(author, name, deleteData) : httpClient.upgradePlugin(author, name); apiCall .then((res) => { asyncTask.startTask(res.task_id); }) .catch((error) => { const errorMessage = pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteError') + error.message : t('plugins.updateError') + error.message; toast.error(errorMessage); }); } const sectionItems = sidebarConfigList.filter((c) => c.section === section); return ( <> {sectionItems.map((config) => { if (!isEntityCategory(config.id)) { // Non-entity entries (e.g. monitoring, market, mcp) render as plain links return ( onChildClick(config)} tooltip={config.name} > {config.icon} {config.name} ); } // Entity categories: collapsible with sub-items const entityKey = ENTITY_KEY_MAP[config.id]; const items: SidebarEntityItem[] = sidebarData[entityKey]; const routePrefix = ENTITY_ROUTE_MAP[config.id]; const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(config.id); const canCreate = CREATABLE_CATEGORIES.includes(config.id); const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id); const isPlugin = config.id === 'plugins'; const isBot = config.id === 'bots'; const isMCP = config.id === 'mcp'; const isActive = selectedChild?.id === config.id || pathname === routePrefix || pathname.startsWith(routePrefix + '/'); // Use stored open state if available, otherwise default to active state const isOpen = sectionOpenState[config.id] ?? isActive; // When sidebar is collapsed on desktop and category is collapse-only, // show a popover flyout instead of the hidden collapsible sub-items const isCollapsed = sidebarState === 'collapsed' && !isMobile; const showPopover = isCollapsed && isCollapseOnly; // Shared entity list renderer used by both popover and collapsible const renderEntityList = (inPopover: boolean) => { const sortedItems = sortByRecent(items); const isExpanded = expandedLists[config.id] ?? false; const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS; const visibleItems = sortedItems.length > maxItems && !isExpanded ? sortedItems.slice(0, maxItems) : sortedItems; const hiddenCount = sortedItems.length - maxItems; if (sortedItems.length === 0) { return (
{t('common.noItems')}
); } return ( <> {visibleItems.map((item) => { const itemRoute = hasDetailPages ? `${routePrefix}?id=${encodeURIComponent(item.id)}` : routePrefix; const isItemActive = hasDetailPages && pathname === routePrefix && searchParams.get('id') === item.id; if (inPopover) { return ( ); } // Normal sidebar sub-item rendering return ( { e.preventDefault(); navigate(itemRoute); }} > {item.emoji ? ( {item.emoji} ) : item.iconURL ? ( {(isBot || isMCP) && ( )} ) : isMCP ? ( ) : null} {item.name} {item.debug && ( )} {item.description && ( {item.description.length > 80 ? item.description.slice(0, 80) + '…' : item.description} )} {/* Plugin context menu - shown on hover (not for debug plugins) */} {isPlugin && !item.debug && ( handlePluginUpdate(item)} onDelete={() => handlePluginDelete(item)} /> )} ); })} {/* Show more / less toggle when items exceed limit */} {sortedItems.length > maxItems && !inPopover && ( )} {hiddenCount > 0 && inPopover && !isExpanded && ( )} ); }; // Popover flyout for collapsed sidebar if (showPopover) { return ( setPopoverOpen((prev) => ({ ...prev, [config.id]: open })) } > {config.icon} {config.name}
{config.name} {canCreate && (isPlugin ? ( {systemInfo.enable_marketplace && ( { e.stopPropagation(); navigate('/home/market'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('plugins.goToMarketplace')} )} { e.stopPropagation(); setPendingPluginInstallAction('local'); navigate('/home/plugins'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('plugins.uploadLocal')} { e.stopPropagation(); setPendingPluginInstallAction('github'); navigate('/home/plugins'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('plugins.installFromGithub')} ) : ( ))}
{renderEntityList(true)}
); } // Normal expanded sidebar with collapsible sub-items return ( onSectionToggle(config.id, open)} className="group/collapsible" > { if (isCollapseOnly) { onSectionToggle(config.id, !isOpen); } else { onChildClick(config); } }} tooltip={config.name} className="group/category-header" > {config.icon} {config.name}
{canCreate && (isPlugin ? ( {systemInfo.enable_marketplace && ( { e.stopPropagation(); navigate('/home/market'); }} > {t('plugins.goToMarketplace')} )} { e.stopPropagation(); setPendingPluginInstallAction('local'); navigate('/home/plugins'); }} > {t('plugins.uploadLocal')} { e.stopPropagation(); setPendingPluginInstallAction('github'); navigate('/home/plugins'); }} > {t('plugins.installFromGithub')} ) : ( ))}
{renderEntityList(false)}
); })} {/* Plugin operation confirmation dialog */} { if (!open) { setShowPluginOpModal(false); setTargetPluginItem(null); asyncTask.reset(); } }} > {pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteConfirm') : t('plugins.updateConfirm')} {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
{(() => { const slashIdx = targetPluginItem?.id.indexOf('/') ?? -1; const author = slashIdx >= 0 ? targetPluginItem!.id.substring(0, slashIdx) : ''; const name = slashIdx >= 0 ? targetPluginItem!.id.substring(slashIdx + 1) : (targetPluginItem?.id ?? ''); return pluginOpType === PluginOperationType.DELETE ? t('plugins.confirmDeletePlugin', { author, name }) : t('plugins.confirmUpdatePlugin', { author, name }); })()}
{pluginOpType === PluginOperationType.DELETE && (
setDeleteData(checked === true) } />
)}
)} {asyncTask.status === AsyncTaskStatus.RUNNING && (
{pluginOpType === PluginOperationType.DELETE ? t('plugins.deleting') : t('plugins.updating')}
)} {asyncTask.status === AsyncTaskStatus.ERROR && (
{pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteError') : t('plugins.updateError')}
{asyncTask.error}
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} {asyncTask.status === AsyncTaskStatus.RUNNING && ( )} {asyncTask.status === AsyncTaskStatus.ERROR && ( )}
); } // Dropdown menu for plugin sidebar sub-items (shown on hover) function PluginItemMenu({ item, onUpdate, onDelete, }: { item: SidebarEntityItem; onUpdate: () => void; onDelete: () => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const isMarketplace = item.installSource === 'marketplace'; const isGithub = item.installSource === 'github'; const hasSourceLink = isMarketplace || isGithub; function handleViewSource() { const slashIdx = item.id.indexOf('/'); const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : ''; const name = slashIdx >= 0 ? item.id.substring(slashIdx + 1) : item.id; if (isGithub && item.installInfo?.github_url) { window.open(item.installInfo.github_url as string, '_blank'); } else if (isMarketplace) { window.open( getCloudServiceClientSync().getPluginMarketplaceURL( systemInfo.cloud_service_url, author, name, ), '_blank', ); } } return ( {isMarketplace && ( { onUpdate(); setOpen(false); }} > {t('plugins.update')} {item.hasUpdate && ( {t('plugins.new')} )} )} {hasSourceLink && ( { handleViewSource(); setOpen(false); }} > {t('plugins.viewSource')} )} { onDelete(); setOpen(false); }} > {t('plugins.delete')} ); } export default function HomeSidebar({ onSelectedChangeAction, }: { onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void; }) { const navigate = useNavigate(); const location = useLocation(); const pathname = location.pathname; const [searchParams] = useSearchParams(); const { isMobile } = useSidebar(); useEffect(() => { handleRouteChange(pathname); }, [pathname]); useEffect(() => { if (searchParams.get('action') === 'showModelSettings') { setModelsDialogOpen(true); } if (searchParams.get('action') === 'showAccountSettings') { setAccountSettingsOpen(true); } if (searchParams.get('action') === 'showApiIntegrationSettings') { setApiKeyDialogOpen(true); } }, [searchParams]); const [selectedChild, setSelectedChild] = useState(); const [sectionOpenState, setSectionOpenState] = useState>(loadSectionState); const { theme, setTheme } = useTheme(); const { t } = useTranslation(); const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [latestRelease, setLatestRelease] = useState( null, ); const [hasNewVersion, setHasNewVersion] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [modelsDialogOpen, setModelsDialogOpen] = useState(false); const [userEmail, setUserEmail] = useState(''); const [starCount, setStarCount] = useState(null); const [userMenuOpen, setUserMenuOpen] = useState(false); function handleModelsDialogChange(open: boolean) { setModelsDialogOpen(open); if (open) { const params = new URLSearchParams(searchParams.toString()); params.set('action', 'showModelSettings'); 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 }); } } function handleAccountSettingsChange(open: boolean) { setAccountSettingsOpen(open); if (open) { const params = new URLSearchParams(searchParams.toString()); params.set('action', 'showAccountSettings'); 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')) { localStorage.setItem('token', 'test-token'); localStorage.setItem('userEmail', 'test@example.com'); } const storedEmail = localStorage.getItem('userEmail'); if (storedEmail) { setUserEmail(storedEmail); } else { httpClient .getUserInfo() .then((info) => { setUserEmail(info.user); localStorage.setItem('userEmail', info.user); }) .catch(() => {}); } getCloudServiceClientSync() .getLangBotReleases() .then((releases) => { if (releases && releases.length > 0) { const latestStable = releases.find((r) => !r.prerelease && !r.draft); const latest = latestStable || releases[0]; setLatestRelease(latest); const currentVersion = systemInfo?.version; if (currentVersion && latest.tag_name) { const isNewer = compareVersions(latest.tag_name, currentVersion); setHasNewVersion(isNewer); } } }) .catch((error) => { console.error('Failed to fetch releases:', error); }); getCloudServiceClientSync() .getGitHubRepoInfo() .then((info) => { if (info?.repo?.stargazers_count != null) { setStarCount(info.repo.stargazers_count); } }) .catch(() => {}); }, []); // Update selected state + notify parent without navigating function selectChild(child: SidebarChildVO) { setSelectedChild(child); onSelectedChangeAction(child); } // Toggle collapsible section open/closed with localStorage persistence function handleSectionToggle(id: string, open: boolean) { setSectionOpenState((prev) => { const next = { ...prev, [id]: open }; saveSectionState(next); return next; }); } // User click: update state AND navigate function handleChildClick(child: SidebarChildVO) { selectChild(child); navigate(child.route); } function initSelect() { const currentPath = pathname; // Match exact route or sub-routes (e.g., /home/bots/abc-123 matches /home/bots) const matchedChild = sidebarConfigList.find( (childConfig) => childConfig.route === currentPath, ) || sidebarConfigList.find((childConfig) => currentPath.startsWith(childConfig.route + '/'), ); if (matchedChild) { // Route already matches — just select without navigating (preserves ?id= query params) selectChild(matchedChild); } else { // No match — redirect to the first route under /home const defaultChild = sidebarConfigList.find((c) => c.route.startsWith('/home')) ?? sidebarConfigList[0]; handleChildClick(defaultChild); } } function handleRouteChange(pathname: string) { if (!pathname.startsWith('/home')) return; // Match exact route or sub-routes (entity detail pages) const routeSelectChild = sidebarConfigList.find((childConfig) => childConfig.route === pathname) || sidebarConfigList.find((childConfig) => pathname.startsWith(childConfig.route + '/'), ); if (routeSelectChild) { setSelectedChild(routeSelectChild); onSelectedChangeAction(routeSelectChild); } } function handleLogout() { localStorage.removeItem('token'); localStorage.removeItem('userEmail'); window.location.href = '/login'; } // Get the initial letter for user avatar const userInitial = userEmail ? userEmail.charAt(0).toUpperCase() : 'U'; return ( <> {/* Header: Logo using sidebar-07 team-switcher pattern */} LangBot
LangBot
{systemInfo?.version} {hasNewVersion && ( setVersionDialogOpen(true)} className="bg-red-500 hover:bg-red-600 text-white text-[0.55rem] px-1 py-0 h-3.5 cursor-pointer" > {t('plugins.new')} )}
{/* Navigation items grouped by section */} {t('sidebar.home')} {t('sidebar.extensions')} {/* Footer */} {/* API Integration entry */} setApiKeyDialogOpen(true)} tooltip={t('common.apiIntegration')} > {t('common.apiIntegration')} {/* Models entry */} handleModelsDialogChange(true)} tooltip={t('models.title')} > {t('models.title')} {/* User menu using sidebar-07 nav-user DropdownMenu pattern */} {userInitial}
{userEmail || t('common.accountOptions')}
{/* User info header */}
{userInitial}
{userEmail || t('common.accountOptions')}
{/* Language & Theme row */}
{/* Account actions */} handleAccountSettingsChange(true)} > {t('account.settings')} { setUserMenuOpen(false); navigate('/wizard'); }} > {t('sidebar.quickStart')} {/* External links */} { const language = localStorage.getItem('langbot_language'); if (language === 'zh-Hans' || language === 'zh-Hant') { window.open( 'https://link.langbot.app/zh/docs/guide', '_blank', ); } else { window.open( 'https://link.langbot.app/en/docs/guide', '_blank', ); } }} > {t('common.helpDocs')} { window.open( 'https://github.com/langbot-app/LangBot/issues', '_blank', ); }} > {t('common.featureRequest')} { window.open( 'https://github.com/langbot-app/LangBot', '_blank', ); }} > {t('common.starOnGitHub')} {starCount != null && ( {starCount >= 1000 ? `${(starCount / 1000).toFixed(1)}k` : starCount} )} {/* Logout */} handleLogout()}> {t('common.logout')}
); }