Files
LangBot/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
Junyan Chin 2317392ee5 refactor(web): migrate from Next.js to Vite + React Router (#2102)
* refactor(web): migrate from Next.js to Vite + React Router

* fix: update build pipelines for Vite migration (out → dist)

- Dockerfile: npm run build → npx vite build, web/out → web/dist
- pyproject.toml: package-data web/out/** → web/dist/**
- paths.py: support both web/dist (Vite) and web/out (legacy) with fallback

* fix: remove .next from git tracking, add to .gitignore

1334 cached files from web/.next/ were accidentally committed.
Added .next/ to both root and web/.gitignore.

* fix: update build process to use Vite and correct output directory

* fix: update pnpm-lock.yaml and eslint config for Vite migration

* style: fix prettier formatting issues

* fix: add eslint-plugin-react-hooks for Vite migration

* fix: remove undefined eslint rule reference, downgrade react-hooks plugin to v5

* fix(web): clean up remaining Next.js artifacts in Vite migration

- Add vite-env.d.ts for import.meta.env and asset type declarations
- Remove dead layout.tsx (providers already in main.tsx)
- Fix useSearchParams destructuring to [searchParams] tuple (11 locations)
- Replace process.env.NEXT_PUBLIC_* with import.meta.env.VITE_*
- Fix langbotIcon.src to langbotIcon (Vite returns URL string)
- Fix Link href to Link to for react-router-dom
- Fix navigate({ scroll: false }) to { preventScrollReset: true }
- Fix [router] dependency arrays to [navigate]
- Remove Next.js plugin from tsconfig, set rsc: false in components.json
- Replace next lint with eslint in lint-staged

* feat: add tools API endpoint and tools-selector form type

Backend:
- Add GET /api/v1/tools — list all available tools (plugin + MCP)
- Add GET /api/v1/tools/<tool_name> — get specific tool details

Frontend:
- Add TOOLS_SELECTOR form type for plugin config forms
- Multi-select dialog with tool name and description
- Add PluginTool entity type and API client methods

* Revert "feat: add tools API endpoint and tools-selector form type"

This reverts commit 3c637fc563.
2026-04-03 17:09:17 +08:00

1513 lines
56 KiB
TypeScript

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<EntityCategoryId, string> = {
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<string, boolean> {
if (typeof window === 'undefined') return {};
try {
const stored = localStorage.getItem(SIDEBAR_SECTIONS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function saveSectionState(state: Record<string, boolean>) {
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<string, boolean>;
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<Record<string, boolean>>(
{},
);
// Track popover open state for collapsed sidebar entity categories
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
// Plugin operation state
const [showPluginOpModal, setShowPluginOpModal] = useState(false);
const [pluginOpType, setPluginOpType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
);
const [targetPluginItem, setTargetPluginItem] =
useState<SidebarEntityItem | null>(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 (
<SidebarMenuItem key={config.id}>
<SidebarMenuButton
isActive={selectedChild?.id === config.id}
onClick={() => onChildClick(config)}
tooltip={config.name}
>
{config.icon}
<span>{config.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
// 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 (
<div
className={cn(
'text-muted-foreground text-xs',
inPopover ? 'px-2 py-3 text-center' : 'px-2 py-1.5',
)}
>
{t('common.noItems')}
</div>
);
}
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 (
<button
key={item.id}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left',
'hover:bg-accent hover:text-accent-foreground transition-colors',
isItemActive &&
'bg-accent text-accent-foreground font-medium',
)}
onClick={() => {
navigate(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
{item.emoji ? (
<span className="text-sm shrink-0">{item.emoji}</span>
) : item.iconURL ? (
<span className="relative shrink-0">
<img
src={item.iconURL}
alt=""
className="size-4 rounded"
/>
{(isBot || isMCP) && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)}
</span>
) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
mcpStatusColor(item),
)}
/>
) : null}
<span className="truncate">{item.name}</span>
</button>
);
}
// Normal sidebar sub-item rendering
return (
<SidebarMenuSubItem
key={item.id}
className={isPlugin ? 'group/plugin-item relative' : ''}
>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<SidebarMenuSubButton asChild isActive={isItemActive}>
<a
href={itemRoute}
className={cn(
isPlugin && !item.debug ? 'pr-6' : '',
)}
onClick={(e) => {
e.preventDefault();
navigate(itemRoute);
}}
>
{item.emoji ? (
<span className="text-sm shrink-0">
{item.emoji}
</span>
) : item.iconURL ? (
<span className="relative shrink-0">
<img
src={item.iconURL}
alt=""
className="size-4 rounded"
/>
{(isBot || isMCP) && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)}
</span>
) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
mcpStatusColor(item),
)}
/>
) : null}
<span className="truncate">{item.name}</span>
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)}
</a>
</SidebarMenuSubButton>
</TooltipTrigger>
{item.description && (
<TooltipContent
side="right"
align="center"
className="max-w-64"
>
{item.description.length > 80
? item.description.slice(0, 80) + '…'
: item.description}
</TooltipContent>
)}
</Tooltip>
{/* Plugin context menu - shown on hover (not for debug plugins) */}
{isPlugin && !item.debug && (
<PluginItemMenu
item={item}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
)}
</SidebarMenuSubItem>
);
})}
{/* Show more / less toggle when items exceed limit */}
{sortedItems.length > maxItems && !inPopover && (
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
className="text-muted-foreground"
>
<button
type="button"
onClick={() =>
setExpandedLists((prev) => ({
...prev,
[config.id]: !isExpanded,
}))
}
>
<span className="text-xs">
{isExpanded
? t('common.less')
: t('common.more', { count: hiddenCount })}
</span>
</button>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
{hiddenCount > 0 && inPopover && !isExpanded && (
<button
type="button"
className="flex w-full items-center justify-center rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
onClick={() =>
setExpandedLists((prev) => ({
...prev,
[config.id]: true,
}))
}
>
{t('common.more', { count: hiddenCount })}
</button>
)}
</>
);
};
// Popover flyout for collapsed sidebar
if (showPopover) {
return (
<SidebarMenuItem key={config.id}>
<Popover
open={popoverOpen[config.id] ?? false}
onOpenChange={(open) =>
setPopoverOpen((prev) => ({ ...prev, [config.id]: open }))
}
>
<PopoverTrigger asChild>
<SidebarMenuButton
isActive={isActive}
tooltip={config.name}
className="group/category-header"
>
{config.icon}
<span>{config.name}</span>
</SidebarMenuButton>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-56 p-2"
>
<div className="flex items-center justify-between mb-1 px-2">
<span className="text-sm font-medium">{config.name}</span>
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
))}
</div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)}
</div>
</PopoverContent>
</Popover>
</SidebarMenuItem>
);
}
// Normal expanded sidebar with collapsible sub-items
return (
<Collapsible
key={config.id}
asChild
open={isOpen}
onOpenChange={(open) => onSectionToggle(config.id, open)}
className="group/collapsible"
>
<SidebarMenuItem>
<SidebarMenuButton
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>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground 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();
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>
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground 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()}
>
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button>
</CollapsibleTrigger>
</div>
</SidebarMenuButton>
<CollapsibleContent>
<SidebarMenuSub>{renderEntityList(false)}</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
})}
{/* Plugin operation confirmation dialog */}
<Dialog
open={showPluginOpModal}
onOpenChange={(open) => {
if (!open) {
setShowPluginOpModal(false);
setTargetPluginItem(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>
{(() => {
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 });
})()}
</div>
{pluginOpType === PluginOperationType.DELETE && (
<div className="flex items-center space-x-2">
<Checkbox
id="sidebar-delete-data"
checked={deleteData}
onCheckedChange={(checked) =>
setDeleteData(checked === true)
}
/>
<label
htmlFor="sidebar-delete-data"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{t('plugins.deleteDataCheckbox')}
</label>
</div>
)}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowPluginOpModal(false);
setTargetPluginItem(null);
asyncTask.reset();
}}
>
{t('common.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
pluginOpType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={executePluginOperation}
>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
pluginOpType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowPluginOpModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// 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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={`absolute right-1 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
open
? 'opacity-100'
: item.hasUpdate
? 'opacity-100'
: 'opacity-0 group-hover/plugin-item:opacity-100'
} transition-opacity`}
onClick={(e) => e.stopPropagation()}
>
<Ellipsis className="size-4" />
{item.hasUpdate && !open && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-red-500" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
onUpdate();
setOpen(false);
}}
>
<ArrowUp className="size-4" />
<span>{t('plugins.update')}</span>
{item.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{hasSourceLink && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
handleViewSource();
setOpen(false);
}}
>
<ExternalLink className="size-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
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<SidebarChildVO>();
const [sectionOpenState, setSectionOpenState] =
useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null,
);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(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 (
<>
<Sidebar variant="inset" collapsible="icon">
{/* Header: Logo using sidebar-07 team-switcher pattern */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="cursor-default hover:bg-transparent active:bg-transparent"
tooltip="LangBot"
>
<img
src={langbotIcon}
alt="LangBot"
className="size-8 rounded-lg"
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">LangBot</span>
<div className="flex items-center gap-1.5">
<span className="truncate text-xs text-muted-foreground">
{systemInfo?.version}
</span>
{hasNewVersion && (
<Badge
onClick={() => 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')}
</Badge>
)}
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* Navigation items grouped by section */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<NavItems
selectedChild={selectedChild}
onChildClick={handleChildClick}
section="home"
sectionOpenState={sectionOpenState}
onSectionToggle={handleSectionToggle}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>{t('sidebar.extensions')}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<NavItems
selectedChild={selectedChild}
onChildClick={handleChildClick}
section="extensions"
sectionOpenState={sectionOpenState}
onSectionToggle={handleSectionToggle}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/* Footer */}
<SidebarFooter>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleModelsDialogChange(true)}
tooltip={t('models.title')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className="text-blue-500"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM4.53956 9.82234C6.8254 10.837 8.68402 12.5048 9.74238 14.7996 10.8008 12.5048 12.6594 10.837 14.9452 9.82234 12.6321 8.79557 10.7676 7.04647 9.74239 4.71088 8.71719 7.04648 6.85267 8.79557 4.53956 9.82234ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899ZM18.3745 19.0469 18.937 18.4883 19.4878 19.0469 18.937 19.5898 18.3745 19.0469Z" />
</svg>
<span>{t('models.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
tooltip={t('common.accountOptions')}
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{userEmail || t('common.accountOptions')}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
{/* User info header */}
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{userEmail || t('common.accountOptions')}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Language & Theme row */}
<div className="flex items-center gap-2 px-1 py-1">
<LanguageSelector triggerClassName="flex-1" />
<Button
variant="outline"
size="icon"
onClick={() =>
setTheme(
theme === 'light'
? 'dark'
: theme === 'dark'
? 'system'
: 'light',
)
}
className="h-9 w-9 shrink-0"
>
{theme === 'light' && (
<Sun className="h-[1.2rem] w-[1.2rem]" />
)}
{theme === 'dark' && (
<Moon className="h-[1.2rem] w-[1.2rem]" />
)}
{theme === 'system' && (
<Monitor className="h-[1.2rem] w-[1.2rem]" />
)}
</Button>
</div>
<DropdownMenuSeparator />
{/* Account actions */}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => handleAccountSettingsChange(true)}
>
<Settings />
{t('account.settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
navigate('/wizard');
}}
>
<Zap className="text-blue-500" />
{t('sidebar.quickStart')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{/* External links */}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
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',
);
}
}}
>
<CircleHelp />
{t('common.helpDocs')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
'https://github.com/langbot-app/LangBot/issues',
'_blank',
);
}}
>
<Lightbulb />
{t('common.featureRequest')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
'https://github.com/langbot-app/LangBot',
'_blank',
);
}}
>
<Star
className={cn(
'text-yellow-500',
userMenuOpen && 'animate-twinkle',
)}
/>
<span className="flex-1">{t('common.starOnGitHub')}</span>
{starCount != null && (
<Badge variant="secondary" className="ml-auto text-xs">
{starCount >= 1000
? `${(starCount / 1000).toFixed(1)}k`
: starCount}
</Badge>
)}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{/* Logout */}
<DropdownMenuItem onClick={() => handleLogout()}>
<LogOut />
{t('common.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<AccountSettingsDialog
open={accountSettingsOpen}
onOpenChange={handleAccountSettingsChange}
/>
<ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
/>
<NewVersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
release={latestRelease}
/>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</>
);
}