feat(web): refactor MCP servers as sidebar entities and improve sidebar footer

- Refactor MCP servers to be managed as collapsible sidebar sub-items with
  ?id= detail routing and inline form (matching bots/pipelines pattern)
- Add MCPDetailContent with create/edit modes, enable toggle, and danger zone
- Extract MCPForm as standalone inline form from MCPFormDialog
- Move API Integration to standalone sidebar footer button
- Add GitHub star CTA with live star count badge in user dropdown menu
- Add MCP server status dot indicators in sidebar (green/gray for enabled/disabled)
- Add i18n keys for MCP detail page and GitHub star CTA in all 4 locales
This commit is contained in:
Junyan Qin
2026-03-27 19:59:34 +08:00
parent 4902c1d3b2
commit 23fa47b07e
11 changed files with 1359 additions and 100 deletions

View File

@@ -18,6 +18,7 @@ import {
LogOut,
KeyRound,
Settings,
Star,
Ellipsis,
ArrowUp,
ExternalLink,
@@ -115,6 +116,7 @@ const ENTITY_CATEGORY_IDS = [
'pipelines',
'knowledge',
'plugins',
'mcp',
] as const;
type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number];
@@ -124,6 +126,7 @@ const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [
'pipelines',
'knowledge',
'plugins',
'mcp',
];
// Categories that support creating new entities from the sidebar
@@ -131,6 +134,7 @@ const CREATABLE_CATEGORIES: EntityCategoryId[] = [
'bots',
'pipelines',
'knowledge',
'mcp',
];
// Categories where clicking the parent only toggles collapse (no list page)
@@ -138,6 +142,7 @@ const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [
'bots',
'pipelines',
'knowledge',
'mcp',
];
function isEntityCategory(id: string): id is EntityCategoryId {
@@ -147,12 +152,13 @@ function isEntityCategory(id: string): id is EntityCategoryId {
// Map sidebar config IDs to SidebarDataContext keys
const ENTITY_KEY_MAP: Record<
EntityCategoryId,
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins'
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers'
> = {
bots: 'bots',
pipelines: 'pipelines',
knowledge: 'knowledgeBases',
plugins: 'plugins',
mcp: 'mcpServers',
};
// Route prefix map for entity detail pages
@@ -161,6 +167,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
pipelines: '/home/pipelines',
knowledge: '/home/knowledge',
plugins: '/home/plugins',
mcp: '/home/mcp',
};
// localStorage key for collapsible section open/closed state
@@ -324,6 +331,7 @@ function NavItems({
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 ||
@@ -400,7 +408,7 @@ function NavItems({
alt=""
className="size-4 rounded"
/>
{isBot && (
{(isBot || isMCP) && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
@@ -411,6 +419,15 @@ function NavItems({
/>
)}
</span>
) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
) : null}
<span className="truncate">{item.name}</span>
</button>
@@ -447,7 +464,7 @@ function NavItems({
alt=""
className="size-4 rounded"
/>
{isBot && (
{(isBot || isMCP) && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
@@ -458,6 +475,15 @@ function NavItems({
/>
)}
</span>
) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
) : null}
<span className="truncate">{item.name}</span>
{item.debug && (
@@ -914,7 +940,8 @@ export default function HomeSidebar({
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) {
@@ -985,6 +1012,15 @@ export default function HomeSidebar({
.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
@@ -1122,6 +1158,19 @@ export default function HomeSidebar({
{/* Footer */}
<SidebarFooter>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
@@ -1145,7 +1194,7 @@ export default function HomeSidebar({
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenu open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
@@ -1226,10 +1275,6 @@ export default function HomeSidebar({
<Settings />
{t('account.settings')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setApiKeyDialogOpen(true)}>
<KeyRound />
{t('common.apiIntegration')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -1266,6 +1311,29 @@ export default function HomeSidebar({
<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 />

View File

@@ -34,10 +34,12 @@ export interface SidebarDataContextValue {
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
@@ -55,6 +57,7 @@ export function SidebarDataProvider({
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const refreshBots = useCallback(async () => {
@@ -158,14 +161,36 @@ export function SidebarDataProvider({
}
}, []);
const refreshMCPServers = useCallback(async () => {
try {
const resp = await httpClient.getMCPServers();
setMCPServers(
resp.servers.map((server) => ({
id: server.name,
name: server.name,
enabled: server.enable,
})),
);
} catch (error) {
console.error('Failed to fetch MCP servers for sidebar:', error);
}
}, []);
const refreshAll = useCallback(async () => {
await Promise.all([
refreshBots(),
refreshPipelines(),
refreshKnowledgeBases(),
refreshPlugins(),
refreshMCPServers(),
]);
}, [refreshBots, refreshPipelines, refreshKnowledgeBases, refreshPlugins]);
}, [
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
]);
// Fetch all entity lists on mount
useEffect(() => {
@@ -179,10 +204,12 @@ export function SidebarDataProvider({
pipelines,
knowledgeBases,
plugins,
mcpServers,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
refreshAll,
detailEntityName,
setDetailEntityName,