feat: improve baseline alignment for tool items (#11447)

*  feat: improve baseline alignment for tool items

Fix baseline alignment issue for KlavisServerItem and LobehubSkillServerItem components.

Fixes LOBE-2106

* refactor: improve next config modification logic by removing webVitalsAttribution and adding invariant checks to property removals.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update category selection logic in community interactions steps

- Changed category selection from the second to the third category to better align with the actual category filters.
- Improved code readability by restructuring comments and formatting for better clarity.
- Enhanced logging for URL verification and initial card count checks.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-13 00:02:30 +08:00
committed by GitHub
parent cd18ea36cc
commit be8dddd52d
12 changed files with 824 additions and 1284 deletions

View File

@@ -22,6 +22,7 @@ config.rules['unicorn/prefer-query-selector'] = 0;
config.rules['unicorn/no-array-callback-reference'] = 0;
// FIXME: Linting error in src/app/[variants]/(main)/chat/features/Migration/DBReader.ts, the fundamental solution should be upgrading typescript-eslint
config.rules['@typescript-eslint/no-useless-constructor'] = 0;
config.rules['@next/next/no-img-element'] = 0;
config.overrides = [
{

View File

@@ -55,12 +55,13 @@ When('I click on a category in the category menu', async function (this: CustomW
// Wait for categories to be visible
await categoryItems.first().waitFor({ state: 'visible', timeout: 30_000 });
// Click the second category (skip "All" which is usually first)
const secondCategory = categoryItems.nth(1);
await secondCategory.click();
// Click the third category (skip "Discover" at index 0 and "All" at index 1)
// This should select the first actual category filter like "Academic"
const targetCategory = categoryItems.nth(2);
await targetCategory.click();
// Store the category for later verification
const categoryText = await secondCategory.textContent();
const categoryText = await targetCategory.textContent();
this.testContext.selectedCategory = categoryText?.trim();
});
@@ -94,12 +95,13 @@ When('I click on a category in the category filter', async function (this: Custo
// Wait for categories to be visible
await categoryItems.first().waitFor({ state: 'visible', timeout: 30_000 });
// Click the second category (skip "All" which is usually first)
const secondCategory = categoryItems.nth(1);
await secondCategory.click();
// Click the third category (skip "Discover" at index 0 and "All" at index 1)
// This should select the first actual category filter
const targetCategory = categoryItems.nth(2);
await targetCategory.click();
// Store the category for later verification
const categoryText = await secondCategory.textContent();
const categoryText = await targetCategory.textContent();
this.testContext.selectedCategory = categoryText?.trim();
});
@@ -285,10 +287,15 @@ When(
// Try to find "more" link near MCP-related content
const mcpSection = this.page.locator('section:has-text("MCP"), div:has-text("MCP Tools")');
const mcpSectionVisible = await mcpSection.first().isVisible().catch(() => false);
const mcpSectionVisible = await mcpSection
.first()
.isVisible()
.catch(() => false);
if (mcpSectionVisible) {
const moreLinkInSection = mcpSection.locator(`a:has-text("${linkText}"), button:has-text("${linkText}")`);
const moreLinkInSection = mcpSection.locator(
`a:has-text("${linkText}"), button:has-text("${linkText}")`,
);
if ((await moreLinkInSection.count()) > 0) {
await moreLinkInSection.first().click();
return;
@@ -297,7 +304,9 @@ When(
// Fallback: click on MCP in the sidebar navigation
console.log(' 📍 Fallback: clicking MCP in sidebar');
const mcpNavItem = this.page.locator('nav a:has-text("MCP"), [class*="nav"] a:has-text("MCP")').first();
const mcpNavItem = this.page
.locator('nav a:has-text("MCP"), [class*="nav"] a:has-text("MCP")')
.first();
if (await mcpNavItem.isVisible().catch(() => false)) {
await mcpNavItem.click();
return;
@@ -363,9 +372,19 @@ Then(
Then('the URL should contain the category parameter', async function (this: CustomWorld) {
const currentUrl = this.page.url();
console.log(` 📍 Current URL: ${currentUrl}`);
console.log(` 📍 Selected category: ${this.testContext.selectedCategory}`);
// Check if URL contains a category-related parameter
// The URL format is: /community/assistant?category=xxx
const hasCategory =
currentUrl.includes('category=') ||
currentUrl.includes('tag=') ||
// For path-based routing like /community/assistant/category-name
/\/community\/assistant\/[^/?]+/.test(currentUrl);
expect(
currentUrl.includes('category=') || currentUrl.includes('tag='),
hasCategory,
`Expected URL to contain category parameter, but got: ${currentUrl}`,
).toBeTruthy();
});
@@ -383,7 +402,9 @@ Then('I should see different assistant cards', async function (this: CustomWorld
// If we used infinite scroll, check that we have cards (might be same or more)
if (this.testContext.usedInfiniteScroll) {
console.log(` 📍 Used infinite scroll, initial count was: ${this.testContext.initialCardCount}`);
console.log(
` 📍 Used infinite scroll, initial count was: ${this.testContext.initialCardCount}`,
);
expect(currentCount).toBeGreaterThan(0);
} else {
expect(currentCount).toBeGreaterThan(0);
@@ -463,7 +484,9 @@ Then('I should see the model detail content', async function (this: CustomWorld)
// Model detail page should have tabs like "Overview", "Model Parameters"
// Wait for these specific elements to appear
const modelTabs = this.page.locator('text=/Overview|Model Parameters|Related Recommendations|Configuration Guide/');
const modelTabs = this.page.locator(
'text=/Overview|Model Parameters|Related Recommendations|Configuration Guide/',
);
console.log(' 📍 Waiting for model detail content to load...');
await expect(modelTabs.first()).toBeVisible({ timeout: 30_000 });

View File

@@ -1,14 +1,13 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { Switch } from 'antd';
import { type DropdownMenuCheckboxItem } from '@lobehub/ui';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
export const useMenu = (): { menuItems: any[] } => {
export const useMenu = (): { menuItems: DropdownMenuCheckboxItem[] } => {
const { t } = useTranslation('chat');
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
@@ -16,23 +15,14 @@ export const useMenu = (): { menuItems: any[] } => {
s.toggleWideScreen,
]);
const menuItems = useMemo(
const menuItems = useMemo<DropdownMenuCheckboxItem[]>(
() => [
{
checked: wideScreen,
key: 'full-width',
label: (
<Flexbox align="center" horizontal justify="space-between">
<span>{t('viewMode.fullWidth')}</span>
<Switch
checked={wideScreen}
onChange={toggleWideScreen}
onClick={(checked, event) => {
event.stopPropagation();
}}
size="small"
/>
</Flexbox>
),
label: t('viewMode.fullWidth'),
onCheckedChange: toggleWideScreen,
type: 'checkbox',
},
],
[t, wideScreen, toggleWideScreen],

View File

@@ -1,494 +1,15 @@
'use client';
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Button, Flexbox, Icon, type ItemType, Segmented } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, PlusIcon, Store, ToyBrick } from 'lucide-react';
import Image from 'next/image';
import React, { Suspense, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import KlavisServerItem from '@/features/ChatInput/ActionBar/Tools/KlavisServerItem';
import ToolItem from '@/features/ChatInput/ActionBar/Tools/ToolItem';
import ActionDropdown from '@/features/ChatInput/ActionBar/components/ActionDropdown';
import PluginStore from '@/features/PluginStore';
import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
builtinToolSelectors,
klavisStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
import PluginTag from './PluginTag';
const WEB_BROWSING_IDENTIFIER = 'lobe-web-browsing';
type TabType = 'all' | 'installed';
const prefixCls = 'ant';
const styles = createStaticStyles(({ css }) => ({
dropdown: css`
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgElevated};
box-shadow: ${cssVar.boxShadowSecondary};
.${prefixCls}-dropdown-menu {
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
`,
header: css`
padding: ${cssVar.paddingXS};
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: transparent;
`,
scroller: css`
overflow: hidden auto;
`,
}));
import { AgentTool as SharedAgentTool } from '@/features/ProfileEditor';
/**
* Klavis 服务器图标组件
* 对于 string 类型的 icon使用 Image 组件渲染
* 对于 IconType 类型的 icon使用 Icon 组件渲染,并根据主题设置填充色
* AgentTool for agent profile editor
* - showWebBrowsing: Agent profile supports web browsing toggle
* - filterAvailableInWeb: Filter out desktop-only tools in web version
* - useAllMetaList: Use allMetaList to include hidden tools
*/
const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return (
<Image alt={label} height={18} src={icon} style={{ flex: 'none' }} unoptimized width={18} />
);
}
// 使用主题色填充,在深色模式下自动适应
return <Icon fill={cssVar.colorText} icon={icon} size={18} />;
});
const AgentTool = memo(() => {
const { t } = useTranslation('setting');
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
// Plugin state management
const plugins = config?.plugins || [];
const toggleAgentPlugin = useAgentStore((s) => s.toggleAgentPlugin);
const updateAgentChatConfig = useAgentStore((s) => s.updateAgentChatConfig);
const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
const builtinList = useToolStore(builtinToolSelectors.allMetaList, isEqual);
// Web browsing uses searchMode instead of plugins array
const isSearchEnabled = useAgentStore(agentChatConfigSelectors.isAgentEnableSearch);
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
// Plugin store modal state
const [modalOpen, setModalOpen] = useState(false);
const [updating, setUpdating] = useState(false);
// Tab state for dual-column layout
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const isInitializedRef = useRef(false);
// Fetch plugins
const [useFetchPluginStore, useFetchUserKlavisServers] = useToolStore((s) => [
s.useFetchPluginStore,
s.useFetchUserKlavisServers,
]);
useFetchPluginStore();
useFetchInstalledPlugins();
useCheckPluginsIsInstalled(plugins);
// 使用 SWR 加载用户的 Klavis 集成(从数据库)
useFetchUserKlavisServers(isKlavisEnabledInEnv);
// Toggle web browsing via searchMode
const toggleWebBrowsing = useCallback(async () => {
const nextMode = isSearchEnabled ? 'off' : 'auto';
await updateAgentChatConfig({ searchMode: nextMode });
}, [isSearchEnabled, updateAgentChatConfig]);
// Check if a tool is enabled (handles web browsing specially)
const isToolEnabled = useCallback(
(identifier: string) => {
if (identifier === WEB_BROWSING_IDENTIFIER) {
return isSearchEnabled;
}
return plugins.includes(identifier);
},
[plugins, isSearchEnabled],
);
// Toggle a tool (handles web browsing specially)
const handleToggleTool = useCallback(
async (identifier: string) => {
if (identifier === WEB_BROWSING_IDENTIFIER) {
await toggleWebBrowsing();
} else {
await toggleAgentPlugin(identifier);
}
},
[toggleWebBrowsing, toggleAgentPlugin],
);
// Set default tab based on installed plugins (only on first load)
useEffect(() => {
if (!isInitializedRef.current && plugins.length >= 0) {
isInitializedRef.current = true;
setActiveTab(plugins.length > 0 ? 'installed' : 'all');
}
}, [plugins.length]);
// 根据 identifier 获取已连接的服务器
const getServerByName = (identifier: string) => {
return allKlavisServers.find((server) => server.identifier === identifier);
};
// 获取所有 Klavis 服务器类型的 identifier 集合(用于过滤 builtinList
const allKlavisTypeIdentifiers = useMemo(
() => new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier)),
[],
);
// 过滤掉 builtinList 中的 klavis 工具(它们会单独显示在 Klavis 区域)
// 同时过滤掉 availableInWeb: false 的工具(如 LocalSystem 仅桌面版可用)
const filteredBuiltinList = useMemo(
() =>
builtinList
.filter((item) => item.availableInWeb)
.filter((item) =>
isKlavisEnabledInEnv ? !allKlavisTypeIdentifiers.has(item.identifier) : true,
),
[builtinList, allKlavisTypeIdentifiers, isKlavisEnabledInEnv],
);
// Klavis 服务器列表项
const klavisServerItems = useMemo(
() =>
isKlavisEnabledInEnv
? KLAVIS_SERVER_TYPES.map((type) => ({
icon: <KlavisIcon icon={type.icon} label={type.label} />,
key: type.identifier,
label: (
<KlavisServerItem
identifier={type.identifier}
label={type.label}
server={getServerByName(type.identifier)}
serverName={type.serverName}
/>
),
}))
: [],
[isKlavisEnabledInEnv, allKlavisServers],
);
// Handle plugin remove via Tag close
const handleRemovePlugin =
(pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> }) =>
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
if (identifier === WEB_BROWSING_IDENTIFIER) {
await updateAgentChatConfig({ searchMode: 'off' });
} else {
toggleAgentPlugin(identifier, false);
}
};
// Build dropdown menu items (adapted from useControls)
const enablePluginCount = plugins.filter(
(id) => !builtinList.some((b) => b.identifier === id),
).length;
// 合并 builtin 工具和 Klavis 服务器
const builtinItems = useMemo(
() => [
// 原有的 builtin 工具
...filteredBuiltinList.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
key: item.identifier,
label: (
<ToolItem
checked={isToolEnabled(item.identifier)}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(item.identifier);
setUpdating(false);
}}
/>
),
})),
// Klavis 服务器
...klavisServerItems,
],
[filteredBuiltinList, klavisServerItems, isToolEnabled, handleToggleTool],
);
// Plugin items for dropdown
const pluginItems = useMemo(
() =>
installedPluginList.map((item) => ({
icon: item?.avatar ? (
<PluginAvatar avatar={item.avatar} size={20} />
) : (
<Icon icon={ToyBrick} size={20} />
),
key: item.identifier,
label: (
<ToolItem
checked={plugins.includes(item.identifier)}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
})),
[installedPluginList, plugins, toggleAgentPlugin],
);
// All tab items (市场 tab)
const allTabItems: ItemType[] = useMemo(
() => [
{
children: builtinItems,
key: 'builtins',
label: t('tools.builtins.groupName'),
type: 'group',
},
{
children: pluginItems,
key: 'plugins',
label: (
<Flexbox align={'center'} gap={40} horizontal justify={'space-between'}>
{t('tools.plugins.groupName')}
{enablePluginCount === 0 ? null : (
<div style={{ fontSize: 12, marginInlineEnd: 4 }}>
{t('tools.plugins.enabled', { num: enablePluginCount })}
</div>
)}
</Flexbox>
),
type: 'group',
},
{
type: 'divider',
},
{
extra: <Icon icon={ArrowRight} />,
icon: Store,
key: 'plugin-store',
label: t('tools.plugins.store'),
onClick: () => {
setModalOpen(true);
},
},
],
[builtinItems, pluginItems, enablePluginCount, t],
);
// Installed tab items - 只显示已启用的
const installedTabItems: ItemType[] = useMemo(() => {
const items: ItemType[] = [];
// 已启用的 builtin 工具
const enabledBuiltinItems = filteredBuiltinList
.filter((item) => isToolEnabled(item.identifier))
.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(item.identifier);
setUpdating(false);
}}
/>
),
}));
// 已连接且已启用的 Klavis 服务器
const connectedKlavisItems = klavisServerItems.filter((item) =>
plugins.includes(item.key as string),
);
// 合并 builtin 和 Klavis
const allBuiltinItems = [...enabledBuiltinItems, ...connectedKlavisItems];
if (allBuiltinItems.length > 0) {
items.push({
children: allBuiltinItems,
key: 'installed-builtins',
label: t('tools.builtins.groupName'),
type: 'group',
});
}
// 已启用的插件
const installedPlugins = installedPluginList
.filter((item) => plugins.includes(item.identifier))
.map((item) => ({
icon: item?.avatar ? (
<PluginAvatar avatar={item.avatar} size={20} />
) : (
<Icon icon={ToyBrick} size={20} />
),
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
}));
if (installedPlugins.length > 0) {
items.push({
children: installedPlugins,
key: 'installed-plugins',
label: t('tools.plugins.groupName'),
type: 'group',
});
}
return items;
}, [
filteredBuiltinList,
klavisServerItems,
installedPluginList,
plugins,
isToolEnabled,
handleToggleTool,
toggleAgentPlugin,
t,
]);
// Use effective tab for display (default to all while initializing)
const effectiveTab = activeTab ?? 'all';
const currentItems = effectiveTab === 'all' ? allTabItems : installedTabItems;
const button = (
<Button
icon={PlusIcon}
loading={updating}
size={'small'}
style={{ color: cssVar.colorTextSecondary }}
type={'text'}
>
{t('tools.add', { defaultValue: 'Add' })}
</Button>
);
// Combine plugins and web browsing for display
const allEnabledTools = useMemo(() => {
const tools = [...plugins];
// Add web browsing if enabled (it's not in plugins array)
if (isSearchEnabled && !tools.includes(WEB_BROWSING_IDENTIFIER)) {
tools.unshift(WEB_BROWSING_IDENTIFIER);
}
return tools;
}, [plugins, isSearchEnabled]);
return (
<>
{/* Plugin Selector and Tags */}
<Flexbox align="center" gap={8} horizontal wrap={'wrap'}>
{/* Second Row: Selected Plugins as Tags */}
{allEnabledTools.map((pluginId) => {
return (
<PluginTag key={pluginId} onRemove={handleRemovePlugin(pluginId)} pluginId={pluginId} />
);
})}
{/* Plugin Selector Dropdown - Using Action component pattern */}
<Suspense fallback={button}>
<ActionDropdown
maxHeight={500}
maxWidth={480}
menu={{
items: currentItems,
style: {
// let only the custom scroller scroll
maxHeight: 'unset',
overflowY: 'visible',
},
}}
minHeight={isKlavisEnabledInEnv ? 500 : undefined}
minWidth={320}
placement={'bottomLeft'}
popupRender={(menu) => (
<div className={styles.dropdown}>
{/* stopPropagation prevents dropdown's onClick from calling preventDefault on Segmented */}
<div className={styles.header} onClick={(e) => e.stopPropagation()}>
<Segmented
block
onChange={(v) => setActiveTab(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'All' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={effectiveTab}
/>
</div>
<div
className={styles.scroller}
style={{
maxHeight: 500,
minHeight: isKlavisEnabledInEnv ? 500 : undefined,
}}
>
{menu}
</div>
</div>
)}
trigger={['click']}
>
{button}
</ActionDropdown>
</Suspense>
</Flexbox>
{/* PluginStore Modal - rendered outside Flexbox to avoid event interference */}
{modalOpen && <PluginStore open={modalOpen} setOpen={setModalOpen} />}
</>
);
});
const AgentTool = () => {
return <SharedAgentTool filterAvailableInWeb showWebBrowsing useAllMetaList />;
};
export default AgentTool;

