mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ 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:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -331,7 +331,6 @@ const LobehubSkillServerItem = memo<LobehubSkillServerItemProps>(({ provider, la
|
||||
handleToggle();
|
||||
}
|
||||
}}
|
||||
style={{ paddingLeft: 8 }}
|
||||
>
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
{label}
|
||||
|
||||
549
src/features/ProfileEditor/AgentTool.tsx
Normal file
549
src/features/ProfileEditor/AgentTool.tsx
Normal 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;
|
||||
213
src/features/ProfileEditor/PluginTag.tsx
Normal file
213
src/features/ProfileEditor/PluginTag.tsx
Normal 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;
|
||||
2
src/features/ProfileEditor/index.ts
Normal file
2
src/features/ProfileEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AgentTool, type AgentToolProps } from './AgentTool';
|
||||
export { default as PluginTag, type PluginTagProps } from './PluginTag';
|
||||
Reference in New Issue
Block a user