refactor: replace Segmented tabs with SearchBar in ProfileEditor; gate local-system injection (#15593)

* 🐛 fix: activator tool discovery for cloud-sandbox and local-system

- P0: Explicitly inject LocalSystemManifest when device gateway is configured
  (discoverable: isDesktop is always false on server, so it never enters
  the discovery loop. The explicit injection mirrors the canUseDevice guard.)

- P1: Skip CloudSandboxManifest when runtimeMode is not 'cloud'
  (resolveRuntimeMode unifies executionTarget='sandbox' and legacy
  chatConfig.runtimeEnv.runtimeMode paths, so agents with sandbox
  disabled correctly exclude the cloud-sandbox tool.)

Both fixes operate at the manifest-map build stage, consistently affecting
all downstream consumers (activator discovery, availableTools, etc.)

* 🐛 fix: remove cloud-sandbox manifest when runtime is not sandbox

The initial manifest seed via getEnabledPluginManifests includes
defaultToolIds (which contains lobe-cloud-sandbox), so the manifest
was already in toolManifestMap before the allowedBuiltinTools loop's
continue guard. This made lobe-cloud-sandbox activatable even when
sandbox was disabled.

Add a delete right after resolveRuntimeMode to cover both the
manifestMap seed and the allowedBuiltinTools loop in one place.

Co-authored-by: chatgpt-codex-connector[bot]

* ♻️ refactor: replace Segmented tabs with SearchBar in ProfileEditor tool dropdown

- PopoverContent: replace Segmented with SearchBar + internal client-side filtering (same pattern as ChatInput ActionBar)
- AgentTool: remove ~270 lines of duplicated installedTabItems useMemo; pass unified items
- AgentTool: add auto-cleanup for stale plugin identifiers in agent config
This commit is contained in:
LiJian
2026-06-12 11:18:44 +08:00
committed by GitHub
parent 61586b9377
commit ca91d2d756
2 changed files with 153 additions and 406 deletions
+70 -337
View File
@@ -44,8 +44,6 @@ import PopoverContent from './PopoverContent';
const WEB_BROWSING_IDENTIFIER = 'lobe-web-browsing';
type TabType = 'all' | 'installed';
export interface AgentToolProps {
/**
* Optional agent ID to use instead of currentAgentConfig
@@ -116,10 +114,6 @@ const AgentTool = memo<AgentToolProps>(
const [updating, setUpdating] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
// Tab state for dual-column layout
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const isInitializedRef = useRef(false);
// Fetch plugins
const [
useFetchUserKlavisServers,
@@ -199,15 +193,6 @@ const AgentTool = memo<AgentToolProps>(
[canEdit, toggleWebBrowsing, togglePlugin, showWebBrowsing],
);
// Set default tab based on installed plugins (only on first load)
// Only show 'installed' tab by default if more than 5 plugins are enabled
useEffect(() => {
if (!isInitializedRef.current && plugins.length >= 0) {
isInitializedRef.current = true;
setActiveTab(plugins.length > 5 ? 'installed' : 'all');
}
}, [plugins.length]);
// Get connected server by identifier
const getServerByName = (identifier: string) => {
return allKlavisServers.find((server) => server.identifier === identifier);
@@ -643,324 +628,6 @@ const AgentTool = memo<AgentToolProps>(
[builtinItems, communityGroupChildren, customGroupChildren, t],
);
// Installed tab items - only show enabled items
const installedTabItems: ItemType[] = useMemo(() => {
const items: ItemType[] = [];
// Enabled builtin tools
const enabledBuiltinItems = filteredBuiltinList
.filter((item) => isToolEnabled(item.identifier))
.map((item) => ({
icon: (
<Avatar
avatar={item.meta.avatar}
size={SKILL_ICON_SIZE}
style={{ marginInlineEnd: 0 }}
/>
),
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(item.identifier);
setUpdating(false);
}}
/>
),
popoverContent: (
<ToolItemDetailPopover
identifier={item.identifier}
sourceLabel={t('skillStore.tabs.lobehub')}
description={t(`tools.builtins.${item.identifier}.description` as any, {
defaultValue: item.meta?.description || '',
})}
icon={
<Avatar
avatar={item.meta.avatar}
size={36}
style={{ flex: 'none', marginInlineEnd: 0 }}
/>
}
title={t(`tools.builtins.${item.identifier}.title` as any, {
defaultValue: item.meta?.title || item.identifier,
})}
/>
),
}));
// Connected and enabled Klavis servers
const connectedKlavisItems = klavisServerItems.filter((item) =>
plugins.includes(item.key as string),
);
// Connected LobeHub Skill Providers
const connectedLobehubSkillItems = lobehubSkillItems.filter((item) =>
plugins.includes(item.key as string),
);
// Enabled Builtin Agent Skills
const enabledBuiltinAgentSkillItems = installedBuiltinSkills
.filter((skill) => isToolEnabled(skill.identifier))
.map((skill) => ({
icon: skill.avatar ? (
<Avatar avatar={skill.avatar} size={SKILL_ICON_SIZE} style={{ marginInlineEnd: 0 }} />
) : (
<Icon icon={SkillsIcon} size={SKILL_ICON_SIZE} />
),
key: skill.identifier,
label: (
<ToolItem
checked={true}
id={skill.identifier}
label={skill.name}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(skill.identifier);
setUpdating(false);
}}
/>
),
popoverContent: (
<ToolItemDetailPopover
description={skill.description}
identifier={skill.identifier}
sourceLabel={t('skillStore.tabs.lobehub')}
title={skill.name}
icon={
skill.avatar ? (
<Avatar
avatar={skill.avatar}
size={36}
style={{ flex: 'none', marginInlineEnd: 0 }}
/>
) : (
<Icon icon={SkillsIcon} size={36} />
)
}
/>
),
}));
// LobeHub group (Builtin Agent Skills + builtin + LobeHub Skill + Klavis)
const lobehubGroupItems = [
...enabledBuiltinAgentSkillItems,
...enabledBuiltinItems,
...connectedLobehubSkillItems,
...connectedKlavisItems,
];
if (lobehubGroupItems.length > 0) {
items.push({
children: lobehubGroupItems,
key: 'installed-lobehub',
label: t('skillStore.tabs.lobehub'),
type: 'group',
});
}
// Enabled community plugins
const enabledCommunityPlugins = communityPlugins
.filter((item) => plugins.includes(item.identifier))
.map((item) => {
const isMcp = item?.avatar === 'MCP_AVATAR' || !item?.avatar;
return {
icon: isMcp ? (
<Icon icon={McpIcon} size={SKILL_ICON_SIZE} />
) : (
<Avatar avatar={item.avatar} shape={'square'} size={SKILL_ICON_SIZE} />
),
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await togglePlugin(item.identifier);
setUpdating(false);
}}
/>
),
popoverContent: (
<ToolItemDetailPopover
description={item.description}
identifier={item.identifier}
sourceLabel={t('skillStore.tabs.community')}
title={item.title}
icon={
isMcp ? (
<Icon icon={McpIcon} size={36} />
) : (
<Avatar
avatar={item.avatar}
shape={'square'}
size={36}
style={{ flex: 'none', marginInlineEnd: 0 }}
/>
)
}
/>
),
};
});
// Enabled Market Agent Skills
const enabledMarketAgentSkillItems = marketAgentSkills
.filter((skill) => isToolEnabled(skill.identifier))
.map((skill) => ({
icon: (
<MarketSkillIcon
identifier={skill.identifier}
name={skill.name}
size={SKILL_ICON_SIZE}
/>
),
key: skill.identifier,
label: (
<ToolItem
checked={true}
id={skill.identifier}
label={skill.name}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(skill.identifier);
setUpdating(false);
}}
/>
),
popoverContent: (
<MarketAgentSkillPopoverContent
description={skill.description}
identifier={skill.identifier}
name={skill.name}
sourceLabel={t('skillStore.tabs.community')}
/>
),
}));
// Community group (Market Agent Skills + community plugins)
const allCommunityItems = [...enabledMarketAgentSkillItems, ...enabledCommunityPlugins];
if (allCommunityItems.length > 0) {
items.push({
children: allCommunityItems,
key: 'installed-community',
label: t('skillStore.tabs.community'),
type: 'group',
});
}
// Enabled custom plugins
const enabledCustomPlugins = customPlugins
.filter((item) => plugins.includes(item.identifier))
.map((item) => {
const isMcp = item?.avatar === 'MCP_AVATAR' || !item?.avatar;
return {
icon: isMcp ? (
<Icon icon={McpIcon} size={SKILL_ICON_SIZE} />
) : (
<Avatar avatar={item.avatar} shape={'square'} size={SKILL_ICON_SIZE} />
),
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await togglePlugin(item.identifier);
setUpdating(false);
}}
/>
),
popoverContent: (
<ToolItemDetailPopover
description={item.description}
identifier={item.identifier}
sourceLabel={t('skillStore.tabs.custom')}
title={item.title}
icon={
isMcp ? (
<Icon icon={McpIcon} size={36} />
) : (
<Avatar
avatar={item.avatar}
shape={'square'}
size={36}
style={{ flex: 'none', marginInlineEnd: 0 }}
/>
)
}
/>
),
};
});
// Enabled User Agent Skills
const enabledUserAgentSkillItems = userAgentSkills
.filter((skill) => isToolEnabled(skill.identifier))
.map((skill) => ({
icon: <Icon icon={SkillsIcon} size={SKILL_ICON_SIZE} />,
key: skill.identifier,
label: (
<ToolItem
checked={true}
id={skill.identifier}
label={skill.name}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(skill.identifier);
setUpdating(false);
}}
/>
),
popoverContent: (
<ToolItemDetailPopover
description={skill.description}
icon={<Icon icon={SkillsIcon} size={36} />}
identifier={skill.identifier}
sourceLabel={t('skillStore.tabs.custom')}
title={skill.name}
/>
),
}));
// Custom group (User Agent Skills + custom plugins)
const allCustomItems = [...enabledUserAgentSkillItems, ...enabledCustomPlugins];
if (allCustomItems.length > 0) {
items.push({
children: allCustomItems,
key: 'installed-custom',
label: t('skillStore.tabs.custom'),
type: 'group',
});
}
return items;
}, [
filteredBuiltinList,
installedBuiltinSkills,
marketAgentSkills,
userAgentSkills,
klavisServerItems,
lobehubSkillItems,
communityPlugins,
customPlugins,
plugins,
isToolEnabled,
handleToggleTool,
togglePlugin,
t,
]);
// Use effective tab for display (default to all while initializing)
const effectiveTab = activeTab ?? 'all';
const button = (
<Button
disabled={!canEdit}
@@ -974,6 +641,75 @@ const AgentTool = memo<AgentToolProps>(
</Button>
);
// ──────────────────────────────────────────────
// Auto-cleanup stale plugins that no longer exist
// ──────────────────────────────────────────────
// Build the set of all valid identifiers known to the system
const validIdentifiers = useMemo(() => {
const all = new Set<string>();
// 1. Builtin tools (includes Klavis metas)
for (const tool of builtinList) all.add(tool.identifier);
// 2. Installed plugins
for (const plugin of installedPluginList) all.add(plugin.identifier);
// 3. Klavis server types (if enabled)
if (isKlavisEnabledInEnv) {
for (const type of KLAVIS_SERVER_TYPES) all.add(type.identifier);
}
// 4. LobeHub Skill providers (if enabled)
if (isLobehubSkillEnabled) {
for (const provider of LOBEHUB_SKILL_PROVIDERS) all.add(provider.id);
}
// 5. Builtin skills
for (const skill of installedBuiltinSkills) all.add(skill.identifier);
// 6. Market agent skills
for (const skill of marketAgentSkills) all.add(skill.identifier);
// 7. User agent skills
for (const skill of userAgentSkills) all.add(skill.identifier);
return all;
}, [
builtinList,
installedPluginList,
isKlavisEnabledInEnv,
isLobehubSkillEnabled,
installedBuiltinSkills,
marketAgentSkills,
userAgentSkills,
]);
// Track whether initial cleanup has been performed
const cleanupDoneRef = useRef(false);
// Auto-remove stale plugin IDs from the agent config
// Uses a short debounce to allow async data (SWR) to complete loading
useEffect(() => {
if (cleanupDoneRef.current) return;
if (validIdentifiers.size === 0) return;
if (plugins.length === 0) return;
// Defer cleanup to avoid race with async data loading (SWR, Klavis, etc.)
const timer = setTimeout(() => {
const stalePlugins = plugins.filter((id) => !validIdentifiers.has(id));
if (stalePlugins.length > 0 && effectiveAgentId) {
const cleanedPlugins = plugins.filter((id) => validIdentifiers.has(id));
updateAgentConfigById(effectiveAgentId, { plugins: cleanedPlugins });
}
cleanupDoneRef.current = true;
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [validIdentifiers]);
// Combine plugins and web browsing for display
const allEnabledTools = useMemo(() => {
const tools = [...plugins];
@@ -1011,11 +747,8 @@ const AgentTool = memo<AgentToolProps>(
}}
popupRender={() => (
<PopoverContent
activeTab={effectiveTab}
allTabItems={allTabItems}
installedTabItems={installedTabItems}
items={allTabItems}
onClose={() => setDropdownOpen(false)}
onTabChange={setActiveTab}
onOpenStore={() => {
setDropdownOpen(false);
createSkillStoreModal();
+83 -69
View File
@@ -1,8 +1,8 @@
import { type ItemType } from '@lobehub/ui';
import { Flexbox, Icon, Segmented, stopPropagation } from '@lobehub/ui';
import { Flexbox, Icon, SearchBar, stopPropagation } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { ChevronRight, ExternalLink, Settings, Store } from 'lucide-react';
import { memo } from 'react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollSignalProvider } from '@/features/ChatInput/ActionBar/Tools/ScrollSignalContext';
@@ -11,18 +11,42 @@ import { useWorkspaceAwareNavigate } from '@/features/Workspace/useWorkspaceAwar
import Empty from './Empty';
type TabType = 'all' | 'installed';
const SKILL_ICON_SIZE = 20;
const filterItems = (items: ItemType[], keyword: string): ItemType[] => {
const lower = keyword.toLowerCase();
return items
.map((item) => {
if (!item) return null;
if (item.type === 'group' && 'children' in item && item.children) {
const filtered = item.children.filter((child) => {
if (!child) return false;
const key = String(child.key || '').toLowerCase();
return key.includes(lower);
});
if (filtered.length === 0) return null;
return { ...item, children: filtered };
}
if (item.type === 'divider') return item;
const key = String('key' in item ? item.key : '').toLowerCase();
return key.includes(lower) ? item : null;
})
.filter(Boolean) as ItemType[];
};
const styles = createStaticStyles(({ css }) => ({
footer: css`
padding: 4px;
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
`,
header: css`
padding: ${cssVar.paddingXS};
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
padding-block: 8px;
padding-inline: 8px;
border-block-end: 1px solid ${cssVar.colorFill};
background: transparent;
`,
scroller: css`
@@ -34,77 +58,67 @@ const styles = createStaticStyles(({ css }) => ({
}));
interface PopoverContentProps {
activeTab: TabType;
allTabItems: ItemType[];
installedTabItems: ItemType[];
items: ItemType[];
onClose?: () => void;
onOpenStore: () => void;
onTabChange: (tab: TabType) => void;
}
const PopoverContent = memo<PopoverContentProps>(
({ activeTab, onTabChange, allTabItems, installedTabItems, onOpenStore, onClose }) => {
const { t } = useTranslation('setting');
const navigate = useWorkspaceAwareNavigate();
const PopoverContent = memo<PopoverContentProps>(({ items, onOpenStore, onClose }) => {
const { t } = useTranslation('setting');
const navigate = useWorkspaceAwareNavigate();
const [searchKeyword, setSearchKeyword] = useState('');
const currentItems = activeTab === 'all' ? allTabItems : installedTabItems;
const filteredItems = useMemo(
() => (searchKeyword ? filterItems(items, searchKeyword) : items),
[items, searchKeyword],
);
return (
<Flexbox style={{ maxHeight: 500, width: '100%' }}>
{/* stopPropagation prevents dropdown's onClick from calling preventDefault on Segmented */}
<div className={styles.header} onClick={stopPropagation}>
<Segmented
block
size="small"
value={activeTab}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'All' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
onChange={(v) => onTabChange(v as TabType)}
/>
</div>
<ScrollSignalProvider className={styles.scroller} style={{ flex: 1 }}>
{activeTab === 'installed' && installedTabItems.length === 0 ? (
<Empty />
) : (
<ToolsList items={currentItems} />
)}
</ScrollSignalProvider>
<div className={styles.footer}>
<div className={toolsListStyles.item} role="button" tabIndex={0} onClick={onOpenStore}>
<div className={toolsListStyles.itemIcon}>
<Icon icon={Store} size={SKILL_ICON_SIZE} />
</div>
<div className={toolsListStyles.itemContent}>{t('skillStore.title')}</div>
<Icon className={styles.trailingIcon} icon={ChevronRight} size={16} />
</div>
<div
className={toolsListStyles.item}
role="button"
tabIndex={0}
onClick={() => {
onClose?.();
navigate('/settings/skill');
}}
>
<div className={toolsListStyles.itemIcon}>
<Icon icon={Settings} size={SKILL_ICON_SIZE} />
</div>
<div className={toolsListStyles.itemContent}>{t('tools.plugins.management')}</div>
<Icon className={styles.trailingIcon} icon={ExternalLink} size={16} />
const isEmpty = filteredItems.length === 0;
return (
<Flexbox style={{ maxHeight: 500, width: '100%' }}>
<div className={styles.header} onClick={stopPropagation}>
<SearchBar
allowClear
placeholder={t('tools.search')}
size="small"
style={{ flex: 1 }}
value={searchKeyword}
variant="borderless"
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={stopPropagation}
/>
</div>
<ScrollSignalProvider className={styles.scroller} style={{ flex: 1 }}>
{isEmpty ? <Empty /> : <ToolsList items={filteredItems} />}
</ScrollSignalProvider>
<div className={styles.footer}>
<div className={toolsListStyles.item} role="button" tabIndex={0} onClick={onOpenStore}>
<div className={toolsListStyles.itemIcon}>
<Icon icon={Store} size={SKILL_ICON_SIZE} />
</div>
<div className={toolsListStyles.itemContent}>{t('skillStore.title')}</div>
<Icon className={styles.trailingIcon} icon={ChevronRight} size={16} />
</div>
</Flexbox>
);
},
);
<div
className={toolsListStyles.item}
role="button"
tabIndex={0}
onClick={() => {
onClose?.();
navigate('/settings/skill');
}}
>
<div className={toolsListStyles.itemIcon}>
<Icon icon={Settings} size={SKILL_ICON_SIZE} />
</div>
<div className={toolsListStyles.itemContent}>{t('tools.plugins.management')}</div>
<Icon className={styles.trailingIcon} icon={ExternalLink} size={16} />
</div>
</div>
</Flexbox>
);
});
PopoverContent.displayName = 'PopoverContent';