View File

@@ -1,195 +0,0 @@
'use client';
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { AlertCircle, X } from 'lucide-react';
import Image from 'next/image';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
builtinToolSelectors,
klavisStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
/**
* Klavis 服务器图标组件
*/
const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return (
<Image alt={label} height={16} src={icon} style={{ flexShrink: 0 }} unoptimized width={16} />
);
}
return <Icon fill={cssVar.colorText} icon={icon} size={16} />;
});
const styles = createStaticStyles(({ css, cssVar }) => ({
notInstalledTag: css`
border-color: ${cssVar.colorWarningBorder};
background: ${cssVar.colorWarningBg};
`,
tag: css`
height: 28px !important;
border-radius: ${cssVar.borderRadiusSM} !important;
`,
warningIcon: css`
flex-shrink: 0;
color: ${cssVar.colorWarning};
`,
}));
interface PluginTagProps {
onRemove: (e: React.MouseEvent) => void;
pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> };
}
const PluginTag = memo<PluginTagProps>(({ pluginId, onRemove }) => {
const isDarkMode = useIsDark();
const { t } = useTranslation('setting');
// Extract identifier
const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
// Get local plugin lists (use allMetaList to include hidden tools)
const builtinList = useToolStore(builtinToolSelectors.allMetaList, isEqual);
const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
// Check if plugin is installed
const isInstalled = useToolStore(pluginSelectors.isPluginInstalled(identifier));
// Try to find in local lists first (including Klavis)
const localMeta = useMemo(() => {
// Check if it's a Klavis server type
if (isKlavisEnabledInEnv) {
const klavisType = KLAVIS_SERVER_TYPES.find((type) => type.identifier === identifier);
if (klavisType) {
// Check if this Klavis server is connected
const connectedServer = allKlavisServers.find((s) => s.identifier === identifier);
return {
availableInWeb: true,
icon: klavisType.icon,
isInstalled: !!connectedServer,
label: klavisType.label,
title: klavisType.label,
type: 'klavis' as const,
};
}
}
const builtinMeta = builtinList.find((p) => p.identifier === identifier);
if (builtinMeta) {
return {
availableInWeb: builtinMeta.availableInWeb,
avatar: builtinMeta.meta.avatar,
isInstalled: true,
title: builtinMeta.meta.title,
type: 'builtin' as const,
};
}
const installedMeta = installedPluginList.find((p) => p.identifier === identifier);
if (installedMeta) {
return {
availableInWeb: true,
avatar: installedMeta.avatar,
isInstalled: true,
title: installedMeta.title,
type: 'plugin' as const,
};
}
return null;
}, [identifier, builtinList, installedPluginList, isKlavisEnabledInEnv, allKlavisServers]);
// Fetch from remote if not found locally
const usePluginDetail = useDiscoverStore((s) => s.usePluginDetail);
const { data: remoteData, isLoading } = usePluginDetail({
identifier: !localMeta && !isInstalled ? identifier : undefined,
withManifest: false,
});
// Determine final metadata
const meta = localMeta || {
availableInWeb: true,
avatar: remoteData?.avatar,
isInstalled: false,
title: remoteData?.title || identifier,
type: 'plugin' as const,
};
const displayTitle = isLoading ? 'Loading...' : meta.title;
const isDesktopOnly = !meta.availableInWeb;
// Render icon based on type
const renderIcon = () => {
if (!meta.isInstalled) {
return <AlertCircle className={styles.warningIcon} size={14} />;
}
// Klavis type has icon property
if (meta.type === 'klavis' && 'icon' in meta && 'label' in meta) {
return <KlavisIcon icon={meta.icon} label={meta.label} />;
}
// Builtin type has avatar
if (meta.type === 'builtin' && 'avatar' in meta && meta.avatar) {
return <Avatar avatar={meta.avatar} shape={'square'} size={16} style={{ flexShrink: 0 }} />;
}
// Plugin type
if ('avatar' in meta) {
return <PluginAvatar avatar={meta.avatar} size={16} />;
}
return null;
};
// Build display text
const getDisplayText = () => {
let text = displayTitle;
if (isDesktopOnly) {
text += ` (${t('tools.desktopOnly', { defaultValue: 'Desktop Only' })})`;
}
if (!meta.isInstalled) {
text += ` (${t('tools.notInstalled', { defaultValue: 'Not Installed' })})`;
}
return text;
};
return (
<Tag
className={styles.tag}
closable
closeIcon={<X size={12} />}
color={meta.isInstalled ? undefined : 'error'}
icon={renderIcon()}
onClose={onRemove}
title={
meta.isInstalled
? undefined
: t('tools.notInstalledWarning', { defaultValue: 'This tool is not installed' })
}
variant={isDarkMode ? 'filled' : 'outlined'}
>
{getDisplayText()}
</Tag>
);
});
PluginTag.displayName = 'PluginTag';
export default PluginTag;

