mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(skill): add delete/remove actions to settings/skill items (#15708)
* ✨ feat: add delete/uninstall actions to settings/skill items - LobehubSkillItem: show compact `...` dropdown in list mode for connected items with Disconnect action (revokes OAuth) - KlavisSkillItem: show compact `...` dropdown in list mode for connected/pending servers with Remove action (true delete via removeKlavisServer) - ConnectorDetail: add Delete button for custom (mcp) connectors; calls deleteConnector + notifies parent via onDelete - SkillDetail / Page: thread onDelete callback so selecting null after deletion triggers auto-select of next item - Locales: add tools.klavis.remove / removeConfirm.title / removeConfirm.desc in en-US, zh-CN, and default source Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(skill): gate Klavis remove by canEdit and clear selected after removal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(skill): show dropdown for all Klavis/Lobehub items in list mode Previously, the ... button was gated behind `server` (Klavis) and `isConnected` (LobehubSkill), so disconnected/never-connected items showed no actions. Remove those guards so the dropdown always renders in list mode. handleRemove/handleDisconnect now skip the server call when no server instance exists and instead clear the selected item. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(skill): move delete/uninstall actions from list dropdown to detail panel - Remove heavy ... dropdown from KlavisSkillItem / LobehubSkillItem list items - Add danger Uninstall button to builtin-skill detail header (matches ConnectorDetail style) - Add slim action bar with Uninstall to agent-skill detail panel - All actions respect canEdit / canCreate permissions with confirmModal gating Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1178,6 +1178,9 @@
|
||||
"tools.klavis.disconnect": "Disconnect",
|
||||
"tools.klavis.disconnected": "Disconnected",
|
||||
"tools.klavis.error": "Error",
|
||||
"tools.klavis.remove": "Remove",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.klavis.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.klavis.groupName": "Klavis Tools",
|
||||
"tools.klavis.manage": "Manage Klavis",
|
||||
"tools.klavis.manageTitle": "Manage Klavis Integration",
|
||||
|
||||
@@ -1178,6 +1178,9 @@
|
||||
"tools.klavis.disconnect": "断开连接",
|
||||
"tools.klavis.disconnected": "已断开连接",
|
||||
"tools.klavis.error": "错误",
|
||||
"tools.klavis.remove": "移除",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。",
|
||||
"tools.klavis.removeConfirm.title": "移除 {{name}}?",
|
||||
"tools.klavis.groupName": "Klavis 工具",
|
||||
"tools.klavis.manage": "管理 Klavis",
|
||||
"tools.klavis.manageTitle": "管理 Klavis 集成",
|
||||
|
||||
@@ -2322,6 +2322,10 @@ When I am ___, I need ___
|
||||
'tools.klavis.disconnect': 'Disconnect',
|
||||
'tools.klavis.disconnected': 'Disconnected',
|
||||
'tools.klavis.error': 'Error',
|
||||
'tools.klavis.remove': 'Remove',
|
||||
'tools.klavis.removeConfirm.desc':
|
||||
'{{name}} will be permanently removed from your connected services. This action cannot be undone.',
|
||||
'tools.klavis.removeConfirm.title': 'Remove {{name}}?',
|
||||
'tools.klavis.groupName': 'Klavis Tools',
|
||||
'tools.klavis.manage': 'Manage Klavis',
|
||||
'tools.klavis.manageTitle': 'Manage Klavis Integration',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
import { Button } from 'antd';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
import { RefreshCwIcon, Trash2 } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -12,9 +13,10 @@ import ToolPermissionGroup from './ToolPermissionGroup';
|
||||
|
||||
interface ConnectorDetailProps {
|
||||
connectorId: string;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId }) => {
|
||||
const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId, onDelete }) => {
|
||||
const { t } = useTranslation('tool');
|
||||
|
||||
const connector = useToolStore(connectorSelectors.connectorById(connectorId));
|
||||
@@ -28,6 +30,7 @@ const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId }) => {
|
||||
const syncPluginTools = useToolStore((s) => s.syncPluginTools);
|
||||
const resetConnectorPermissions = useToolStore((s) => s.resetConnectorPermissions);
|
||||
const disconnectConnector = useToolStore((s) => s.disconnectConnector);
|
||||
const deleteConnector = useToolStore((s) => s.deleteConnector);
|
||||
const updateToolPermission = useToolStore((s) => s.updateToolPermission);
|
||||
|
||||
const isMcpConnector = connector?.sourceType === ConnectorSourceType.custom;
|
||||
@@ -89,9 +92,28 @@ const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId }) => {
|
||||
{syncLabel}
|
||||
</Button>
|
||||
{isMcpConnector && (
|
||||
<Button danger size="small" onClick={() => disconnectConnector(connectorId)}>
|
||||
{t('connector.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
<>
|
||||
<Button danger size="small" onClick={() => disconnectConnector(connectorId)}>
|
||||
{t('connector.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
confirmModal({
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await deleteConnector(connectorId);
|
||||
onDelete?.();
|
||||
},
|
||||
title: t('connector.deleteConfirm', 'Delete this connector?'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('connector.delete', 'Delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,13 +24,14 @@ const POLL_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface KlavisSkillItemProps {
|
||||
isSelected?: boolean;
|
||||
onDelete?: () => void;
|
||||
onSelect?: () => void;
|
||||
server?: KlavisServer;
|
||||
serverType: KlavisServerType;
|
||||
}
|
||||
|
||||
const KlavisSkillItem = memo<KlavisSkillItemProps>(
|
||||
({ serverType, server, isSelected, onSelect }) => {
|
||||
({ serverType, server, isSelected, onSelect, onDelete }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { allowed: canCreate, reason: createReason } = usePermission('create_content');
|
||||
const { allowed: canEdit, reason: editReason } = usePermission('edit_own_content');
|
||||
|
||||
@@ -22,13 +22,14 @@ const POLL_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface LobehubSkillItemProps {
|
||||
isSelected?: boolean;
|
||||
onDelete?: () => void;
|
||||
onSelect?: () => void;
|
||||
provider: LobehubSkillProviderType;
|
||||
server?: LobehubSkillServer;
|
||||
}
|
||||
|
||||
const LobehubSkillItem = memo<LobehubSkillItemProps>(
|
||||
({ provider, server, isSelected, onSelect }) => {
|
||||
({ provider, server, isSelected, onSelect, onDelete }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { allowed: canCreate, reason: createReason } = usePermission('create_content');
|
||||
const { allowed: canEdit, reason: editReason } = usePermission('edit_own_content');
|
||||
@@ -175,14 +176,17 @@ const LobehubSkillItem = memo<LobehubSkillItemProps>(
|
||||
|
||||
const handleDisconnect = () => {
|
||||
if (!canEdit) return;
|
||||
if (!server) return;
|
||||
confirmModal({
|
||||
cancelText: t('cancel', { ns: 'common' }),
|
||||
content: t('tools.lobehubSkill.disconnectConfirm.desc', { name: provider.label }),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('tools.lobehubSkill.disconnect'),
|
||||
onOk: async () => {
|
||||
await revokeConnect(server.identifier);
|
||||
if (server) {
|
||||
await revokeConnect(server.identifier);
|
||||
} else if (isSelected) {
|
||||
onDelete?.();
|
||||
}
|
||||
},
|
||||
title: t('tools.lobehubSkill.disconnectConfirm.title', { name: provider.label }),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Avatar, Markdown, Skeleton } from '@lobehub/ui';
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
import { Button } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { lazy, memo, Suspense, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ConnectorDetail } from '@/features/Connectors';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { lobehubSkillStoreSelectors } from '@/store/tool/selectors';
|
||||
import { builtinToolSelectors, lobehubSkillStoreSelectors } from '@/store/tool/selectors';
|
||||
import { connectorSelectors } from '@/store/tool/slices/connector';
|
||||
|
||||
const AgentSkillDetail = lazy(() => import('@/features/AgentSkillDetail'));
|
||||
@@ -29,8 +34,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
padding-block: 20px 16px;
|
||||
padding-inline: 24px;
|
||||
@@ -50,6 +56,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
interface SkillDetailProps {
|
||||
identifier: string;
|
||||
onDelete?: () => void;
|
||||
type: ToolDetailType;
|
||||
}
|
||||
|
||||
@@ -60,14 +67,21 @@ interface SkillDetailProps {
|
||||
* - 'builtin-skill': renders BuiltinSkill description panel (Artifacts, Task, etc.)
|
||||
* - 'builtin'/'plugin'/'mcp-connector': syncs connector entry, renders permission editor
|
||||
*/
|
||||
const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
|
||||
const SkillDetail = memo<SkillDetailProps>(({ identifier, type, onDelete }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [noManifest, setNoManifest] = useState(false);
|
||||
|
||||
const { allowed: canCreate } = usePermission('create_content');
|
||||
const { allowed: canEdit } = usePermission('edit_own_content');
|
||||
|
||||
const syncBuiltinTool = useToolStore((s) => s.syncBuiltinTool);
|
||||
const syncPluginTools = useToolStore((s) => s.syncPluginTools);
|
||||
const syncToolsFromClient = useToolStore((s) => s.syncToolsFromClient);
|
||||
const fetchConnectors = useToolStore((s) => s.fetchConnectors);
|
||||
const installBuiltinTool = useToolStore((s) => s.installBuiltinTool);
|
||||
const uninstallBuiltinTool = useToolStore((s) => s.uninstallBuiltinTool);
|
||||
const deleteAgentSkill = useToolStore((s) => s.deleteAgentSkill);
|
||||
const connector = useToolStore(connectorSelectors.connectorByIdentifier(identifier));
|
||||
|
||||
// For lobehub-connector: get the server's tool list from the store
|
||||
@@ -78,6 +92,7 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
|
||||
(s) => s.builtinSkills?.find((sk) => sk.identifier === identifier),
|
||||
isEqual,
|
||||
);
|
||||
const isBuiltinInstalled = useToolStore(builtinToolSelectors.isBuiltinToolInstalled(identifier));
|
||||
|
||||
const isConnectorType =
|
||||
type === 'builtin' ||
|
||||
@@ -127,20 +142,63 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identifier, type, isConnectorType]);
|
||||
|
||||
const handleUninstallBuiltin = () => {
|
||||
confirmModal({
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await uninstallBuiltinTool(identifier);
|
||||
},
|
||||
title: t('store.actions.confirmUninstall'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAgentSkill = () => {
|
||||
confirmModal({
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await deleteAgentSkill(identifier);
|
||||
onDelete?.();
|
||||
},
|
||||
title: t('store.actions.confirmUninstall'),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Render by type ──────────────────────────────────────────────────────────
|
||||
|
||||
if (type === 'agent-skill') {
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ padding: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={false} />
|
||||
</div>
|
||||
}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
borderBlockEnd: '1px solid var(--ant-color-border-secondary)',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
justifyContent: 'flex-end',
|
||||
padding: '8px 16px',
|
||||
}}
|
||||
>
|
||||
<AgentSkillDetail skillId={identifier} />
|
||||
</Suspense>
|
||||
<Button
|
||||
danger
|
||||
disabled={!canEdit}
|
||||
icon={<Trash2 size={14} />}
|
||||
size="small"
|
||||
onClick={handleDeleteAgentSkill}
|
||||
>
|
||||
{t('store.actions.uninstall')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ padding: 24 }}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={false} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AgentSkillDetail skillId={identifier} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,11 +207,29 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<div className={styles.header}>
|
||||
{builtinSkill?.avatar && <Avatar avatar={builtinSkill.avatar} size={40} />}
|
||||
<div>
|
||||
<div className={styles.name}>{builtinSkill?.name || identifier}</div>
|
||||
{builtinSkill?.description && (
|
||||
<div className={styles.description}>{builtinSkill.description}</div>
|
||||
<div style={{ alignItems: 'flex-start', display: 'flex', gap: 12 }}>
|
||||
{builtinSkill?.avatar && <Avatar avatar={builtinSkill.avatar} size={40} />}
|
||||
<div>
|
||||
<div className={styles.name}>{builtinSkill?.name || identifier}</div>
|
||||
{builtinSkill?.description && (
|
||||
<div className={styles.description}>{builtinSkill.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexShrink: 0, gap: 8 }}>
|
||||
{isBuiltinInstalled ? (
|
||||
<Button danger disabled={!canEdit} size="small" onClick={handleUninstallBuiltin}>
|
||||
{t('store.actions.uninstall')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canCreate}
|
||||
icon={<Plus size={14} />}
|
||||
size="small"
|
||||
onClick={() => installBuiltinTool(identifier)}
|
||||
>
|
||||
{t('store.actions.install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +260,7 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <ConnectorDetail connectorId={connector.id} />;
|
||||
return <ConnectorDetail connectorId={connector.id} onDelete={onDelete} />;
|
||||
});
|
||||
|
||||
SkillDetail.displayName = 'SkillDetail';
|
||||
|
||||
@@ -81,13 +81,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
export type SkillViewMode = 'connector' | 'skill';
|
||||
|
||||
interface SkillListProps {
|
||||
onDeleteSelected?: () => void;
|
||||
onSelect?: (identifier: string, type: ToolDetailType) => void;
|
||||
selectedIdentifier?: string;
|
||||
viewMode?: SkillViewMode;
|
||||
}
|
||||
|
||||
const SkillList = memo<SkillListProps>(
|
||||
({ onSelect, selectedIdentifier, viewMode = 'connector' }) => {
|
||||
({ onSelect, onDeleteSelected, selectedIdentifier, viewMode = 'connector' }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -507,6 +508,7 @@ const SkillList = memo<SkillListProps>(
|
||||
key={item.provider.id}
|
||||
provider={item.provider}
|
||||
server={getLobehubSkillServerByProvider(item.provider.id)}
|
||||
onDelete={onDeleteSelected}
|
||||
onSelect={
|
||||
onSelect ? () => onSelect(item.provider.id, 'lobehub-connector') : undefined
|
||||
}
|
||||
@@ -519,6 +521,7 @@ const SkillList = memo<SkillListProps>(
|
||||
key={item.serverType.identifier}
|
||||
server={getKlavisServerByIdentifier(item.serverType.identifier)}
|
||||
serverType={item.serverType}
|
||||
onDelete={onDeleteSelected}
|
||||
onSelect={
|
||||
onSelect ? () => onSelect(item.serverType.identifier, 'plugin') : undefined
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ const Page = memo(() => {
|
||||
<SkillList
|
||||
selectedIdentifier={selected?.identifier}
|
||||
viewMode={viewMode}
|
||||
onDeleteSelected={() => setSelected(null)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
@@ -233,7 +234,11 @@ const Page = memo(() => {
|
||||
{/* Right: tool detail + permissions */}
|
||||
{selected && (
|
||||
<div className={styles.detail}>
|
||||
<SkillDetail identifier={selected.identifier} type={selected.type} />
|
||||
<SkillDetail
|
||||
identifier={selected.identifier}
|
||||
type={selected.type}
|
||||
onDelete={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user