Compare commits

...

1 Commits

Author SHA1 Message Date
rdmclin2 80bce41dea chore: optimize skill setting page and profile editor page 2026-01-23 19:07:31 +08:00
30 changed files with 881 additions and 369 deletions
+1
View File
@@ -495,6 +495,7 @@
"skillStore.networkError": "Network error, please try again",
"skillStore.search": "Search skills by name or keyword, press Enter to search…",
"skillStore.tabs.community": "Community",
"skillStore.tabs.custom": "Custom",
"skillStore.tabs.lobehub": "LobeHub",
"skillStore.title": "Skill Store",
"startConversation": "Start Conversation",
+1
View File
@@ -495,6 +495,7 @@
"skillStore.networkError": "网络错误,请重试",
"skillStore.search": "搜索技能名称或关键词,按回车键搜索…",
"skillStore.tabs.community": "社区",
"skillStore.tabs.custom": "自定义",
"skillStore.tabs.lobehub": "LobeHub",
"skillStore.title": "技能商店",
"startConversation": "开始对话",
@@ -53,6 +53,7 @@ const ProfileEditor = memo(() => {
style={{ marginBottom: 12 }}
>
<ModelSelect
listHeight={400}
onChange={updateConfig}
value={{
model: config.model,
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import McpSettingsModal from '@/features/MCP/MCPSettings/McpSettingsModal';
import PluginDetailModal from '@/features/PluginDetailModal';
import EditCustomPlugin from './EditCustomPlugin';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useServerConfigStore } from '@/store/serverConfig';
@@ -14,6 +13,8 @@ import { pluginHelpers, useToolStore } from '@/store/tool';
import { pluginSelectors, pluginStoreSelectors } from '@/store/tool/selectors';
import { type LobeToolType } from '@/types/tool/tool';
import EditCustomPlugin from './EditCustomPlugin';
interface ActionsProps {
identifier: string;
isMCP?: boolean;
@@ -46,9 +47,6 @@ const Actions = memo<ActionsProps>(({ identifier, type, isMCP }) => {
const [showModal, setModal] = useState(false);
const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false);
// 自定义插件(包括自定义 MCP)使用 EditCustomPlugin
// 社区 MCP 使用 McpSettingsModal
// 传统插件使用 PluginDetailModal
const isCommunityMCP = !isCustomPlugin && isMCP;
const showConfigureButton = isCustomPlugin || isMCP || hasSettings;
@@ -3,7 +3,7 @@
import { type KlavisServerType } from '@lobechat/const';
import { ActionIcon, Avatar, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { App, Button } from 'antd';
import { createStyles, cssVar } from 'antd-style';
import { createStaticStyles, cssVar } from 'antd-style';
import { Loader2, MoreVerticalIcon, SquareArrowOutUpRight, Unplug } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -17,10 +17,10 @@ import { userProfileSelectors } from '@/store/user/selectors';
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 15_000;
const useStyles = createStyles(({ css, token }) => ({
const styles = createStaticStyles(({ css }) => ({
connected: css`
font-size: 14px;
color: ${token.colorSuccess};
color: ${cssVar.colorSuccess};
`,
container: css`
padding-block: 12px;
@@ -28,11 +28,17 @@ const useStyles = createStyles(({ css, token }) => ({
`,
disconnected: css`
font-size: 14px;
color: ${token.colorTextTertiary};
color: ${cssVar.colorTextTertiary};
`,
disconnectedIcon: css`
opacity: 0.5;
`,
disconnectedTitle: css`
color: ${cssVar.colorTextTertiary};
`,
error: css`
font-size: 14px;
color: ${token.colorError};
color: ${cssVar.colorError};
`,
icon: css`
display: flex;
@@ -44,20 +50,20 @@ const useStyles = createStyles(({ css, token }) => ({
height: 48px;
border-radius: 12px;
background: ${token.colorFillTertiary};
background: ${cssVar.colorFillTertiary};
`,
pending: css`
font-size: 14px;
color: ${token.colorWarning};
color: ${cssVar.colorWarning};
`,
title: css`
cursor: pointer;
font-size: 15px;
font-weight: 500;
color: ${token.colorText};
color: ${cssVar.colorText};
&:hover {
color: ${token.colorPrimary};
color: ${cssVar.colorPrimary};
}
`,
}));
@@ -69,7 +75,6 @@ interface KlavisSkillItemProps {
const KlavisSkillItem = memo<KlavisSkillItemProps>(({ serverType, server }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const { modal } = App.useApp();
const [isConnecting, setIsConnecting] = useState(false);
const [isWaitingAuth, setIsWaitingAuth] = useState(false);
@@ -323,9 +328,14 @@ const KlavisSkillItem = memo<KlavisSkillItemProps>(({ serverType, server }) => {
justify="space-between"
>
<Flexbox align="center" gap={16} horizontal style={{ flex: 1, overflow: 'hidden' }}>
<div className={styles.icon}>{renderIcon()}</div>
<div className={`${styles.icon} ${!isConnected ? styles.disconnectedIcon : ''}`}>
{renderIcon()}
</div>
<Flexbox gap={4} style={{ overflow: 'hidden' }}>
<span className={styles.title} onClick={() => setDetailOpen(true)}>
<span
className={`${styles.title} ${!isConnected ? styles.disconnectedTitle : ''}`}
onClick={() => setDetailOpen(true)}
>
{serverType.label}
</span>
{!isConnected && renderStatus()}
@@ -3,7 +3,7 @@
import { type LobehubSkillProviderType } from '@lobechat/const';
import { ActionIcon, Avatar, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { App, Button } from 'antd';
import { createStyles, cssVar } from 'antd-style';
import { createStaticStyles, cssVar } from 'antd-style';
import { Loader2, MoreVerticalIcon, SquareArrowOutUpRight, Unplug } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,10 +18,10 @@ import {
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 15_000;
const useStyles = createStyles(({ css, token }) => ({
const styles = createStaticStyles(({ css }) => ({
connected: css`
font-size: 14px;
color: ${token.colorSuccess};
color: ${cssVar.colorSuccess};
`,
container: css`
padding-block: 12px;
@@ -29,17 +29,17 @@ const useStyles = createStyles(({ css, token }) => ({
`,
disconnected: css`
font-size: 14px;
color: ${token.colorTextTertiary};
color: ${cssVar.colorTextTertiary};
`,
disconnectedIcon: css`
opacity: 0.5;
`,
disconnectedTitle: css`
color: ${token.colorTextTertiary};
color: ${cssVar.colorTextTertiary};
`,
error: css`
font-size: 14px;
color: ${token.colorError};
color: ${cssVar.colorError};
`,
icon: css`
display: flex;
@@ -51,16 +51,16 @@ const useStyles = createStyles(({ css, token }) => ({
height: 48px;
border-radius: 12px;
background: ${token.colorFillTertiary};
background: ${cssVar.colorFillTertiary};
`,
title: css`
cursor: pointer;
font-size: 15px;
font-weight: 500;
color: ${token.colorText};
color: ${cssVar.colorText};
&:hover {
color: ${token.colorPrimary};
color: ${cssVar.colorPrimary};
}
`,
}));
@@ -72,7 +72,6 @@ interface LobehubSkillItemProps {
const LobehubSkillItem = memo<LobehubSkillItemProps>(({ provider, server }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const { modal } = App.useApp();
const [isConnecting, setIsConnecting] = useState(false);
const [isWaitingAuth, setIsWaitingAuth] = useState(false);
@@ -1,20 +1,22 @@
'use client';
import { Flexbox, Modal } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo, useState } from 'react';
import { createStaticStyles, cssVar } from 'antd-style';
import { Suspense, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import PluginTag from '@/components/Plugins/PluginTag';
import PluginDetailModal from '@/features/PluginDetailModal';
import Actions from './Actions';
import McpDetail from '@/features/MCP/MCPDetail';
import McpDetailLoading from '@/features/MCP/MCPDetail/Loading';
import PluginDetailModal from '@/features/PluginDetailModal';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { type LobeToolType } from '@/types/tool/tool';
const useStyles = createStyles(({ css, token }) => ({
import Actions from './Actions';
const styles = createStaticStyles(({ css }) => ({
container: css`
padding-block: 12px;
padding-inline: 0;
@@ -29,16 +31,16 @@ const useStyles = createStyles(({ css, token }) => ({
height: 48px;
border-radius: 12px;
background: ${token.colorFillTertiary};
background: ${cssVar.colorFillTertiary};
`,
title: css`
cursor: pointer;
font-size: 15px;
font-weight: 500;
color: ${token.colorText};
color: ${cssVar.colorText};
&:hover {
color: ${token.colorPrimary};
color: ${cssVar.colorPrimary};
}
`,
}));
@@ -54,7 +56,6 @@ interface McpSkillItemProps {
const McpSkillItem = memo<McpSkillItemProps>(
({ identifier, title, avatar, type, runtimeType, author }) => {
const { styles } = useStyles();
const { t } = useTranslation('plugin');
const isMCP = runtimeType === 'mcp';
const isCustomPlugin = type === 'customPlugin';
@@ -94,7 +95,9 @@ const McpSkillItem = memo<McpSkillItemProps>(
title={t('dev.title.skillDetails')}
width={800}
>
<McpDetail identifier={identifier} noSettings />
<Suspense fallback={<McpDetailLoading />}>
<McpDetail identifier={identifier} noSettings />
</Suspense>
</Modal>
)}
{isCustomPlugin && (
@@ -11,7 +11,7 @@ import {
getLobehubSkillProviderById,
} from '@lobechat/const';
import { Divider } from 'antd';
import { createStyles } from 'antd-style';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,11 +29,11 @@ import { KlavisServerStatus } from '@/store/tool/slices/klavisStore';
import { LobehubSkillStatus } from '@/store/tool/slices/lobehubSkillStore/types';
import { type LobeToolType } from '@/types/tool/tool';
import McpSkillItem from './McpSkillItem';
import KlavisSkillItem from './KlavisSkillItem';
import LobehubSkillItem from './LobehubSkillItem';
import McpSkillItem from './McpSkillItem';
const useStyles = createStyles(({ css, token }) => ({
const styles = createStaticStyles(({ css }) => ({
container: css`
display: flex;
flex-direction: column;
@@ -41,18 +41,17 @@ const useStyles = createStyles(({ css, token }) => ({
`,
description: css`
margin-block-end: 8px;
color: ${token.colorTextSecondary};
color: ${cssVar.colorTextSecondary};
`,
empty: css`
padding: 24px;
color: ${token.colorTextTertiary};
color: ${cssVar.colorTextTertiary};
text-align: center;
`,
}));
const SkillList = memo(() => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const isLobehubSkillEnabled = useServerConfigStore(serverConfigSelectors.enableLobehubSkill);
const isKlavisEnabled = useServerConfigStore(serverConfigSelectors.enableKlavis);
@@ -90,16 +89,51 @@ const SkillList = memo(() => {
// If RECOMMENDED_SKILLS is configured, use it to build the list
if (RECOMMENDED_SKILLS.length > 0) {
const addedLobehubIds = new Set<string>();
const addedKlavisIds = new Set<string>();
for (const skill of RECOMMENDED_SKILLS) {
if (skill.type === RecommendedSkillType.Lobehub && isLobehubSkillEnabled) {
const provider = getLobehubSkillProviderById(skill.id);
if (provider) {
integrationItems.push({ provider, type: 'lobehub' });
addedLobehubIds.add(skill.id);
}
} else if (skill.type === RecommendedSkillType.Klavis && isKlavisEnabled) {
const serverType = getKlavisServerByServerIdentifier(skill.id);
if (serverType) {
integrationItems.push({ serverType, type: 'klavis' });
addedKlavisIds.add(skill.id);
}
}
}
// Also add connected Lobehub skills that are not in RECOMMENDED_SKILLS
if (isLobehubSkillEnabled) {
for (const server of allLobehubSkillServers) {
if (
server.status === LobehubSkillStatus.CONNECTED &&
!addedLobehubIds.has(server.identifier)
) {
const provider = getLobehubSkillProviderById(server.identifier);
if (provider) {
integrationItems.push({ provider, type: 'lobehub' });
}
}
}
}
// Also add connected Klavis skills that are not in RECOMMENDED_SKILLS
if (isKlavisEnabled) {
for (const server of allKlavisServers) {
if (
server.status === KlavisServerStatus.CONNECTED &&
!addedKlavisIds.has(server.identifier)
) {
const serverType = getKlavisServerByServerIdentifier(server.identifier);
if (serverType) {
integrationItems.push({ serverType, type: 'klavis' });
}
}
}
}
+33 -20
View File
@@ -3,6 +3,8 @@ import { BadgeCheck, CircleUser, Package } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import MCPTag from './MCPTag';
interface PluginTagProps {
author?: string;
isMCP?: boolean;
@@ -11,28 +13,39 @@ interface PluginTagProps {
type: 'builtin' | 'customPlugin' | 'plugin';
}
const PluginTag = memo<PluginTagProps>(({ showIcon = true, author, type, showText = true }) => {
const { t } = useTranslation('plugin');
const isCustom = type === 'customPlugin';
const isOfficial = author === 'LobeHub';
const PluginTag = memo<PluginTagProps>(
({ showIcon = true, author, type, showText = true, isMCP }) => {
const { t } = useTranslation('plugin');
const isCustom = type === 'customPlugin';
const isOfficial = author === 'LobeHub';
const customTag = (
<Tag color={'warning'} icon={showIcon && <Icon icon={Package} />} size={'small'}>
{t('store.customPlugin')}
</Tag>
);
const customTag = (
<Tag color={'warning'} icon={showIcon && <Icon icon={Package} />} size={'small'}>
{t('store.customPlugin')}
</Tag>
);
if (isCustom) return customTag;
if (isMCP) {
return (
<>
<MCPTag showIcon={showIcon} showText={false} />
{isCustom && customTag}
</>
);
}
return (
<Tag
color={isOfficial ? 'success' : undefined}
icon={showIcon && <Icon icon={isOfficial ? BadgeCheck : CircleUser} />}
size={'small'}
>
{showText && (author || t('store.communityPlugin'))}
</Tag>
);
});
if (isCustom) return customTag;
return (
<Tag
color={isOfficial ? 'success' : undefined}
icon={showIcon && <Icon icon={isOfficial ? BadgeCheck : CircleUser} />}
size={'small'}
>
{showText && (author || t('store.communityPlugin'))}
</Tag>
);
},
);
export default PluginTag;
@@ -1,6 +1,6 @@
import { Flexbox, Icon, type ItemType, usePopoverContext } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { ChevronRight, Settings, Store } from 'lucide-react';
import { ChevronRight, ExternalLink, Settings, Store } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -53,7 +53,7 @@ const PopoverContent = memo<PopoverContentProps>(({ items, enableKlavis, onOpenS
<div className={toolsListStyles.itemIcon}>
<Icon icon={Store} size={20} />
</div>
<div className={toolsListStyles.itemContent}>{t('tools.plugins.store')}</div>
<div className={toolsListStyles.itemContent}>{t('skillStore.title')}</div>
<Icon className={styles.trailingIcon} icon={ChevronRight} size={16} />
</div>
<div
@@ -69,7 +69,7 @@ const PopoverContent = memo<PopoverContentProps>(({ items, enableKlavis, onOpenS
<Icon icon={Settings} size={20} />
</div>
<div className={toolsListStyles.itemContent}>{t('tools.plugins.management')}</div>
<Icon className={styles.trailingIcon} icon={ChevronRight} size={16} />
<Icon className={styles.trailingIcon} icon={ExternalLink} size={16} />
</div>
</div>
</Flexbox>
@@ -6,7 +6,7 @@ import {
RECOMMENDED_SKILLS,
RecommendedSkillType,
} from '@lobechat/const';
import { Avatar, Flexbox, Icon, Image, type ItemType } from '@lobehub/ui';
import { Avatar, Icon, Image, type ItemType } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ToyBrick } from 'lucide-react';
@@ -72,12 +72,6 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
s.togglePlugin,
]);
const builtinList = useToolStore(builtinToolSelectors.metaList, isEqual);
const enablePluginCount = useAgentStore(
(s) =>
agentByIdSelectors
.getAgentPluginsById(agentId)(s)
.filter((i) => !builtinList.some((b) => b.identifier === i)).length,
);
const plugins = useAgentStore((s) => agentByIdSelectors.getAgentPluginsById(agentId)(s));
// Klavis 相关状态
@@ -219,10 +213,21 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
);
// Skills 列表项(包含 LobeHub Skill 和 Klavis
const skillItems = useMemo(
() => [...lobehubSkillItems, ...klavisServerItems],
[lobehubSkillItems, klavisServerItems],
);
// 已连接的排在前面
const skillItems = useMemo(() => {
const allItems = [...lobehubSkillItems, ...klavisServerItems];
return allItems.sort((a, b) => {
const isConnectedA =
installedLobehubIds.has(a.key as string) || installedKlavisIds.has(a.key as string);
const isConnectedB =
installedLobehubIds.has(b.key as string) || installedKlavisIds.has(b.key as string);
if (isConnectedA && !isConnectedB) return -1;
if (!isConnectedA && isConnectedB) return 1;
return 0;
});
}, [lobehubSkillItems, klavisServerItems, installedLobehubIds, installedKlavisIds]);
// 区分社区插件和自定义插件
const communityPlugins = list.filter((item) => item.type !== 'customPlugin');
@@ -250,47 +255,55 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
),
});
// 构建 Skills 分组的 children
const skillGroupChildren: ItemType[] = [
// 1. LobeHub Skill 和 Klavis
// 构建 LobeHub 分组的 children(包含内置工具和 LobeHub Skill/Klavis
const lobehubGroupChildren: ItemType[] = [
// 1. 内置工具
...builtinItems,
// 2. LobeHub Skill 和 Klavis(作为内置技能)
...skillItems,
// 2. divider (如果有 skillItems 且有社区插件)
...(skillItems.length > 0 && communityPlugins.length > 0
? [{ key: 'divider-skill-community', type: 'divider' as const }]
: []),
// 3. 社区插件
...communityPlugins.map(mapPluginToItem),
// 4. divider (如果有自定义插件)
...(customPlugins.length > 0
? [{ key: 'divider-community-custom', type: 'divider' as const }]
: []),
// 5. 自定义插件
...customPlugins.map(mapPluginToItem),
];
// 构建 Community 分组的 children(只包含社区插件)
const communityGroupChildren: ItemType[] = communityPlugins.map(mapPluginToItem);
// 构建 Custom 分组的 children(只包含自定义插件)
const customGroupChildren: ItemType[] = customPlugins.map(mapPluginToItem);
// 市场 tab 的 items
const marketItems: ItemType[] = [
{
children: builtinItems,
key: 'builtins',
label: t('tools.builtins.groupName'),
type: 'group',
},
{
children: skillGroupChildren,
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',
},
// LobeHub 分组
...(lobehubGroupChildren.length > 0
? [
{
children: lobehubGroupChildren,
key: 'lobehub',
label: t('skillStore.tabs.lobehub'),
type: 'group' as const,
},
]
: []),
// Community 分组
...(communityGroupChildren.length > 0
? [
{
children: communityGroupChildren,
key: 'community',
label: t('skillStore.tabs.community'),
type: 'group' as const,
},
]
: []),
// Custom 分组(只有在有自定义插件时才显示)
...(customGroupChildren.length > 0
? [
{
children: customGroupChildren,
key: 'custom',
label: t('skillStore.tabs.custom'),
type: 'group' as const,
},
]
: []),
];
// 已安装 tab 的 items - 只显示已安装的插件
@@ -319,15 +332,6 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
),
}));
if (enabledBuiltinItems.length > 0) {
installedItems.push({
children: enabledBuiltinItems,
key: 'installed-builtins',
label: t('tools.builtins.groupName'),
type: 'group',
});
}
// 已连接的 Klavis 服务器
const connectedKlavisItems = klavisServerItems.filter((item) =>
checked.includes(item.key as string),
@@ -338,9 +342,30 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
checked.includes(item.key as string),
);
// 合并已启用的 LobeHub Skill 和 Klavis
// 合并已启用的 LobeHub Skill 和 Klavis(作为内置技能)
const enabledSkillItems = [...connectedLobehubSkillItems, ...connectedKlavisItems];
// 构建内置工具分组的 children(包含内置工具和 LobeHub Skill/Klavis
const allBuiltinItems: ItemType[] = [
// 1. 内置工具
...enabledBuiltinItems,
// 2. divider (如果有内置工具且有 skill items)
...(enabledBuiltinItems.length > 0 && enabledSkillItems.length > 0
? [{ key: 'installed-divider-builtin-skill', type: 'divider' as const }]
: []),
// 3. LobeHub Skill 和 Klavis
...enabledSkillItems,
];
if (allBuiltinItems.length > 0) {
installedItems.push({
children: allBuiltinItems,
key: 'installed-lobehub',
label: t('skillStore.tabs.lobehub'),
type: 'group',
});
}
// 已启用的社区插件
const enabledCommunityPlugins = communityPlugins
.filter((item) => checked.includes(item.identifier))
@@ -389,29 +414,22 @@ export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean)
),
}));
// 构建 Skills 分组的 children(带 divider
const allSkillItems: ItemType[] = [
// 1. LobeHub Skill 和 Klavis
...enabledSkillItems,
// 2. divider (如果有 skillItems 且有社区插件)
...(enabledSkillItems.length > 0 && enabledCommunityPlugins.length > 0
? [{ key: 'installed-divider-skill-community', type: 'divider' as const }]
: []),
// 3. 社区插件
...enabledCommunityPlugins,
// 4. divider (如果有自定义插件)
...(enabledCustomPlugins.length > 0
? [{ key: 'installed-divider-community-custom', type: 'divider' as const }]
: []),
// 5. 自定义插件
...enabledCustomPlugins,
];
if (allSkillItems.length > 0) {
// Community 分组(只包含社区插件
if (enabledCommunityPlugins.length > 0) {
installedItems.push({
children: allSkillItems,
key: 'installed-plugins',
label: t('tools.plugins.groupName'),
children: enabledCommunityPlugins,
key: 'installed-community',
label: t('skillStore.tabs.community'),
type: 'group',
});
}
// Custom 分组(只包含自定义插件)
if (enabledCustomPlugins.length > 0) {
installedItems.push({
children: enabledCustomPlugins,
key: 'installed-custom',
label: t('skillStore.tabs.custom'),
type: 'group',
});
}
+16 -2
View File
@@ -29,7 +29,10 @@ interface ModelOption {
value: string;
}
interface ModelSelectProps extends Pick<SelectProps, 'loading' | 'size' | 'style' | 'variant'> {
interface ModelSelectProps extends Pick<
SelectProps,
'listHeight' | 'loading' | 'size' | 'style' | 'variant'
> {
defaultValue?: { model: string; provider?: string };
onChange?: (props: { model: string; provider: string }) => void;
requiredAbilities?: (keyof EnabledProviderWithModels['children'][number]['abilities'])[];
@@ -38,7 +41,17 @@ interface ModelSelectProps extends Pick<SelectProps, 'loading' | 'size' | 'style
}
const ModelSelect = memo<ModelSelectProps>(
({ value, onChange, showAbility = true, requiredAbilities, loading, size, style, variant }) => {
({
value,
onChange,
showAbility = true,
requiredAbilities,
listHeight,
loading,
size,
style,
variant,
}) => {
const enabledList = useEnabledChatModels();
const options = useMemo<SelectProps['options']>(() => {
@@ -92,6 +105,7 @@ const ModelSelect = memo<ModelSelectProps>(
popup: { root: styles.popup },
}}
defaultValue={`${value?.provider}/${value?.model}`}
listHeight={listHeight}
loading={loading}
onChange={(value, option) => {
const model = value.split('/').slice(1).join('/');
+1
View File
@@ -36,6 +36,7 @@ const PluginDetailModal = memo<PluginDetailModalProps>(
return (
<Modal
allowFullscreen
destroyOnHidden
footer={null}
onCancel={onClose}
onOk={() => {
+167 -169
View File
@@ -6,10 +6,10 @@ import {
LOBEHUB_SKILL_PROVIDERS,
type LobehubSkillProviderType,
} from '@lobechat/const';
import { Avatar, Button, Flexbox, Icon, type ItemType, Segmented } from '@lobehub/ui';
import { Avatar, Button, Flexbox, Icon, type ItemType } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, PlusIcon, Store, ToyBrick } from 'lucide-react';
import { PlusIcon, ToyBrick } from 'lucide-react';
import React, { Suspense, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -34,44 +34,19 @@ import {
import { type LobeToolMetaWithAvailability } from '@/store/tool/slices/builtin/selectors';
import PluginTag from './PluginTag';
import PopoverContent from './PopoverContent';
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;
`,
}));
/**
@@ -157,9 +132,9 @@ const AgentTool = memo<AgentToolProps>(
const allLobehubSkillServers = useToolStore(lobehubSkillStoreSelectors.getServers, isEqual);
const isLobehubSkillEnabled = useServerConfigStore(serverConfigSelectors.enableLobehubSkill);
// Plugin store modal state
const [modalOpen, setModalOpen] = useState(false);
const [updating, setUpdating] = useState(false);
const [skillStoreOpen, setSkillStoreOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
// Tab state for dual-column layout
const [activeTab, setActiveTab] = useState<TabType | null>(null);
@@ -235,10 +210,11 @@ const AgentTool = memo<AgentToolProps>(
);
// Set default tab based on installed plugins (only on first load)
// Only show 'installed' tab by default if more than 5 plugins are enabled
useEffect(() => {
if (!isInitializedRef.current && plugins.length >= 0) {
isInitializedRef.current = true;
setActiveTab(plugins.length > 0 ? 'installed' : 'all');
setActiveTab(plugins.length > 5 ? 'installed' : 'all');
}
}, [plugins.length]);
@@ -331,17 +307,14 @@ const AgentTool = memo<AgentToolProps>(
}
};
// Build dropdown menu items (adapted from useControls)
const enablePluginCount = plugins.filter(
(id) => !builtinList.some((b) => b.identifier === id),
).length;
// 合并 builtin 工具、LobeHub Skill Providers 和 Klavis 服务器
const builtinItems = useMemo(
() => [
// 原有的 builtin 工具
...filteredBuiltinList.map((item) => ({
icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
icon: (
<Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none', marginRight: 0 }} />
),
key: item.identifier,
label: (
<ToolItem
@@ -364,70 +337,85 @@ const AgentTool = memo<AgentToolProps>(
[filteredBuiltinList, klavisServerItems, lobehubSkillItems, 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 togglePlugin(item.identifier);
setUpdating(false);
}}
/>
),
})),
[installedPluginList, plugins, togglePlugin],
// 区分社区插件和自定义插件
const communityPlugins = installedPluginList.filter((item) => item.type !== 'customPlugin');
const customPlugins = installedPluginList.filter((item) => item.type === 'customPlugin');
// 生成插件列表项的函数
const mapPluginToItem = useCallback(
(item: (typeof installedPluginList)[0]) => ({
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 togglePlugin(item.identifier);
setUpdating(false);
}}
/>
),
}),
[plugins, togglePlugin],
);
// Community 插件列表项
const communityPluginItems = useMemo(
() => communityPlugins.map(mapPluginToItem),
[communityPlugins, mapPluginToItem],
);
// Custom 插件列表项
const customPluginItems = useMemo(
() => customPlugins.map(mapPluginToItem),
[customPlugins, mapPluginToItem],
);
// 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);
},
},
// LobeHub 分组
...(builtinItems.length > 0
? [
{
children: builtinItems,
key: 'lobehub',
label: t('skillStore.tabs.lobehub'),
type: 'group' as const,
},
]
: []),
// Community 分组
...(communityPluginItems.length > 0
? [
{
children: communityPluginItems,
key: 'community',
label: t('skillStore.tabs.community'),
type: 'group' as const,
},
]
: []),
// Custom 分组
...(customPluginItems.length > 0
? [
{
children: customPluginItems,
key: 'custom',
label: t('skillStore.tabs.custom'),
type: 'group' as const,
},
]
: []),
],
[builtinItems, pluginItems, enablePluginCount, t],
[builtinItems, communityPluginItems, customPluginItems, t],
);
// Installed tab items - 只显示已启用的
@@ -464,24 +452,24 @@ const AgentTool = memo<AgentToolProps>(
plugins.includes(item.key as string),
);
// 合并 builtinLobeHub Skill Klavis
const allBuiltinItems = [
// LobeHub 分组(builtin + LobeHub Skill + Klavis
const lobehubGroupItems = [
...enabledBuiltinItems,
...connectedKlavisItems,
...connectedLobehubSkillItems,
...connectedKlavisItems,
];
if (allBuiltinItems.length > 0) {
if (lobehubGroupItems.length > 0) {
items.push({
children: allBuiltinItems,
key: 'installed-builtins',
label: t('tools.builtins.groupName'),
children: lobehubGroupItems,
key: 'installed-lobehub',
label: t('skillStore.tabs.lobehub'),
type: 'group',
});
}
// 已启用的插件
const installedPlugins = installedPluginList
// 已启用的社区插件
const enabledCommunityPlugins = communityPlugins
.filter((item) => plugins.includes(item.identifier))
.map((item) => ({
icon: item?.avatar ? (
@@ -504,11 +492,44 @@ const AgentTool = memo<AgentToolProps>(
),
}));
if (installedPlugins.length > 0) {
if (enabledCommunityPlugins.length > 0) {
items.push({
children: installedPlugins,
key: 'installed-plugins',
label: t('tools.plugins.groupName'),
children: enabledCommunityPlugins,
key: 'installed-community',
label: t('skillStore.tabs.community'),
type: 'group',
});
}
// 已启用的自定义插件
const enabledCustomPlugins = customPlugins
.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 togglePlugin(item.identifier);
setUpdating(false);
}}
/>
),
}));
if (enabledCustomPlugins.length > 0) {
items.push({
children: enabledCustomPlugins,
key: 'installed-custom',
label: t('skillStore.tabs.custom'),
type: 'group',
});
}
@@ -518,7 +539,8 @@ const AgentTool = memo<AgentToolProps>(
filteredBuiltinList,
klavisServerItems,
lobehubSkillItems,
installedPluginList,
communityPlugins,
customPlugins,
plugins,
isToolEnabled,
handleToggleTool,
@@ -553,10 +575,42 @@ const AgentTool = memo<AgentToolProps>(
}, [plugins, isSearchEnabled, showWebBrowsing]);
return (
<>
<Suspense fallback={button}>
{/* Plugin Selector and Tags */}
<Flexbox align="center" gap={8} horizontal wrap={'wrap'}>
{/* Second Row: Selected Plugins as Tags */}
{/* Plugin Selector Dropdown - Using Action component pattern */}
<ActionDropdown
maxWidth={400}
menu={{
items: currentItems,
style: {
// let only the custom scroller scroll
maxHeight: 'unset',
overflowY: 'visible',
},
}}
minWidth={400}
onOpenChange={setDropdownOpen}
open={dropdownOpen}
placement={'bottomLeft'}
popupRender={(menu) => (
<PopoverContent
activeTab={effectiveTab}
installedTabItems={installedTabItems}
menu={menu}
onClose={() => setDropdownOpen(false)}
onOpenStore={() => {
setDropdownOpen(false);
setSkillStoreOpen(true);
}}
onTabChange={setActiveTab}
/>
)}
trigger={'click'}
>
{button}
</ActionDropdown>
{/* Selected Plugins as Tags */}
{allEnabledTools.map((pluginId) => {
return (
<PluginTag
@@ -568,65 +622,9 @@ const AgentTool = memo<AgentToolProps>(
/>
);
})}
{/* 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 || isLobehubSkillEnabled ? 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 || isLobehubSkillEnabled ? 500 : undefined,
}}
>
{menu}
</div>
</div>
)}
trigger={'click'}
>
{button}
</ActionDropdown>
</Suspense>
</Flexbox>
{/* PluginStore Modal - rendered outside Flexbox to avoid event interference */}
{modalOpen && <SkillStore open={modalOpen} setOpen={setModalOpen} />}
</>
<SkillStore open={skillStoreOpen} setOpen={setSkillStoreOpen} />
</Suspense>
);
},
);
+22
View File
@@ -0,0 +1,22 @@
import { Empty as EmptyComponent } from '@lobehub/ui';
import { Plug2 } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const Empty = memo(() => {
const { t } = useTranslation('setting');
return (
<EmptyComponent
description={t('tools.installed.empty', {
defaultValue: 'No skills enabled',
})}
icon={Plug2}
style={{ paddingBlock: 40 }}
/>
);
});
Empty.displayName = 'ToolEmpty';
export default Empty;
+33 -7
View File
@@ -9,7 +9,7 @@ import {
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 { AlertCircle, Loader2, X } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -51,6 +51,21 @@ const LobehubSkillIcon = memo<Pick<LobehubSkillProviderType, 'icon' | 'label'>>(
);
const styles = createStaticStyles(({ css, cssVar }) => ({
loadingIcon: css`
flex-shrink: 0;
color: ${cssVar.colorTextSecondary};
animation: spin 1s linear infinite;
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`,
notInstalledTag: css`
border-color: ${cssVar.colorWarningBorder};
background: ${cssVar.colorWarningBg};
@@ -196,11 +211,18 @@ const PluginTag = memo<PluginTagProps>(
type: 'plugin' as const,
};
const displayTitle = isLoading ? 'Loading...' : meta.title;
// Use identifier as title when loading, otherwise use meta.title
const displayTitle = meta.title;
const isDesktopOnly = showDesktopOnlyLabel && !meta.availableInWeb;
// Render icon based on type
const renderIcon = () => {
// Show loading spinner when loading
if (isLoading) {
return <Loader2 className={styles.loadingIcon} size={14} />;
}
// Show warning icon when not installed
if (!meta.isInstalled) {
return <AlertCircle className={styles.warningIcon} size={14} />;
}
@@ -234,24 +256,28 @@ const PluginTag = memo<PluginTagProps>(
if (isDesktopOnly) {
text += ` (${t('tools.desktopOnly', { defaultValue: 'Desktop Only' })})`;
}
if (!meta.isInstalled) {
// Don't show "Not Installed" when loading
if (!meta.isInstalled && !isLoading) {
text += ` (${t('tools.notInstalled', { defaultValue: 'Not Installed' })})`;
}
return text;
};
// Only show error state when not installed and not loading
const showErrorState = !meta.isInstalled && !isLoading;
return (
<Tag
className={styles.tag}
closable
closeIcon={<X size={12} />}
color={meta.isInstalled ? undefined : 'error'}
color={showErrorState ? 'error' : undefined}
icon={renderIcon()}
onClose={onRemove}
title={
meta.isInstalled
? undefined
: t('tools.notInstalledWarning', { defaultValue: 'This tool is not installed' })
showErrorState
? t('tools.notInstalledWarning', { defaultValue: 'This tool is not installed' })
: undefined
}
variant={isDarkMode ? 'filled' : 'outlined'}
>
@@ -0,0 +1,142 @@
import { Flexbox, Icon, type ItemType, Segmented } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { ArrowRight, ExternalLink, Settings, Store } from 'lucide-react';
import { type ReactNode, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Empty from './Empty';
type TabType = 'all' | 'installed';
const prefixCls = 'ant';
const styles = createStaticStyles(({ css }) => ({
dropdown: css`
overflow: hidden;
width: 100%;
.${prefixCls}-dropdown-menu {
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
`,
footerItem: css`
cursor: pointer;
display: flex;
gap: 12px;
align-items: center;
padding-block: 8px;
padding-inline: 12px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
footerItemContent: css`
flex: 1;
min-width: 0;
`,
footerItemIcon: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`,
header: css`
padding: ${cssVar.paddingXS};
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: transparent;
`,
scroller: css`
overflow: hidden auto;
`,
trailingIcon: css`
opacity: 0.5;
`,
}));
interface PopoverContentProps {
activeTab: TabType;
installedTabItems: ItemType[];
menu: ReactNode;
onClose?: () => void;
onOpenStore: () => void;
onTabChange: (tab: TabType) => void;
}
const PopoverContent = memo<PopoverContentProps>(
({ menu, activeTab, onTabChange, installedTabItems, onOpenStore, onClose }) => {
const { t } = useTranslation('setting');
const navigate = useNavigate();
return (
<Flexbox className={styles.dropdown} style={{ maxHeight: 500 }}>
{/* stopPropagation prevents dropdown's onClick from calling preventDefault on Segmented */}
<div className={styles.header} onClick={(e) => e.stopPropagation()}>
<Segmented
block
onChange={(v) => onTabChange(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={activeTab}
/>
</div>
<div className={styles.scroller} style={{ flex: 1 }}>
{activeTab === 'installed' && installedTabItems.length === 0 ? <Empty /> : menu}
</div>
<div
style={{
borderBlockStart: `1px solid ${cssVar.colorBorderSecondary}`,
padding: 4,
}}
>
<div className={styles.footerItem} onClick={onOpenStore} role="button" tabIndex={0}>
<div className={styles.footerItemIcon}>
<Icon icon={Store} size={20} />
</div>
<div className={styles.footerItemContent}>{t('skillStore.title')}</div>
<Icon className={styles.trailingIcon} icon={ArrowRight} size={16} />
</div>
<div
className={styles.footerItem}
onClick={() => {
onClose?.();
navigate('/settings/skill');
}}
role="button"
tabIndex={0}
>
<div className={styles.footerItemIcon}>
<Icon icon={Settings} size={20} />
</div>
<div className={styles.footerItemContent}>{t('tools.plugins.management')}</div>
<Icon className={styles.trailingIcon} icon={ExternalLink} size={16} />
</div>
</div>
</Flexbox>
);
},
);
PopoverContent.displayName = 'PopoverContent';
export default PopoverContent;
+10 -8
View File
@@ -4,12 +4,13 @@ import { ActionIcon, Block, DropdownMenu, Flexbox, Icon, Modal } from '@lobehub/
import { App, Button } from 'antd';
import isEqual from 'fast-deep-equal';
import { MoreVerticalIcon, Plus, Trash2 } from 'lucide-react';
import React, { memo, useState } from 'react';
import React, { Suspense, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import MCPInstallProgress from '@/features/MCP/MCPInstallProgress';
import McpDetail from '@/features/MCP/MCPDetail';
import McpDetailLoading from '@/features/MCP/MCPDetail/Loading';
import MCPInstallProgress from '@/features/MCP/MCPInstallProgress';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
@@ -17,10 +18,9 @@ import { useToolStore } from '@/store/tool';
import { mcpStoreSelectors, pluginSelectors } from '@/store/tool/selectors';
import { type DiscoverMcpItem } from '@/types/discover';
import { useItemStyles } from '../style';
import { itemStyles } from '../style';
const Item = memo<DiscoverMcpItem>(({ name, description, icon, identifier }) => {
const { styles } = useItemStyles();
const { t } = useTranslation('plugin');
const { modal } = App.useApp();
const [detailOpen, setDetailOpen] = useState(false);
@@ -114,7 +114,7 @@ const Item = memo<DiscoverMcpItem>(({ name, description, icon, identifier }) =>
return (
<>
<Flexbox className={styles.container} gap={0}>
<Flexbox className={itemStyles.container} gap={0}>
<Block
align={'center'}
gap={12}
@@ -127,8 +127,8 @@ const Item = memo<DiscoverMcpItem>(({ name, description, icon, identifier }) =>
>
<PluginAvatar avatar={icon} size={40} />
<Flexbox flex={1} gap={4} style={{ minWidth: 0, overflow: 'hidden' }}>
<span className={styles.title}>{name}</span>
{description && <span className={styles.description}>{description}</span>}
<span className={itemStyles.title}>{name}</span>
{description && <span className={itemStyles.description}>{description}</span>}
</Flexbox>
<div onClick={(e) => e.stopPropagation()}>{renderAction()}</div>
</Block>
@@ -147,7 +147,9 @@ const Item = memo<DiscoverMcpItem>(({ name, description, icon, identifier }) =>
title={t('dev.title.skillDetails')}
width={800}
>
<McpDetail identifier={identifier} noSettings />
<Suspense fallback={<McpDetailLoading />}>
<McpDetail identifier={identifier} noSettings />
</Suspense>
</Modal>
</>
);
+10 -3
View File
@@ -6,13 +6,14 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AddSkillButton from './AddSkillButton';
import CommunityList from './CommunityList';
import CustomList from './CustomList';
import LobeHubList from './LobeHubList';
import Search from './Search';
export enum SkillStoreTab {
Community = 'community',
Custom = 'custom',
LobeHub = 'lobehub',
}
@@ -24,14 +25,17 @@ export const Content = memo(() => {
const options: SegmentedOptions = [
{ label: t('skillStore.tabs.lobehub'), value: SkillStoreTab.LobeHub },
{ label: t('skillStore.tabs.community'), value: SkillStoreTab.Community },
{ label: t('skillStore.tabs.custom'), value: SkillStoreTab.Custom },
];
const isLobeHub = activeTab === SkillStoreTab.LobeHub;
const isCommunity = activeTab === SkillStoreTab.Community;
const isCustom = activeTab === SkillStoreTab.Custom;
return (
<Flexbox gap={8} style={{ maxHeight: '75vh' }} width={'100%'}>
<Flexbox gap={8} paddingInline={16}>
<Flexbox align="center" gap={8} horizontal>
<Flexbox align={'center'} gap={8} horizontal>
<Segmented
block
onChange={(v) => setActiveTab(v as SkillStoreTab)}
@@ -47,9 +51,12 @@ export const Content = memo(() => {
<Flexbox flex={1} style={{ display: isLobeHub ? 'flex' : 'none', overflow: 'auto' }}>
<LobeHubList keywords={lobehubKeywords} />
</Flexbox>
<Flexbox flex={1} style={{ display: !isLobeHub ? 'flex' : 'none', overflow: 'auto' }}>
<Flexbox flex={1} style={{ display: isCommunity ? 'flex' : 'none', overflow: 'auto' }}>
<CommunityList />
</Flexbox>
<Flexbox flex={1} style={{ display: isCustom ? 'flex' : 'none', overflow: 'auto' }}>
<CustomList />
</Flexbox>
</Flexbox>
);
});
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { ActionIcon, Block, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { MoreVerticalIcon, PackageSearch, Trash2 } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import PluginDetailModal from '@/features/PluginDetailModal';
import DevModal from '@/features/PluginDevModal';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { itemStyles } from '../style';
const styles = createStaticStyles(({ css }) => ({
title: css`
cursor: pointer;
overflow: hidden;
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: ${cssVar.colorPrimary};
}
`,
}));
interface ItemProps {
avatar?: string;
description?: string;
identifier: string;
title?: string;
}
const Item = memo<ItemProps>(({ identifier, title, description, avatar }) => {
const { t } = useTranslation('plugin');
const { modal } = App.useApp();
const [configOpen, setConfigOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [customPlugin, uninstallPlugin, updateCustomPlugin, pluginManifest] = useToolStore((s) => [
pluginSelectors.getCustomPluginById(identifier)(s),
s.uninstallPlugin,
s.updateCustomPlugin,
pluginSelectors.getToolManifestById(identifier)(s),
]);
const [togglePlugin, isPluginEnabledInAgent] = useAgentStore((s) => [
s.togglePlugin,
agentSelectors.currentAgentPlugins(s).includes(identifier),
]);
const handleDelete = () => {
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
if (isPluginEnabledInAgent) {
await togglePlugin(identifier, false);
}
await uninstallPlugin(identifier);
},
title: t('store.actions.confirmUninstall'),
type: 'error',
});
};
return (
<>
<Flexbox className={itemStyles.container} gap={0}>
<Block
align={'center'}
gap={12}
horizontal
paddingBlock={12}
paddingInline={12}
variant={'filled'}
>
<PluginAvatar avatar={avatar} size={40} />
<Flexbox flex={1} gap={4} style={{ minWidth: 0, overflow: 'hidden' }}>
<span className={styles.title} onClick={() => setDetailOpen(true)}>
{title || identifier}
</span>
{description && <span className={itemStyles.description}>{description}</span>}
</Flexbox>
<Flexbox horizontal>
<ActionIcon
icon={PackageSearch}
onClick={() => setConfigOpen(true)}
title={t('store.actions.manifest')}
/>
<DropdownMenu
items={[
{
danger: true,
icon: <Icon icon={Trash2} />,
key: 'uninstall',
label: t('store.actions.uninstall'),
onClick: handleDelete,
},
]}
placement="bottomRight"
>
<ActionIcon icon={MoreVerticalIcon} />
</DropdownMenu>
</Flexbox>
</Block>
</Flexbox>
{customPlugin && (
<DevModal
mode="edit"
onDelete={async () => {
if (isPluginEnabledInAgent) {
await togglePlugin(identifier, false);
}
await uninstallPlugin(identifier);
}}
onOpenChange={setConfigOpen}
onSave={async (value) => {
await updateCustomPlugin(identifier, value);
}}
open={configOpen}
value={customPlugin}
/>
)}
<PluginDetailModal
id={identifier}
onClose={() => setDetailOpen(false)}
open={detailOpen}
schema={pluginManifest?.settings}
tab="info"
/>
</>
);
});
Item.displayName = 'CustomListItem';
export default Item;
@@ -0,0 +1,71 @@
'use client';
import { createStaticStyles, responsive } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo, useMemo } from 'react';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import Empty from '../Empty';
import Item from './Item';
const styles = createStaticStyles(({ css }) => ({
container: css`
min-height: 50vh;
`,
grid: css`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding-block-end: 16px;
padding-inline: 16px;
${responsive.sm} {
grid-template-columns: 1fr;
}
`,
}));
export const CustomList = memo(() => {
const customPlugins = useToolStore(pluginSelectors.installedCustomPluginMetaList, isEqual);
const searchKeywords = useToolStore((s) => s.customPluginSearchKeywords || '');
const filteredItems = useMemo(() => {
const lowerKeywords = searchKeywords.toLowerCase().trim();
if (!lowerKeywords) return customPlugins;
return customPlugins.filter((plugin) => {
const title = plugin.title?.toLowerCase() || '';
const identifier = plugin.identifier?.toLowerCase() || '';
return title.includes(lowerKeywords) || identifier.includes(lowerKeywords);
});
}, [customPlugins, searchKeywords]);
const hasSearchKeywords = Boolean(searchKeywords && searchKeywords.trim());
if (filteredItems.length === 0) {
return <Empty search={hasSearchKeywords} />;
}
return (
<div className={styles.container}>
<div className={styles.grid}>
{filteredItems.map((plugin) => (
<Item
avatar={plugin.avatar}
description={plugin.description}
identifier={plugin.identifier}
key={plugin.identifier}
title={plugin.title}
/>
))}
</div>
</div>
);
});
CustomList.displayName = 'CustomList';
export default CustomList;
+3
View File
@@ -3,6 +3,8 @@ import { Plug2 } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import AddSkillButton from './AddSkillButton';
interface SkillEmptyProps extends Omit<EmptyProps, 'icon'> {
search?: boolean;
}
@@ -13,6 +15,7 @@ const Empty = memo<SkillEmptyProps>(({ search, ...rest }) => {
return (
<Center height="100%" style={{ minHeight: '50vh' }} width="100%">
<EmptyComponent
action={!search && <AddSkillButton />}
description={search ? t('skillStore.emptySearch') : t('skillStore.empty')}
descriptionProps={{
fontSize: 14,
+4 -5
View File
@@ -8,7 +8,7 @@ import { Loader2, MoreVerticalIcon, Plus, Unplug } from 'lucide-react';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useItemStyles } from '../style';
import { itemStyles } from '../style';
import { useSkillConnect } from './useSkillConnect';
interface ItemProps {
@@ -25,7 +25,6 @@ interface ItemProps {
const Item = memo<ItemProps>(
({ description, icon, identifier, label, onOpenDetail, serverName, type }) => {
const { t } = useTranslation('setting');
const { styles } = useItemStyles();
const { modal } = App.useApp();
const { handleConnect, handleDisconnect, isConnected, isConnecting } = useSkillConnect({
@@ -91,7 +90,7 @@ const Item = memo<ItemProps>(
return (
<Block
align={'center'}
className={styles.container}
className={itemStyles.container}
gap={12}
horizontal
onClick={onOpenDetail}
@@ -102,9 +101,9 @@ const Item = memo<ItemProps>(
>
{renderIcon()}
<Flexbox flex={1} gap={4} style={{ minWidth: 0, overflow: 'hidden' }}>
<span className={styles.title}>{label}</span>
<span className={itemStyles.title}>{label}</span>
{localizedDescription && (
<span className={styles.description}>{localizedDescription}</span>
<span className={itemStyles.description}>{localizedDescription}</span>
)}
</Flexbox>
<div onClick={(e) => e.stopPropagation()}>{renderAction()}</div>
@@ -1,7 +1,7 @@
'use client';
import { KLAVIS_SERVER_TYPES, LOBEHUB_SKILL_PROVIDERS } from '@lobechat/const';
import { createStyles } from 'antd-style';
import { createStaticStyles, responsive } from 'antd-style';
import isEqual from 'fast-deep-equal';
import type { Klavis } from 'klavis';
import { memo, useMemo, useState } from 'react';
@@ -17,7 +17,7 @@ import Empty from '../Empty';
import Item from './Item';
import { useSkillConnect } from './useSkillConnect';
const useStyles = createStyles(({ css }) => ({
const styles = createStaticStyles(({ css }) => ({
grid: css`
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -26,7 +26,7 @@ const useStyles = createStyles(({ css }) => ({
padding-block-end: 16px;
padding-inline: 16px;
@media (max-width: 768px) {
${responsive.sm} {
grid-template-columns: 1fr;
}
`,
@@ -69,7 +69,6 @@ const DetailModalWithConnect = memo<DetailModalWithConnectProps>(({ detailState,
DetailModalWithConnect.displayName = 'DetailModalWithConnect';
export const LobeHubList = memo<LobeHubListProps>(({ keywords }) => {
const { styles } = useStyles();
const [detailState, setDetailState] = useState<DetailState | null>(null);
const isLobehubSkillEnabled = useServerConfigStore(serverConfigSelectors.enableLobehubSkill);
@@ -173,10 +172,7 @@ export const LobeHubList = memo<LobeHubListProps>(({ keywords }) => {
})}
</div>
{detailState && (
<DetailModalWithConnect
detailState={detailState}
onClose={() => setDetailState(null)}
/>
<DetailModalWithConnect detailState={detailState} onClose={() => setDetailState(null)} />
)}
</>
);
+18 -18
View File
@@ -1,6 +1,6 @@
'use client';
import { Flexbox, SearchBar } from '@lobehub/ui';
import { SearchBar } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -17,26 +17,26 @@ export const Search = memo<SearchProps>(({ activeTab, onLobeHubSearch }) => {
const { t } = useTranslation('setting');
const mcpKeywords = useToolStore((s) => s.mcpSearchKeywords);
const isCustomTab = activeTab === SkillStoreTab.Custom;
const keywords = activeTab === SkillStoreTab.Community ? mcpKeywords : '';
return (
<Flexbox align={'center'} gap={8} horizontal justify={'space-between'}>
<Flexbox flex={1}>
<SearchBar
allowClear
defaultValue={keywords}
onSearch={(keywords: string) => {
if (activeTab === SkillStoreTab.Community) {
useToolStore.setState({ mcpSearchKeywords: keywords, searchLoading: true });
} else {
onLobeHubSearch(keywords);
}
}}
placeholder={t('skillStore.search')}
variant={'borderless'}
/>
</Flexbox>
</Flexbox>
<SearchBar
allowClear
defaultValue={keywords}
onSearch={(keywords: string) => {
if (activeTab === SkillStoreTab.Community) {
useToolStore.setState({ mcpSearchKeywords: keywords, searchLoading: true });
} else if (isCustomTab) {
useToolStore.setState({ customPluginSearchKeywords: keywords });
} else {
onLobeHubSearch(keywords);
}
}}
placeholder={t('skillStore.search')}
variant={'borderless'}
/>
);
});
+1 -1
View File
@@ -25,7 +25,7 @@ export const SkillStore = memo<SkillStoreProps>(({ open, setOpen }) => {
body: { overflow: 'hidden', padding: 0 },
}}
title={t('skillStore.title')}
width={'min(80%, 800px)'}
width={'min(90%, 900px)'}
>
<Content />
</Modal>
+4 -4
View File
@@ -1,6 +1,6 @@
import { createStyles } from 'antd-style';
import { createStaticStyles, cssVar } from 'antd-style';
export const useItemStyles = createStyles(({ css, token }) => ({
export const itemStyles = createStaticStyles(({ css }) => ({
container: css`
position: relative;
overflow: hidden;
@@ -11,7 +11,7 @@ export const useItemStyles = createStyles(({ css, token }) => ({
overflow: hidden;
font-size: 12px;
color: ${token.colorTextSecondary};
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
`,
@@ -20,7 +20,7 @@ export const useItemStyles = createStyles(({ css, token }) => ({
font-size: 14px;
font-weight: 500;
color: ${token.colorText};
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
+1
View File
@@ -563,6 +563,7 @@ export default {
'skillStore.networkError': 'Network error, please try again',
'skillStore.search': 'Search skills by name or keyword, press Enter to search…',
'skillStore.tabs.community': 'Community',
'skillStore.tabs.custom': 'Custom',
'skillStore.tabs.lobehub': 'LobeHub',
'skillStore.title': 'Skill Store',
'startConversation': 'Start Conversation',
@@ -1,6 +1,7 @@
import { type LobeToolCustomPlugin } from '@/types/tool/plugin';
export interface CustomPluginState {
customPluginSearchKeywords?: string;
newCustomPlugin: Partial<LobeToolCustomPlugin>;
}
export const defaultCustomPlugin: Partial<LobeToolCustomPlugin> = {
@@ -13,5 +14,6 @@ export const defaultCustomPlugin: Partial<LobeToolCustomPlugin> = {
};
export const initialCustomPluginState: CustomPluginState = {
customPluginSearchKeywords: '',
newCustomPlugin: defaultCustomPlugin,
};
+1
View File
@@ -731,6 +731,7 @@ export const createMCPPluginStoreSlice: StateCreator<
draft.mcpPluginItems = [];
draft.currentPage = 1;
draft.mcpSearchKeywords = keywords;
draft.isMcpListInit = false;
}),
false,
n('resetMCPPluginList'),