View File

@@ -1,395 +1,13 @@
'use client';
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Button, Flexbox, Icon, type ItemType, Segmented } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, PlusIcon, Store, ToyBrick } from 'lucide-react';
import Image from 'next/image';
import React, { Suspense, memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import KlavisServerItem from '@/features/ChatInput/ActionBar/Tools/KlavisServerItem';
import ToolItem from '@/features/ChatInput/ActionBar/Tools/ToolItem';
import ActionDropdown from '@/features/ChatInput/ActionBar/components/ActionDropdown';
import PluginStore from '@/features/PluginStore';
import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
builtinToolSelectors,
klavisStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
import PluginTag from './PluginTag';
type TabType = 'all' | 'installed';
import { AgentTool as SharedAgentTool } from '@/features/ProfileEditor';
/**
* Klavis 服务器图标组件
* 对于 string 类型的 icon使用 Image 组件渲染
* 对于 IconType 类型的 icon使用 Icon 组件渲染,并根据主题设置填充色
* AgentTool for group profile editor
* - Uses default settings (no web browsing, no filterAvailableInWeb, uses metaList)
*/
const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return (
<Image alt={label} height={18} src={icon} style={{ flex: 'none' }} unoptimized width={18} />
);
}
// 使用主题色填充,在深色模式下自动适应
return <Icon fill={cssVar.colorText} icon={icon} size={18} />;
});
const AgentTool = memo(() => {
const { t } = useTranslation('setting');
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
// Plugin state management
const plugins = config?.plugins || [];
const toggleAgentPlugin = useAgentStore((s) => s.toggleAgentPlugin);
const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
const builtinList = useToolStore(builtinToolSelectors.metaList, isEqual);
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
// Plugin store modal state
const [modalOpen, setModalOpen] = useState(false);
const [updating, setUpdating] = useState(false);
// Tab state for dual-column layout
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const isInitializedRef = useRef(false);
// Fetch plugins
const [useFetchPluginStore, useFetchUserKlavisServers] = useToolStore((s) => [
s.useFetchPluginStore,
s.useFetchUserKlavisServers,
]);
useFetchPluginStore();
useFetchInstalledPlugins();
useCheckPluginsIsInstalled(plugins);
// 使用 SWR 加载用户的 Klavis 集成(从数据库)
useFetchUserKlavisServers(isKlavisEnabledInEnv);
// Set default tab based on installed plugins (only on first load)
useEffect(() => {
if (!isInitializedRef.current && plugins.length >= 0) {
isInitializedRef.current = true;
setActiveTab(plugins.length > 0 ? 'installed' : 'all');
}
}, [plugins.length]);
// 根据 identifier 获取已连接的服务器
const getServerByName = (identifier: string) => {
return allKlavisServers.find((server) => server.identifier === identifier);
};
// 获取所有 Klavis 服务器类型的 identifier 集合(用于过滤 builtinList
const allKlavisTypeIdentifiers = useMemo(
() => new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier)),
[],
);
// 过滤掉 builtinList 中的 klavis 工具(它们会单独显示在 Klavis 区域)
const filteredBuiltinList = useMemo(
() =>
isKlavisEnabledInEnv
? builtinList.filter((item) => !allKlavisTypeIdentifiers.has(item.identifier))
: builtinList,
[builtinList, allKlavisTypeIdentifiers, isKlavisEnabledInEnv],
);
// Klavis 服务器列表项
const klavisServerItems = useMemo(
() =>
isKlavisEnabledInEnv
? KLAVIS_SERVER_TYPES.map((type) => ({
icon: <KlavisIcon icon={type.icon} label={type.label} />,
key: type.identifier,
label: (
<KlavisServerItem
identifier={type.identifier}
label={type.label}
server={getServerByName(type.identifier)}
serverName={type.serverName}
/>
),
}))
: [],
[isKlavisEnabledInEnv, allKlavisServers],
);
// Handle plugin remove via Tag close
const handleRemovePlugin =
(pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> }) =>
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
toggleAgentPlugin(identifier, false);
};
// Build dropdown menu items (adapted from useControls)
const enablePluginCount = plugins.filter(
(id) => !builtinList.some((b) => b.identifier === id),
).length;
// 合并 builtin 工具和 Klavis 服务器
const builtinItems = useMemo(
() => [
// 原有的 builtin 工具
...filteredBuiltinList.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
key: item.identifier,
label: (
<ToolItem
checked={plugins.includes(item.identifier)}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
})),
// Klavis 服务器
...klavisServerItems,
],
[filteredBuiltinList, klavisServerItems, plugins, toggleAgentPlugin],
);
// Plugin items for dropdown
const pluginItems = useMemo(
() =>
installedPluginList.map((item) => ({
icon: item?.avatar ? (
<PluginAvatar avatar={item.avatar} size={20} />
) : (
<Icon icon={ToyBrick} size={20} />
),
key: item.identifier,
label: (
<ToolItem
checked={plugins.includes(item.identifier)}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
})),
[installedPluginList, plugins, toggleAgentPlugin],
);
// All tab items (市场 tab)
const allTabItems: ItemType[] = useMemo(
() => [
{
children: builtinItems,
key: 'builtins',
label: t('tools.builtins.groupName'),
type: 'group',
},
{
children: pluginItems,
key: 'plugins',
label: (
<Flexbox align={'center'} gap={40} horizontal justify={'space-between'}>
{t('tools.plugins.groupName')}
{enablePluginCount === 0 ? null : (
<div style={{ fontSize: 12, marginInlineEnd: 4 }}>
{t('tools.plugins.enabled', { num: enablePluginCount })}
</div>
)}
</Flexbox>
),
type: 'group',
},
{
type: 'divider',
},
{
extra: <Icon icon={ArrowRight} />,
icon: Store,
key: 'plugin-store',
label: t('tools.plugins.store'),
onClick: () => {
setModalOpen(true);
},
},
],
[builtinItems, pluginItems, enablePluginCount, t],
);
// Installed tab items - 只显示已启用的
const installedTabItems: ItemType[] = useMemo(() => {
const items: ItemType[] = [];
// 已启用的 builtin 工具
const enabledBuiltinItems = filteredBuiltinList
.filter((item) => plugins.includes(item.identifier))
.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
}));
// 已连接且已启用的 Klavis 服务器
const connectedKlavisItems = klavisServerItems.filter((item) =>
plugins.includes(item.key as string),
);
// 合并 builtin 和 Klavis
const allBuiltinItems = [...enabledBuiltinItems, ...connectedKlavisItems];
if (allBuiltinItems.length > 0) {
items.push({
children: allBuiltinItems,
key: 'installed-builtins',
label: t('tools.builtins.groupName'),
type: 'group',
});
}
// 已启用的插件
const installedPlugins = installedPluginList
.filter((item) => plugins.includes(item.identifier))
.map((item) => ({
icon: item?.avatar ? (
<PluginAvatar avatar={item.avatar} size={20} />
) : (
<Icon icon={ToyBrick} size={20} />
),
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
}));
if (installedPlugins.length > 0) {
items.push({
children: installedPlugins,
key: 'installed-plugins',
label: t('tools.plugins.groupName'),
type: 'group',
});
}
return items;
}, [filteredBuiltinList, klavisServerItems, installedPluginList, plugins, toggleAgentPlugin, t]);
// Use effective tab for display (default to all while initializing)
const effectiveTab = activeTab ?? 'all';
const currentItems = effectiveTab === 'all' ? allTabItems : installedTabItems;
// Final menu items with tab segmented control
const menuItems: ItemType[] = useMemo(
() => [
{
key: 'tabs',
label: (
<Segmented
block
onChange={(v) => setActiveTab(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'All' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={effectiveTab}
/>
),
type: 'group',
},
...currentItems,
],
[currentItems, effectiveTab, t],
);
const button = (
<Button
icon={PlusIcon}
loading={updating}
size={'small'}
style={{ color: cssVar.colorTextSecondary }}
type={'text'}
>
{t('tools.add', { defaultValue: 'Add' })}
</Button>
);
return (
<>
{/* Plugin Selector and Tags */}
<Flexbox align="center" gap={8} horizontal wrap={'wrap'}>
{/* Second Row: Selected Plugins as Tags */}
{plugins?.map((pluginId) => {
return (
<PluginTag key={pluginId} onRemove={handleRemovePlugin(pluginId)} pluginId={pluginId} />
);
})}
{/* Plugin Selector Dropdown - Using Action component pattern */}
<Suspense fallback={button}>
<ActionDropdown
maxHeight={500}
maxWidth={480}
menu={{ items: menuItems }}
minHeight={isKlavisEnabledInEnv ? 500 : undefined}
minWidth={320}
placement={'bottomLeft'}
trigger={['click']}
>
{button}
</ActionDropdown>
</Suspense>
</Flexbox>
{/* PluginStore Modal */}
<PluginStore open={modalOpen} setOpen={setModalOpen} />
</>
);
});
const AgentTool = () => {
return <SharedAgentTool />;
};
export default AgentTool;

