mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-14 01:36:03 +00:00
* 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.
1513 lines
56 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|