View File

@@ -1,180 +0,0 @@
'use client';
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { AlertCircle, X } from 'lucide-react';
import Image from 'next/image';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
builtinToolSelectors,
klavisStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
/**
* Klavis 服务器图标组件
*/
const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return (
<Image alt={label} height={16} src={icon} style={{ flexShrink: 0 }} unoptimized width={16} />
);
}
return <Icon fill={cssVar.colorText} icon={icon} size={16} />;
});
const styles = createStaticStyles(({ css, cssVar }) => ({
notInstalledTag: css`
border-color: ${cssVar.colorWarningBorder};
background: ${cssVar.colorWarningBg};
`,
tag: css`
height: 28px !important;
border-radius: ${cssVar.borderRadiusSM}px !important;
`,
warningIcon: css`
flex-shrink: 0;
color: ${cssVar.colorWarning};
`,
}));
interface PluginTagProps {
onRemove: (e: React.MouseEvent) => void;
pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> };
}
const PluginTag = memo<PluginTagProps>(({ pluginId, onRemove }) => {
const isDarkMode = useIsDark();
const { t } = useTranslation('setting');
// Extract identifier
const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
// Get local plugin lists
const builtinList = useToolStore(builtinToolSelectors.metaList, isEqual);
const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
// Check if plugin is installed
const isInstalled = useToolStore(pluginSelectors.isPluginInstalled(identifier));
// Try to find in local lists first (including Klavis)
const localMeta = useMemo(() => {
// Check if it's a Klavis server type
if (isKlavisEnabledInEnv) {
const klavisType = KLAVIS_SERVER_TYPES.find((type) => type.identifier === identifier);
if (klavisType) {
// Check if this Klavis server is connected
const connectedServer = allKlavisServers.find((s) => s.identifier === identifier);
return {
icon: klavisType.icon,
isInstalled: !!connectedServer,
label: klavisType.label,
title: klavisType.label,
type: 'klavis' as const,
};
}
}
const builtinMeta = builtinList.find((p) => p.identifier === identifier);
if (builtinMeta) {
return {
avatar: builtinMeta.meta.avatar,
isInstalled: true,
title: builtinMeta.meta.title,
type: 'builtin' as const,
};
}
const installedMeta = installedPluginList.find((p) => p.identifier === identifier);
if (installedMeta) {
return {
avatar: installedMeta.avatar,
isInstalled: true,
title: installedMeta.title,
type: 'plugin' as const,
};
}
return null;
}, [identifier, builtinList, installedPluginList, isKlavisEnabledInEnv, allKlavisServers]);
// Fetch from remote if not found locally
const usePluginDetail = useDiscoverStore((s) => s.usePluginDetail);
const { data: remoteData, isLoading } = usePluginDetail({
identifier: !localMeta && !isInstalled ? identifier : undefined,
withManifest: false,
});
// Determine final metadata
const meta = localMeta || {
avatar: remoteData?.avatar,
isInstalled: false,
title: remoteData?.title || identifier,
type: 'plugin' as const,
};
const displayTitle = isLoading ? 'Loading...' : meta.title;
// Render icon based on type
const renderIcon = () => {
if (!meta.isInstalled) {
return <AlertCircle className={styles.warningIcon} size={14} />;
}
// Klavis type has icon property
if (meta.type === 'klavis' && 'icon' in meta && 'label' in meta) {
return <KlavisIcon icon={meta.icon} label={meta.label} />;
}
// Builtin type has avatar
if (meta.type === 'builtin' && 'avatar' in meta && meta.avatar) {
return <Avatar avatar={meta.avatar} shape={'square'} size={16} style={{ flexShrink: 0 }} />;
}
// Plugin type
if ('avatar' in meta) {
return <PluginAvatar avatar={meta.avatar} size={16} />;
}
return null;
};
return (
<Tag
className={styles.tag}
closable
closeIcon={<X size={12} />}
color={meta.isInstalled ? undefined : 'error'}
icon={renderIcon()}
onClose={onRemove}
title={
meta.isInstalled
? undefined
: t('tools.notInstalledWarning', { defaultValue: 'This tool is not installed' })
}
variant={isDarkMode ? 'filled' : 'outlined'}
>
{!meta.isInstalled
? `${displayTitle} (${t('tools.notInstalled', { defaultValue: 'Not Installed' })})`
: displayTitle}
</Tag>
);
});
PluginTag.displayName = 'PluginTag';
export default PluginTag;

View File

@@ -1,4 +1,4 @@
import { Flexbox, Icon , Checkbox } from '@lobehub/ui';
import { Checkbox, Flexbox, Icon } from '@lobehub/ui';
import { Loader2, SquareArrowOutUpRight, Unplug } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -336,7 +336,6 @@ const KlavisServerItem = memo<KlavisServerItemProps>(
handleToggle();
}
}}
style={{ paddingLeft: 8 }}
>
<Flexbox align={'center'} gap={8} horizontal>
{label}

View File

@@ -331,7 +331,6 @@ const LobehubSkillServerItem = memo<LobehubSkillServerItemProps>(({ provider, la
handleToggle();
}
}}
style={{ paddingLeft: 8 }}
>
<Flexbox align={'center'} gap={8} horizontal>
{label}

View File

@@ -0,0 +1,549 @@
'use client';
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Button, Flexbox, Icon, type ItemType, Segmented } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, PlusIcon, Store, ToyBrick } from 'lucide-react';
import React, { Suspense, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import KlavisServerItem from '@/features/ChatInput/ActionBar/Tools/KlavisServerItem';
import ToolItem from '@/features/ChatInput/ActionBar/Tools/ToolItem';
import ActionDropdown from '@/features/ChatInput/ActionBar/components/ActionDropdown';
import PluginStore from '@/features/PluginStore';
import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useAgentStore } from '@/store/agent';
import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
builtinToolSelectors,
klavisStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
import { type LobeToolMetaWithAvailability } from '@/store/tool/slices/builtin/selectors';
import PluginTag from './PluginTag';
const WEB_BROWSING_IDENTIFIER = 'lobe-web-browsing';
type TabType = 'all' | 'installed';
const prefixCls = 'ant';
const styles = createStaticStyles(({ css }) => ({
dropdown: css`
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgElevated};
box-shadow: ${cssVar.boxShadowSecondary};
.${prefixCls}-dropdown-menu {
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
`,
header: css`
padding: ${cssVar.paddingXS};
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: transparent;
`,
icon: css`
flex: none;
width: 18px;
height: 18px;
margin-inline-end: ${cssVar.marginXS};
`,
scroller: css`
overflow: hidden auto;
`,
}));
/**
* Klavis 服务器图标组件
* 对于 string 类型的 icon使用 Image 组件渲染
* 对于 IconType 类型的 icon使用 Icon 组件渲染,并根据主题设置填充色
*/
const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return <img alt={label} className={styles.icon} height={18} src={icon} width={18} />;
}
// 使用主题色填充,在深色模式下自动适应
return <Icon className={styles.icon} fill={cssVar.colorText} icon={icon} size={18} />;
});
export interface AgentToolProps {
/**
* Whether to filter tools by availableInWeb property
* @default false
*/
filterAvailableInWeb?: boolean;
/**
* Whether to show web browsing toggle functionality
* @default false
*/
showWebBrowsing?: boolean;
/**
* Whether to use allMetaList (includes hidden tools) or metaList
* @default false
*/
useAllMetaList?: boolean;
}
const AgentTool = memo<AgentToolProps>(
({ showWebBrowsing = false, filterAvailableInWeb = false, useAllMetaList = false }) => {
const { t } = useTranslation('setting');
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
// Plugin state management
const plugins = config?.plugins || [];
const toggleAgentPlugin = useAgentStore((s) => s.toggleAgentPlugin);
const updateAgentChatConfig = useAgentStore((s) => s.updateAgentChatConfig);
const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
// Use appropriate builtin list based on prop
const builtinList = useToolStore(
useAllMetaList ? builtinToolSelectors.allMetaList : builtinToolSelectors.metaList,
isEqual,
);
// Web browsing uses searchMode instead of plugins array
const isSearchEnabled = useAgentStore(agentChatConfigSelectors.isAgentEnableSearch);
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
// Plugin store modal state
const [modalOpen, setModalOpen] = useState(false);
const [updating, setUpdating] = useState(false);
// Tab state for dual-column layout
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const isInitializedRef = useRef(false);
// Fetch plugins
const [useFetchPluginStore, useFetchUserKlavisServers] = useToolStore((s) => [
s.useFetchPluginStore,
s.useFetchUserKlavisServers,
]);
useFetchPluginStore();
useFetchInstalledPlugins();
useCheckPluginsIsInstalled(plugins);
// 使用 SWR 加载用户的 Klavis 集成(从数据库)
useFetchUserKlavisServers(isKlavisEnabledInEnv);
// Toggle web browsing via searchMode
const toggleWebBrowsing = useCallback(async () => {
const nextMode = isSearchEnabled ? 'off' : 'auto';
await updateAgentChatConfig({ searchMode: nextMode });
}, [isSearchEnabled, updateAgentChatConfig]);
// Check if a tool is enabled (handles web browsing specially)
const isToolEnabled = useCallback(
(identifier: string) => {
if (showWebBrowsing && identifier === WEB_BROWSING_IDENTIFIER) {
return isSearchEnabled;
}
return plugins.includes(identifier);
},
[plugins, isSearchEnabled, showWebBrowsing],
);
// Toggle a tool (handles web browsing specially)
const handleToggleTool = useCallback(
async (identifier: string) => {
if (showWebBrowsing && identifier === WEB_BROWSING_IDENTIFIER) {
await toggleWebBrowsing();
} else {
await toggleAgentPlugin(identifier);
}
},
[toggleWebBrowsing, toggleAgentPlugin, showWebBrowsing],
);
// Set default tab based on installed plugins (only on first load)
useEffect(() => {
if (!isInitializedRef.current && plugins.length >= 0) {
isInitializedRef.current = true;
setActiveTab(plugins.length > 0 ? 'installed' : 'all');
}
}, [plugins.length]);
// 根据 identifier 获取已连接的服务器
const getServerByName = (identifier: string) => {
return allKlavisServers.find((server) => server.identifier === identifier);
};
// 获取所有 Klavis 服务器类型的 identifier 集合(用于过滤 builtinList
const allKlavisTypeIdentifiers = useMemo(
() => new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier)),
[],
);
// 过滤掉 builtinList 中的 klavis 工具(它们会单独显示在 Klavis 区域)
// 根据配置,可选地过滤掉 availableInWeb: false 的工具(如 LocalSystem 仅桌面版可用)
const filteredBuiltinList = useMemo(() => {
// Cast to LobeToolMetaWithAvailability for type safety when filterAvailableInWeb is used
type ListType = typeof builtinList;
let list: ListType = builtinList;
// Filter by availableInWeb if requested (only makes sense when using allMetaList)
if (filterAvailableInWeb && useAllMetaList) {
list = (list as LobeToolMetaWithAvailability[]).filter(
(item) => item.availableInWeb,
) as ListType;
}
// Filter out Klavis tools if Klavis is enabled
if (isKlavisEnabledInEnv) {
list = list.filter((item) => !allKlavisTypeIdentifiers.has(item.identifier));
}
return list;
}, [
builtinList,
allKlavisTypeIdentifiers,
isKlavisEnabledInEnv,
filterAvailableInWeb,
useAllMetaList,
]);
// Klavis 服务器列表项
const klavisServerItems = useMemo(
() =>
isKlavisEnabledInEnv
? KLAVIS_SERVER_TYPES.map((type) => ({
icon: <KlavisIcon icon={type.icon} label={type.label} />,
key: type.identifier,
label: (
<KlavisServerItem
identifier={type.identifier}
label={type.label}
server={getServerByName(type.identifier)}
serverName={type.serverName}
/>
),
}))
: [],
[isKlavisEnabledInEnv, allKlavisServers],
);
// Handle plugin remove via Tag close
const handleRemovePlugin =
(
pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> },
) =>
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
if (showWebBrowsing && identifier === WEB_BROWSING_IDENTIFIER) {
await updateAgentChatConfig({ searchMode: 'off' });
} else {
toggleAgentPlugin(identifier, false);
}
};
// Build dropdown menu items (adapted from useControls)
const enablePluginCount = plugins.filter(
(id) => !builtinList.some((b) => b.identifier === id),
).length;
// 合并 builtin 工具和 Klavis 服务器
const builtinItems = useMemo(
() => [
// 原有的 builtin 工具
...filteredBuiltinList.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
key: item.identifier,
label: (
<ToolItem
checked={isToolEnabled(item.identifier)}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(item.identifier);
setUpdating(false);
}}
/>
),
})),
// Klavis 服务器
...klavisServerItems,
],
[filteredBuiltinList, klavisServerItems, isToolEnabled, handleToggleTool],
);
// Plugin items for dropdown
const pluginItems = useMemo(
() =>
installedPluginList.map((item) => ({
icon: item?.avatar ? (
<PluginAvatar avatar={item.avatar} size={20} />
) : (
<Icon icon={ToyBrick} size={20} />
),
key: item.identifier,
label: (
<ToolItem
checked={plugins.includes(item.identifier)}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
})),
[installedPluginList, plugins, toggleAgentPlugin],
);
// All tab items (市场 tab)
const allTabItems: ItemType[] = useMemo(
() => [
{
children: builtinItems,
key: 'builtins',
label: t('tools.builtins.groupName'),
type: 'group',
},
{
children: pluginItems,
key: 'plugins',
label: (
<Flexbox align={'center'} gap={40} horizontal justify={'space-between'}>
{t('tools.plugins.groupName')}
{enablePluginCount === 0 ? null : (
<div style={{ fontSize: 12, marginInlineEnd: 4 }}>
{t('tools.plugins.enabled', { num: enablePluginCount })}
</div>
)}
</Flexbox>
),
type: 'group',
},
{
type: 'divider',
},
{
extra: <Icon icon={ArrowRight} />,
icon: Store,
key: 'plugin-store',
label: t('tools.plugins.store'),
onClick: () => {
setModalOpen(true);
},
},
],
[builtinItems, pluginItems, enablePluginCount, t],
);
// Installed tab items - 只显示已启用的
const installedTabItems: ItemType[] = useMemo(() => {
const items: ItemType[] = [];
// 已启用的 builtin 工具
const enabledBuiltinItems = filteredBuiltinList
.filter((item) => isToolEnabled(item.identifier))
.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.meta?.title}
onUpdate={async () => {
setUpdating(true);
await handleToggleTool(item.identifier);
setUpdating(false);
}}
/>
),
}));
// 已连接且已启用的 Klavis 服务器
const connectedKlavisItems = klavisServerItems.filter((item) =>
plugins.includes(item.key as string),
);
// 合并 builtin 和 Klavis
const allBuiltinItems = [...enabledBuiltinItems, ...connectedKlavisItems];
if (allBuiltinItems.length > 0) {
items.push({
children: allBuiltinItems,
key: 'installed-builtins',
label: t('tools.builtins.groupName'),
type: 'group',
});
}
// 已启用的插件
const installedPlugins = installedPluginList
.filter((item) => plugins.includes(item.identifier))
.map((item) => ({
icon: item?.avatar ? (
<PluginAvatar avatar={item.avatar} size={20} />
) : (
<Icon icon={ToyBrick} size={20} />
),
key: item.identifier,
label: (
<ToolItem
checked={true}
id={item.identifier}
label={item.title}
onUpdate={async () => {
setUpdating(true);
await toggleAgentPlugin(item.identifier);
setUpdating(false);
}}
/>
),
}));
if (installedPlugins.length > 0) {
items.push({
children: installedPlugins,
key: 'installed-plugins',
label: t('tools.plugins.groupName'),
type: 'group',
});
}
return items;
}, [
filteredBuiltinList,
klavisServerItems,
installedPluginList,
plugins,
isToolEnabled,
handleToggleTool,
toggleAgentPlugin,
t,
]);
// Use effective tab for display (default to all while initializing)
const effectiveTab = activeTab ?? 'all';
const currentItems = effectiveTab === 'all' ? allTabItems : installedTabItems;
const button = (
<Button
icon={PlusIcon}
loading={updating}
size={'small'}
style={{ color: cssVar.colorTextSecondary }}
type={'text'}
>
{t('tools.add', { defaultValue: 'Add' })}
</Button>
);
// Combine plugins and web browsing for display
const allEnabledTools = useMemo(() => {
const tools = [...plugins];
// Add web browsing if enabled (it's not in plugins array)
if (showWebBrowsing && isSearchEnabled && !tools.includes(WEB_BROWSING_IDENTIFIER)) {
tools.unshift(WEB_BROWSING_IDENTIFIER);
}
return tools;
}, [plugins, isSearchEnabled, showWebBrowsing]);
return (
<>
{/* Plugin Selector and Tags */}
<Flexbox align="center" gap={8} horizontal wrap={'wrap'}>
{/* Second Row: Selected Plugins as Tags */}
{allEnabledTools.map((pluginId) => {
return (
<PluginTag
key={pluginId}
onRemove={handleRemovePlugin(pluginId)}
pluginId={pluginId}
showDesktopOnlyLabel={filterAvailableInWeb}
useAllMetaList={useAllMetaList}
/>
);
})}
{/* Plugin Selector Dropdown - Using Action component pattern */}
<Suspense fallback={button}>
<ActionDropdown
maxHeight={500}
maxWidth={400}
menu={{
items: currentItems,
style: {
// let only the custom scroller scroll
maxHeight: 'unset',
overflowY: 'visible',
},
}}
minHeight={isKlavisEnabledInEnv ? 500 : undefined}
minWidth={400}
placement={'bottomLeft'}
popupRender={(menu) => (
<div className={styles.dropdown}>
{/* stopPropagation prevents dropdown's onClick from calling preventDefault on Segmented */}
<div className={styles.header} onClick={(e) => e.stopPropagation()}>
<Segmented
block
onChange={(v) => setActiveTab(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'All' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={effectiveTab}
/>
</div>
<div
className={styles.scroller}
style={{
maxHeight: 500,
minHeight: isKlavisEnabledInEnv ? 500 : undefined,
}}
>
{menu}
</div>
</div>
)}
trigger={['click']}
>
{button}
</ActionDropdown>
</Suspense>
</Flexbox>
{/* PluginStore Modal - rendered outside Flexbox to avoid event interference */}
{modalOpen && <PluginStore open={modalOpen} setOpen={setModalOpen} />}
</>
);
},
);
AgentTool.displayName = 'AgentTool';
export default AgentTool;

View File

@@ -0,0 +1,213 @@
'use client';
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { AlertCircle, X } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
import {
builtinToolSelectors,
klavisStoreSelectors,
pluginSelectors,
} from '@/store/tool/selectors';
import { type LobeToolMetaWithAvailability } from '@/store/tool/slices/builtin/selectors';
/**
* Klavis 服务器图标组件
*/
const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
if (typeof icon === 'string') {
return <img alt={label} height={16} src={icon} style={{ flexShrink: 0 }} width={16} />;
}
return <Icon fill={cssVar.colorText} icon={icon} size={16} />;
});
const styles = createStaticStyles(({ css, cssVar }) => ({
notInstalledTag: css`
border-color: ${cssVar.colorWarningBorder};
background: ${cssVar.colorWarningBg};
`,
tag: css`
height: 28px !important;
border-radius: ${cssVar.borderRadiusSM} !important;
`,
warningIcon: css`
flex-shrink: 0;
color: ${cssVar.colorWarning};
`,
}));
export interface PluginTagProps {
onRemove: (e: React.MouseEvent) => void;
pluginId: string | { enabled: boolean; identifier: string; settings: Record<string, any> };
/**
* Whether to show "Desktop Only" label for tools not available in web
* @default false
*/
showDesktopOnlyLabel?: boolean;
/**
* Whether to use allMetaList (includes hidden tools) or metaList
* @default false
*/
useAllMetaList?: boolean;
}
const PluginTag = memo<PluginTagProps>(
({ pluginId, onRemove, showDesktopOnlyLabel = false, useAllMetaList = false }) => {
const isDarkMode = useIsDark();
const { t } = useTranslation('setting');
// Extract identifier
const identifier = typeof pluginId === 'string' ? pluginId : pluginId?.identifier;
// Get local plugin lists - use allMetaList or metaList based on prop
const builtinList = useToolStore(
useAllMetaList ? builtinToolSelectors.allMetaList : builtinToolSelectors.metaList,
isEqual,
);
const installedPluginList = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
// Klavis 相关状态
const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
// Check if plugin is installed
const isInstalled = useToolStore(pluginSelectors.isPluginInstalled(identifier));
// Try to find in local lists first (including Klavis)
const localMeta = useMemo(() => {
// Check if it's a Klavis server type
if (isKlavisEnabledInEnv) {
const klavisType = KLAVIS_SERVER_TYPES.find((type) => type.identifier === identifier);
if (klavisType) {
// Check if this Klavis server is connected
const connectedServer = allKlavisServers.find((s) => s.identifier === identifier);
return {
availableInWeb: true,
icon: klavisType.icon,
isInstalled: !!connectedServer,
label: klavisType.label,
title: klavisType.label,
type: 'klavis' as const,
};
}
}
const builtinMeta = builtinList.find((p) => p.identifier === identifier);
if (builtinMeta) {
// availableInWeb is only present when using allMetaList
const availableInWeb =
useAllMetaList && 'availableInWeb' in builtinMeta
? (builtinMeta as LobeToolMetaWithAvailability).availableInWeb
: true;
return {
availableInWeb,
avatar: builtinMeta.meta.avatar,
isInstalled: true,
title: builtinMeta.meta.title,
type: 'builtin' as const,
};
}
const installedMeta = installedPluginList.find((p) => p.identifier === identifier);
if (installedMeta) {
return {
availableInWeb: true,
avatar: installedMeta.avatar,
isInstalled: true,
title: installedMeta.title,
type: 'plugin' as const,
};
}
return null;
}, [identifier, builtinList, installedPluginList, isKlavisEnabledInEnv, allKlavisServers]);
// Fetch from remote if not found locally
const usePluginDetail = useDiscoverStore((s) => s.usePluginDetail);
const { data: remoteData, isLoading } = usePluginDetail({
identifier: !localMeta && !isInstalled ? identifier : undefined,
withManifest: false,
});
// Determine final metadata
const meta = localMeta || {
availableInWeb: true,
avatar: remoteData?.avatar,
isInstalled: false,
title: remoteData?.title || identifier,
type: 'plugin' as const,
};
const displayTitle = isLoading ? 'Loading...' : meta.title;
const isDesktopOnly = showDesktopOnlyLabel && !meta.availableInWeb;
// Render icon based on type
const renderIcon = () => {
if (!meta.isInstalled) {
return <AlertCircle className={styles.warningIcon} size={14} />;
}
// Klavis type has icon property
if (meta.type === 'klavis' && 'icon' in meta && 'label' in meta) {
return <KlavisIcon icon={meta.icon} label={meta.label} />;
}
// Builtin type has avatar
if (meta.type === 'builtin' && 'avatar' in meta && meta.avatar) {
return <Avatar avatar={meta.avatar} shape={'square'} size={16} style={{ flexShrink: 0 }} />;
}
// Plugin type
if ('avatar' in meta) {
return <PluginAvatar avatar={meta.avatar} size={16} />;
}
return null;
};
// Build display text
const getDisplayText = () => {
let text = displayTitle;
if (isDesktopOnly) {
text += ` (${t('tools.desktopOnly', { defaultValue: 'Desktop Only' })})`;
}
if (!meta.isInstalled) {
text += ` (${t('tools.notInstalled', { defaultValue: 'Not Installed' })})`;
}
return text;
};
return (
<Tag
className={styles.tag}
closable
closeIcon={<X size={12} />}
color={meta.isInstalled ? undefined : 'error'}
icon={renderIcon()}
onClose={onRemove}
title={
meta.isInstalled
? undefined
: t('tools.notInstalledWarning', { defaultValue: 'This tool is not installed' })
}
variant={isDarkMode ? 'filled' : 'outlined'}
>
{getDisplayText()}
</Tag>
);
},
);
PluginTag.displayName = 'PluginTag';
export default PluginTag;

View File

@@ -0,0 +1,2 @@
export { default as AgentTool, type AgentToolProps } from './AgentTool';
export { default as PluginTag, type PluginTagProps } from './PluginTag';