mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
feat: refine agent tools picker (skills, MCP connect/OAuth, web search)
- Skills picker: per-card visibility (public) and shared-author badges, category filtering, and an in-place Create skill flow that auto-attaches the new skill without leaving the builder - MCP: inline Connect button in the first dialog plus a dedicated OAuth dialog (continue, copyable URL, QR code) shown only when OAuth is required - Web search: auth-aware affordance, settings cog when user-provided and an info icon when system-defined - Remove orphaned com_ui_unavailable/com_ui_initializing keys and the dead Tools/MCPToolItem component
This commit is contained in:
parent
71eb21d80c
commit
6c53a83a9e
21 changed files with 656 additions and 153 deletions
119
client/src/components/MCP/McpOAuthDialog.tsx
Normal file
119
client/src/components/MCP/McpOAuthDialog.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, CopyCheck } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface McpOAuthDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
serverName: string;
|
||||
oauthUrl: string;
|
||||
canCancel: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated second dialog, opened ONLY when connecting an MCP server requires
|
||||
* OAuth. Offers three ways to complete the flow: continue in this browser, copy
|
||||
* the authorization URL to open elsewhere, or scan a QR code to open it on a
|
||||
* phone. Auto-closes once the server connects (the caller derives `open` from
|
||||
* connection state).
|
||||
*/
|
||||
export default function McpOAuthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
serverName,
|
||||
oauthUrl,
|
||||
canCancel,
|
||||
onCancel,
|
||||
}: McpOAuthDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const copyUrl = useCopyToClipboard({ text: oauthUrl });
|
||||
|
||||
if (!oauthUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent className="w-11/12 max-w-md overflow-hidden rounded-2xl">
|
||||
<OGDialogTitle className="text-base font-semibold text-text-primary">
|
||||
{localize('com_nav_mcp_connect_server', { 0: serverName })}
|
||||
</OGDialogTitle>
|
||||
<OGDialogDescription className="text-sm text-text-secondary">
|
||||
{localize('com_ui_mcp_oauth_qr_code_description')}
|
||||
</OGDialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-4 p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
className="w-full"
|
||||
onClick={() => window.open(oauthUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{localize('com_ui_continue_oauth')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 break-all text-xs text-text-secondary"
|
||||
data-testid="mcp-oauth-url"
|
||||
>
|
||||
{oauthUrl}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
onClick={() => {
|
||||
if (!isCopying) {
|
||||
copyUrl(setIsCopying);
|
||||
}
|
||||
}}
|
||||
className={cn('shrink-0', isCopying && 'cursor-default')}
|
||||
>
|
||||
{isCopying ? (
|
||||
<CopyCheck className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{isCopying ? localize('com_ui_link_copied') : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-lg">
|
||||
<QRCodeSVG
|
||||
value={oauthUrl}
|
||||
size={180}
|
||||
marginSize={2}
|
||||
title={localize('com_ui_mcp_oauth_qr_code_description')}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_ui_mcp_oauth_scan_qr')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canCancel && (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={onCancel}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
71
client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx
Normal file
71
client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import McpOAuthDialog from '../McpOAuthDialog';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useCopyToClipboard: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual('react');
|
||||
const Pass = ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('div', null, children);
|
||||
return {
|
||||
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
OGDialogContent: Pass,
|
||||
OGDialogTitle: Pass,
|
||||
OGDialogDescription: Pass,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'aria-label'?: string;
|
||||
}) =>
|
||||
React.createElement('button', { type: 'button', onClick, 'aria-label': ariaLabel }, children),
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
onOpenChange: jest.fn(),
|
||||
serverName: 'srv',
|
||||
oauthUrl: 'https://oauth.example/authorize?x=1',
|
||||
canCancel: false,
|
||||
onCancel: jest.fn(),
|
||||
};
|
||||
|
||||
describe('McpOAuthDialog', () => {
|
||||
test('renders the continue button, the copyable URL, and a QR code', () => {
|
||||
render(<McpOAuthDialog {...baseProps} />);
|
||||
expect(screen.getByText('com_ui_continue_oauth')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp-oauth-url')).toHaveTextContent(baseProps.oauthUrl);
|
||||
expect(screen.getByText('com_ui_mcp_oauth_scan_qr')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Continue opens the OAuth URL in a new tab', () => {
|
||||
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
render(<McpOAuthDialog {...baseProps} />);
|
||||
fireEvent.click(screen.getByText('com_ui_continue_oauth'));
|
||||
expect(openSpy).toHaveBeenCalledWith(baseProps.oauthUrl, '_blank', 'noopener,noreferrer');
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('shows a Cancel action only when cancellable', () => {
|
||||
const { rerender } = render(<McpOAuthDialog {...baseProps} canCancel={false} />);
|
||||
expect(screen.queryByText('com_ui_cancel')).not.toBeInTheDocument();
|
||||
rerender(<McpOAuthDialog {...baseProps} canCancel={true} />);
|
||||
expect(screen.getByText('com_ui_cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders nothing without an OAuth URL', () => {
|
||||
const { container } = render(<McpOAuthDialog {...baseProps} oauthUrl="" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import McpSection from '../sections/McpSection';
|
|||
|
||||
const mockSetValue = jest.fn();
|
||||
const mockGetValues = jest.fn((): string[] => []);
|
||||
const mockInitializeServer = jest.fn();
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
|
||||
|
|
@ -17,6 +18,7 @@ jest.mock('~/Providers', () => ({
|
|||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useCopyToClipboard: () => jest.fn(),
|
||||
useAgentCapabilities: () => ({
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
|
|
@ -25,6 +27,10 @@ jest.mock('~/hooks', () => ({
|
|||
useMCPServerManager: () => ({
|
||||
getServerStatusIconProps: () => null,
|
||||
getConfigDialogProps: () => null,
|
||||
initializeServer: mockInitializeServer,
|
||||
getOAuthUrl: () => undefined,
|
||||
isCancellable: () => false,
|
||||
cancelOAuthFlow: jest.fn(),
|
||||
}),
|
||||
useMCPToolOptions: () => ({
|
||||
isToolDeferred: () => false,
|
||||
|
|
@ -68,6 +74,24 @@ jest.mock('~/components/MCP/MCPServerStatusIcon', () => ({
|
|||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
jest.mock('~/components/MCP/McpOAuthDialog', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
open,
|
||||
oauthUrl,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
oauthUrl: string;
|
||||
onCancel: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="oauth-dialog">
|
||||
{oauthUrl}
|
||||
<button type="button" data-testid="oauth-cancel" onClick={onCancel} />
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual('react');
|
||||
|
|
@ -114,6 +138,7 @@ describe('McpSection', () => {
|
|||
beforeEach(() => {
|
||||
mockSetValue.mockClear();
|
||||
mockGetValues.mockReturnValue([]);
|
||||
mockInitializeServer.mockReset();
|
||||
});
|
||||
|
||||
test('renders one row per tool', () => {
|
||||
|
|
@ -160,6 +185,44 @@ describe('McpSection', () => {
|
|||
expect(screen.getByTestId('tool-mcp:srv:b')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test('renders an inline Connect button when the server is not connected', () => {
|
||||
render(<McpSection item={item} />);
|
||||
expect(screen.getByText('com_nav_mcp_connect_server')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking Connect initializes the server without auto-opening OAuth', () => {
|
||||
mockInitializeServer.mockResolvedValue({ success: true });
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByText('com_nav_mcp_connect_server'));
|
||||
expect(mockInitializeServer).toHaveBeenCalledWith('srv', false);
|
||||
});
|
||||
|
||||
test('opens the OAuth dialog only when initialize reports oauthRequired', async () => {
|
||||
mockInitializeServer.mockResolvedValue({
|
||||
success: true,
|
||||
oauthRequired: true,
|
||||
oauthUrl: 'https://oauth.example/authorize?x=1',
|
||||
});
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByText('com_nav_mcp_connect_server'));
|
||||
expect(await screen.findByTestId('oauth-dialog')).toHaveTextContent(
|
||||
'https://oauth.example/authorize?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelling the OAuth flow closes the dialog', async () => {
|
||||
mockInitializeServer.mockResolvedValue({
|
||||
success: true,
|
||||
oauthRequired: true,
|
||||
oauthUrl: 'https://oauth.example/authorize?x=1',
|
||||
});
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByText('com_nav_mcp_connect_server'));
|
||||
await screen.findByTestId('oauth-dialog');
|
||||
fireEvent.click(screen.getByTestId('oauth-cancel'));
|
||||
expect(screen.queryByTestId('oauth-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows empty hint when the server exposes no tools', () => {
|
||||
const empty: McpItem = {
|
||||
...item,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { Clock, Code2 } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
|
||||
import { Button, Spinner, Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { McpItem } from '../../items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
} from '~/hooks';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import McpOAuthDialog from '~/components/MCP/McpOAuthDialog';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import MCPToolItem from '../../../MCPToolItem';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -56,7 +59,17 @@ interface Props {
|
|||
export default function McpSection({ item }: Props) {
|
||||
const localize = useLocalize();
|
||||
const { control, getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
||||
const {
|
||||
getServerStatusIconProps,
|
||||
getConfigDialogProps,
|
||||
initializeServer,
|
||||
getOAuthUrl,
|
||||
isCancellable,
|
||||
cancelOAuthFlow,
|
||||
} = useMCPServerManager();
|
||||
const [oauthOpen, setOauthOpen] = useState(false);
|
||||
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
|
||||
const [prevConnected, setPrevConnected] = useState(false);
|
||||
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { deferredToolsEnabled, programmaticToolsEnabled } = useAgentCapabilities(
|
||||
|
|
@ -124,6 +137,35 @@ export default function McpSection({ item }: Props) {
|
|||
* both cases instead of a misleading "no tools" message. */
|
||||
const toolsLoading =
|
||||
!hasTools && (mcpToolsLoading || isInitializing || connectionState === 'connecting');
|
||||
const isConnected = connectionState === 'connected';
|
||||
const isBusy = isInitializing || connectionState === 'connecting';
|
||||
|
||||
/** Close + clear the OAuth dialog once the server connects, and don't let it
|
||||
* reopen on its own if the connection later drops. No useEffect — adjust state
|
||||
* during render by comparing against the previous connection result. */
|
||||
if (prevConnected !== isConnected) {
|
||||
setPrevConnected(isConnected);
|
||||
if (isConnected) {
|
||||
setOauthOpen(false);
|
||||
setOauthUrl(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect inline from this first dialog. Servers with custom user variables are
|
||||
* routed to the config dialog (which sets the vars and initializes); others
|
||||
* connect directly. `autoOpenOAuth=false` surfaces the URL in our OAuth dialog
|
||||
* (continue / copy / QR) instead of the browser silently opening a tab. */
|
||||
const handleConnect = async (e: MouseEvent) => {
|
||||
if (statusIconProps != null && statusIconProps.hasCustomUserVars) {
|
||||
statusIconProps.onConfigClick(e);
|
||||
return;
|
||||
}
|
||||
const res = await initializeServer(serverName, false);
|
||||
if (res?.oauthRequired && res.oauthUrl) {
|
||||
setOauthUrl(res.oauthUrl);
|
||||
setOauthOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
|
@ -141,9 +183,22 @@ export default function McpSection({ item }: Props) {
|
|||
{localize(statusDisplay.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
{statusIconProps && <MCPServerStatusIcon {...statusIconProps} />}
|
||||
{isConnected && statusIconProps && <MCPServerStatusIcon {...statusIconProps} />}
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
className="w-full gap-2"
|
||||
disabled={isBusy}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{isBusy && <Spinner className="size-4" />}
|
||||
{localize('com_nav_mcp_connect_server', { 0: serverName })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
|
|
@ -272,6 +327,18 @@ export default function McpSection({ item }: Props) {
|
|||
</div>
|
||||
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
<McpOAuthDialog
|
||||
open={oauthOpen && !isConnected}
|
||||
onOpenChange={setOauthOpen}
|
||||
serverName={serverName}
|
||||
oauthUrl={oauthUrl ?? getOAuthUrl(serverName) ?? ''}
|
||||
canCancel={isCancellable(serverName)}
|
||||
onCancel={() => {
|
||||
cancelOAuthFlow(serverName);
|
||||
setOauthOpen(false);
|
||||
setOauthUrl(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import {
|
||||
Radio,
|
||||
Input,
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { AgentItem } from './items/types';
|
||||
import type { TSkill } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { CategoryOption } from './CategoryFilter';
|
||||
import type { AgentItem } from './items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
useListSkillsQuery,
|
||||
|
|
@ -19,10 +22,13 @@ import {
|
|||
useGetSkillFavoritesQuery,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useHasAccess, useAuthContext } from '~/hooks';
|
||||
import { CreateSkillDialog } from '~/components/Skills/dialogs';
|
||||
import MarketplaceCatalog from './MarketplaceCatalog';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import { CategoryIcon } from '~/components/Prompts';
|
||||
import { buildSkillItems } from './items/catalog';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import { applyFilter } from './items/filtering';
|
||||
import CategoryFilter from './CategoryFilter';
|
||||
import { itemKey } from './items/selectors';
|
||||
|
||||
interface SkillsDialogProps {
|
||||
|
|
@ -48,6 +54,10 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const { data: skillsData, isLoading: isLoadingSkills } = useListSkillsQuery(
|
||||
{ limit: 100 },
|
||||
{ enabled: hasSkillsAccess },
|
||||
|
|
@ -76,6 +86,8 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
|
||||
const [view, setView] = useState<SkillView>('marketplace');
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<string | 'all'>('all');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailItem, setDetailItem] = useState<AgentItem | null>(null);
|
||||
|
||||
const catalog = useMemo(
|
||||
|
|
@ -83,9 +95,39 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
[skillsData, user?.id],
|
||||
);
|
||||
|
||||
const categoryOptions = useMemo<CategoryOption[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const options: CategoryOption[] = [];
|
||||
for (const item of catalog) {
|
||||
if (item.kind !== 'skill') {
|
||||
continue;
|
||||
}
|
||||
const value = item.skill.category;
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
options.push({
|
||||
value,
|
||||
label: value,
|
||||
icon: <CategoryIcon category={value} className="size-4" />,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [catalog]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFilter(catalog, { search, kind: 'skill', category: 'all', view }, { favoritedIds }),
|
||||
[catalog, search, view, favoritedIds],
|
||||
() => applyFilter(catalog, { search, kind: 'skill', category, view }, { favoritedIds }),
|
||||
[catalog, search, category, view, favoritedIds],
|
||||
);
|
||||
|
||||
const handleSkillCreated = useCallback(
|
||||
(skill: TSkill) => {
|
||||
const current = (getValues('skills') ?? []) as string[];
|
||||
setValue('skills', Array.from(new Set([...current, skill._id])), { shouldDirty: true });
|
||||
setView('mine');
|
||||
},
|
||||
[getValues, setValue],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
|
|
@ -110,7 +152,9 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
);
|
||||
|
||||
const emptyKey: TranslationKeys | undefined =
|
||||
!search.trim() && view === 'marketplace' ? 'com_ui_no_skills_found' : undefined;
|
||||
!search.trim() && category === 'all' && view === 'marketplace'
|
||||
? 'com_ui_no_skills_found'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -120,9 +164,24 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
</OGDialogDescription>
|
||||
<div className="flex h-[80vh] max-h-[760px] flex-col">
|
||||
<div className="flex flex-col gap-3 border-b border-border-light px-6 pb-4 pt-5">
|
||||
<OGDialogTitle className="pr-10 text-base font-semibold text-text-primary">
|
||||
{localize('com_ui_skills')}
|
||||
</OGDialogTitle>
|
||||
<div className="flex items-center justify-between gap-2 pr-10">
|
||||
<OGDialogTitle className="text-base font-semibold text-text-primary">
|
||||
{localize('com_ui_skills')}
|
||||
</OGDialogTitle>
|
||||
{hasCreateAccess && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label={localize('com_ui_create_skill')}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
<span className="truncate">{localize('com_ui_create_skill')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
|
|
@ -139,13 +198,17 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
className="h-[42px] bg-transparent pl-9"
|
||||
/>
|
||||
</div>
|
||||
<CategoryFilter options={categoryOptions} value={category} onChange={setCategory} />
|
||||
<label id="skills-view-label" className="sr-only">
|
||||
{localize('com_ui_skills_filter')}
|
||||
</label>
|
||||
<Radio
|
||||
options={viewOptions}
|
||||
value={view}
|
||||
onChange={(value) => setView(value as SkillView)}
|
||||
onChange={(value) => {
|
||||
setView(value as SkillView);
|
||||
setCategory('all');
|
||||
}}
|
||||
className="flex-shrink-0 p-1"
|
||||
aria-labelledby="skills-view-label"
|
||||
/>
|
||||
|
|
@ -167,6 +230,11 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
|
|||
</div>
|
||||
</div>
|
||||
<ItemDialog item={detailItem} agentId={agentId} onClose={() => setDetailItem(null)} />
|
||||
<CreateSkillDialog
|
||||
isOpen={createOpen}
|
||||
setIsOpen={setCreateOpen}
|
||||
onCreated={handleSkillCreated}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { memo, useState } from 'react';
|
||||
import { BadgeCheck, Check, Settings } from 'lucide-react';
|
||||
import { BadgeCheck, Check, Globe, Info, Settings, User } from 'lucide-react';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentItem } from './items/types';
|
||||
import { hasConfigurableSettings } from './items/configurable';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { getIconForItem } from './items/icons';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ToolCardProps {
|
||||
|
|
@ -79,14 +79,24 @@ function ItemIconView({ item, size }: ItemIconProps) {
|
|||
function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps) {
|
||||
const localize = useLocalize();
|
||||
const { name, description } = useDisplayStrings(item);
|
||||
const { user } = useAuthContext();
|
||||
const isNative = item.kind === 'builtin';
|
||||
const kindLabel = localize(KIND_LABEL_KEYS[item.kind]);
|
||||
const canConfigure = hasConfigurableSettings(item) && onConfigure !== undefined;
|
||||
const skill = item.kind === 'skill' ? item.skill : undefined;
|
||||
const isPublicSkill = skill?.isPublic === true;
|
||||
const isSharedSkill = skill != null && skill.author !== user?.id && Boolean(skill.authorName);
|
||||
const showInfoOnly =
|
||||
item.kind === 'builtin' &&
|
||||
item.id === 'web_search' &&
|
||||
!canConfigure &&
|
||||
onConfigure !== undefined;
|
||||
const DetailIcon = canConfigure ? Settings : Info;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-32 w-full flex-col rounded-2xl border',
|
||||
'group relative flex h-32 w-full flex-col overflow-hidden rounded-2xl border',
|
||||
selected
|
||||
? 'border-emerald-500/60 bg-emerald-500/[0.06] shadow-sm'
|
||||
: 'border-border-light bg-transparent hover:border-border-medium hover:bg-surface-tertiary hover:shadow-sm',
|
||||
|
|
@ -137,27 +147,50 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
|
|||
{isNative ? localize('com_ui_tools_native_short') : kindLabel}
|
||||
</p>
|
||||
)}
|
||||
{item.kind === 'action' && item.endpointCount > 0 && (
|
||||
<div className="mt-auto flex w-full items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2 py-0.5 text-[10px] text-text-tertiary">
|
||||
{localize(
|
||||
item.endpointCount === 1
|
||||
? 'com_ui_tools_endpoint_count_one'
|
||||
: 'com_ui_tools_endpoint_count',
|
||||
{ count: item.endpointCount },
|
||||
)}
|
||||
</span>
|
||||
{(item.kind === 'action' && item.endpointCount > 0) || isPublicSkill || isSharedSkill ? (
|
||||
<div className="mt-auto flex w-full flex-wrap items-center gap-1.5">
|
||||
{item.kind === 'action' && item.endpointCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2 py-0.5 text-[10px] text-text-tertiary">
|
||||
{localize(
|
||||
item.endpointCount === 1
|
||||
? 'com_ui_tools_endpoint_count_one'
|
||||
: 'com_ui_tools_endpoint_count',
|
||||
{ count: item.endpointCount },
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{isSharedSkill && skill && (
|
||||
<span
|
||||
className="inline-flex max-w-[60%] items-center gap-1 rounded-full bg-surface-tertiary px-2 py-0.5 text-[10px] text-text-tertiary"
|
||||
title={localize('com_ui_tools_shared_by', { name: skill.authorName })}
|
||||
aria-label={localize('com_ui_tools_shared_by', { name: skill.authorName })}
|
||||
>
|
||||
<User className="size-2.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{skill.authorName}</span>
|
||||
</span>
|
||||
)}
|
||||
{isPublicSkill && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-1.5 py-0.5 text-[10px] text-text-tertiary"
|
||||
title={localize('com_ui_sr_public_skill')}
|
||||
aria-label={localize('com_ui_sr_public_skill')}
|
||||
>
|
||||
<Globe className="size-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
{canConfigure && (
|
||||
{(canConfigure || showInfoOnly) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigure?.(item);
|
||||
}}
|
||||
aria-label={localize('com_ui_tools_configure')}
|
||||
aria-label={
|
||||
canConfigure ? localize('com_ui_tools_configure') : localize('com_ui_tools_info')
|
||||
}
|
||||
className={cn(
|
||||
'absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-lg text-text-secondary',
|
||||
'opacity-0 transition duration-150 hover:bg-surface-hover hover:text-text-primary',
|
||||
|
|
@ -165,7 +198,7 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
|
|||
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
)}
|
||||
>
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
<DetailIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ import {
|
|||
import type { AgentItem, AgentItemKind, ItemFilter } from './items/types';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useBuiltinAuthMap, useShowMemory, useUninstallToolCredentials } from './hooks';
|
||||
import {
|
||||
useBuiltinAuthMap,
|
||||
useShowMemory,
|
||||
useWebSearchUserProvided,
|
||||
useUninstallToolCredentials,
|
||||
} from './hooks';
|
||||
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
|
||||
import { deriveSelectedItems, itemKey } from './items/selectors';
|
||||
import { computeToggleAction } from './items/mutations';
|
||||
|
|
@ -52,6 +57,7 @@ export default function ToolsMarketplaceDialog({
|
|||
});
|
||||
const builtinAuthMap = useBuiltinAuthMap();
|
||||
const showMemory = useShowMemory();
|
||||
const webSearchUserProvided = useWebSearchUserProvided();
|
||||
const uninstallToolCredentials = useUninstallToolCredentials();
|
||||
|
||||
const { data: favorites } = useGetFavoritesQuery();
|
||||
|
|
@ -115,6 +121,7 @@ export default function ToolsMarketplaceDialog({
|
|||
actions: agentActions,
|
||||
permissions: { mcp: hasMcpAccess, skills: false },
|
||||
showMemory,
|
||||
webSearchUserProvided,
|
||||
builtinAuthMap,
|
||||
}),
|
||||
[
|
||||
|
|
@ -124,6 +131,7 @@ export default function ToolsMarketplaceDialog({
|
|||
agentActions,
|
||||
hasMcpAccess,
|
||||
showMemory,
|
||||
webSearchUserProvided,
|
||||
builtinAuthMap,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-
|
|||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { AgentItem } from './items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useBuiltinAuthMap, useShowMemory, useUninstallToolCredentials } from './hooks';
|
||||
import {
|
||||
useBuiltinAuthMap,
|
||||
useShowMemory,
|
||||
useWebSearchUserProvided,
|
||||
useUninstallToolCredentials,
|
||||
} from './hooks';
|
||||
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
|
||||
import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP';
|
||||
import ToolsMarketplaceDialog from './ToolsMarketplaceDialog';
|
||||
|
|
@ -66,6 +71,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
|
||||
|
||||
const showMemory = useShowMemory();
|
||||
const webSearchUserProvided = useWebSearchUserProvided();
|
||||
const builtinAuthMap = useBuiltinAuthMap();
|
||||
const uninstallToolCredentials = useUninstallToolCredentials();
|
||||
|
||||
|
|
@ -97,6 +103,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
actions: agentActions,
|
||||
permissions: { mcp: hasMcpAccess, skills: showSkills },
|
||||
showMemory,
|
||||
webSearchUserProvided,
|
||||
builtinAuthMap,
|
||||
}),
|
||||
[
|
||||
|
|
@ -108,6 +115,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
hasMcpAccess,
|
||||
showSkills,
|
||||
showMemory,
|
||||
webSearchUserProvided,
|
||||
builtinAuthMap,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import MarketplaceCatalog from '../MarketplaceCatalog';
|
||||
import type { AgentItem } from '../items/types';
|
||||
import MarketplaceCatalog from '../MarketplaceCatalog';
|
||||
import { itemKey } from '../items/selectors';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useAuthContext: () => ({ user: { id: 'u1' } }),
|
||||
}));
|
||||
|
||||
const items: AgentItem[] = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import ToolCard from '../ToolCard';
|
||||
import type { AgentItem } from '../items/types';
|
||||
import ToolCard from '../ToolCard';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
|
|
@ -11,6 +11,7 @@ jest.mock('~/hooks', () => ({
|
|||
const parts = Object.entries(options).map(([name, value]) => `${name}=${String(value)}`);
|
||||
return `${key}[${parts.join(',')}]`;
|
||||
},
|
||||
useAuthContext: () => ({ user: { id: 'u1' } }),
|
||||
}));
|
||||
|
||||
const skill: AgentItem = {
|
||||
|
|
@ -69,4 +70,37 @@ describe('ToolCard', () => {
|
|||
render(<ToolCard item={mcp} selected={false} onToggle={jest.fn()} />);
|
||||
expect(screen.queryByText('com_ui_tools_count[count=14]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders public and shared-author badges for another user public skill', () => {
|
||||
const shared: AgentItem = {
|
||||
kind: 'skill',
|
||||
id: 's2',
|
||||
name: 'Shared',
|
||||
description: '',
|
||||
iconKey: 'skill',
|
||||
skill: {
|
||||
_id: 's2',
|
||||
name: 'Shared',
|
||||
author: 'u2',
|
||||
authorName: 'Alice',
|
||||
isPublic: true,
|
||||
} as never,
|
||||
};
|
||||
render(<ToolCard item={shared} selected={false} onToggle={jest.fn()} />);
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_sr_public_skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('omits the author badge for the current user own skill', () => {
|
||||
const own: AgentItem = {
|
||||
kind: 'skill',
|
||||
id: 's3',
|
||||
name: 'Mine',
|
||||
description: '',
|
||||
iconKey: 'skill',
|
||||
skill: { _id: 's3', name: 'Mine', author: 'u1', authorName: 'Me', isPublic: false } as never,
|
||||
};
|
||||
render(<ToolCard item={own} selected={false} onToggle={jest.fn()} />);
|
||||
expect(screen.queryByText('Me')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ jest.mock('react-router-dom', () => ({
|
|||
jest.mock('../hooks', () => ({
|
||||
useBuiltinAuthMap: () => new Map(),
|
||||
useShowMemory: () => false,
|
||||
useWebSearchUserProvided: () => false,
|
||||
useUninstallToolCredentials: () => jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ jest.mock('~/hooks/MCP', () => ({
|
|||
jest.mock('../hooks', () => ({
|
||||
useBuiltinAuthMap: () => new Map(),
|
||||
useShowMemory: () => false,
|
||||
useWebSearchUserProvided: () => false,
|
||||
useUninstallToolCredentials: () => jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,21 @@ export function useBuiltinAuthMap(): Map<string, boolean> {
|
|||
}, [data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `web_search` uses USER_PROVIDED auth (a user-managed key). When false
|
||||
* the deployment uses SYSTEM_DEFINED keys, so there is nothing for the user to
|
||||
* configure. Shares the `useBuiltinAuthMap` React Query key, so it adds no
|
||||
* request. Threaded into `buildCatalog` so the card/row affordance is decided
|
||||
* synchronously (cog vs info) without a per-row hook.
|
||||
*/
|
||||
export function useWebSearchUserProvided(): boolean {
|
||||
const { data } = useVerifyAgentToolAuth({ toolId: Tools.web_search }, { retry: 1 });
|
||||
return useMemo(
|
||||
() => data?.authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED) ?? false,
|
||||
[data],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callback that revokes a tool's stored user credentials when it is
|
||||
* removed from an agent, mirroring the legacy `AgentTool` removal. Without it,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,21 @@ describe('buildCatalog', () => {
|
|||
expect(buildCatalog({ ...emptyInputs, showMemory: true }).find(memoryId)).toBeDefined();
|
||||
});
|
||||
|
||||
test('flags web_search userProvidedAuth from the webSearchUserProvided input', () => {
|
||||
const findWebSearch = (inputs: BuildCatalogInputs) =>
|
||||
buildCatalog(inputs).find(
|
||||
(i) => i.kind === 'builtin' && i.id === AgentCapabilities.web_search,
|
||||
);
|
||||
const base = {
|
||||
...emptyInputs,
|
||||
agentsConfig: { capabilities: [AgentCapabilities.web_search] },
|
||||
};
|
||||
const userProvided = findWebSearch({ ...base, webSearchUserProvided: true });
|
||||
const systemDefined = findWebSearch({ ...base, webSearchUserProvided: false });
|
||||
expect(userProvided?.kind === 'builtin' && userProvided.userProvidedAuth).toBe(true);
|
||||
expect(systemDefined?.kind === 'builtin' && systemDefined.userProvidedAuth).toBe(false);
|
||||
});
|
||||
|
||||
test('hides MCP items when the user lacks MCP permission', () => {
|
||||
const map = new Map();
|
||||
map.set('srv', { serverName: 'srv', isConfigured: true, tools: [] });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import type { AgentItem } from '../types';
|
||||
import { makePlugin, makeMcpServer, makeSkill, makeAction } from 'test/itemFactories';
|
||||
import { hasConfigurableSettings } from '../configurable';
|
||||
|
||||
const builtin = (id: string, extra: Record<string, unknown> = {}): AgentItem =>
|
||||
({ kind: 'builtin', id, name: '', description: '', iconKey: id, ...extra }) as AgentItem;
|
||||
|
||||
describe('hasConfigurableSettings', () => {
|
||||
test('artifacts, file_search, and context builtins are configurable', () => {
|
||||
expect(hasConfigurableSettings(builtin('artifacts'))).toBe(true);
|
||||
expect(hasConfigurableSettings(builtin('file_search'))).toBe(true);
|
||||
expect(hasConfigurableSettings(builtin('context'))).toBe(true);
|
||||
});
|
||||
|
||||
test('execute_code and memory builtins are not configurable', () => {
|
||||
expect(hasConfigurableSettings(builtin('execute_code'))).toBe(false);
|
||||
expect(hasConfigurableSettings(builtin('memory'))).toBe(false);
|
||||
});
|
||||
|
||||
test('web_search is configurable only when auth is user-provided', () => {
|
||||
expect(hasConfigurableSettings(builtin('web_search'))).toBe(false);
|
||||
expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: false }))).toBe(false);
|
||||
expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: true }))).toBe(true);
|
||||
});
|
||||
|
||||
test('mcp and action are always configurable; skills never are', () => {
|
||||
const mcp: AgentItem = {
|
||||
kind: 'mcp',
|
||||
id: 'srv',
|
||||
name: 'srv',
|
||||
description: '',
|
||||
iconKey: 'mcp',
|
||||
server: makeMcpServer({ serverName: 'srv' }),
|
||||
toolCount: 0,
|
||||
};
|
||||
const action: AgentItem = {
|
||||
kind: 'action',
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
description: '',
|
||||
iconKey: 'action',
|
||||
action: makeAction({ action_id: 'a1' }),
|
||||
endpointCount: 0,
|
||||
};
|
||||
const skill: AgentItem = {
|
||||
kind: 'skill',
|
||||
id: 's1',
|
||||
name: 's1',
|
||||
description: '',
|
||||
iconKey: 'skill',
|
||||
skill: makeSkill({ _id: 's1' }),
|
||||
};
|
||||
expect(hasConfigurableSettings(mcp)).toBe(true);
|
||||
expect(hasConfigurableSettings(action)).toBe(true);
|
||||
expect(hasConfigurableSettings(skill)).toBe(false);
|
||||
});
|
||||
|
||||
test('a regular tool is configurable only when it needs auth', () => {
|
||||
const noAuth: AgentItem = {
|
||||
kind: 'tool',
|
||||
id: 'dalle',
|
||||
name: 'DALL-E',
|
||||
description: '',
|
||||
iconKey: 'tool',
|
||||
plugin: makePlugin({ pluginKey: 'dalle' }),
|
||||
};
|
||||
const needsAuth: AgentItem = {
|
||||
kind: 'tool',
|
||||
id: 'serpapi',
|
||||
name: 'SerpApi',
|
||||
description: '',
|
||||
iconKey: 'tool',
|
||||
plugin: makePlugin({
|
||||
pluginKey: 'serpapi',
|
||||
authConfig: [{ authField: 'SERPAPI_API_KEY', label: 'Key', description: '' }],
|
||||
authenticated: false,
|
||||
}),
|
||||
};
|
||||
expect(hasConfigurableSettings(noAuth)).toBe(false);
|
||||
expect(hasConfigurableSettings(needsAuth)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,12 @@ export interface BuildCatalogInputs {
|
|||
* `agentsConfig.capabilities` here.
|
||||
*/
|
||||
showMemory?: boolean;
|
||||
/**
|
||||
* Whether `web_search` uses USER_PROVIDED auth (a user-managed key). Drives the
|
||||
* cog-vs-info affordance on the web_search card/row: configurable only when a
|
||||
* user key exists; SYSTEM_DEFINED deployments have nothing to configure.
|
||||
*/
|
||||
webSearchUserProvided?: boolean;
|
||||
}
|
||||
|
||||
interface BuiltinDef {
|
||||
|
|
@ -107,6 +113,8 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] {
|
|||
name: def.nameKey,
|
||||
description: def.descriptionKey,
|
||||
status: inputs.builtinAuthMap?.get(def.id) === true ? 'needs_setup' : undefined,
|
||||
userProvidedAuth:
|
||||
def.id === AgentCapabilities.web_search ? inputs.webSearchUserProvided === true : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import { pluginNeedsAuth } from './auth';
|
|||
export function hasConfigurableSettings(item: AgentItem): boolean {
|
||||
switch (item.kind) {
|
||||
case 'builtin':
|
||||
return item.id === 'artifacts' || item.id === 'file_search' || item.id === 'context';
|
||||
return (
|
||||
item.id === 'artifacts' ||
|
||||
item.id === 'file_search' ||
|
||||
item.id === 'context' ||
|
||||
(item.id === 'web_search' && item.userProvidedAuth === true)
|
||||
);
|
||||
case 'tool':
|
||||
return pluginNeedsAuth(item.plugin);
|
||||
case 'mcp':
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ interface ItemBase {
|
|||
export interface BuiltinItem extends ItemBase {
|
||||
kind: 'builtin';
|
||||
id: BuiltinId;
|
||||
/**
|
||||
* True when `web_search` auth is USER_PROVIDED (a user-managed key exists to
|
||||
* configure). Undefined/false means SYSTEM_DEFINED — nothing to configure, so
|
||||
* the card/row shows an info icon instead of a settings cog.
|
||||
*/
|
||||
userProvidedAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolItem extends ItemBase {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
SKILL_NAME_MAX_LENGTH,
|
||||
SKILL_DESCRIPTION_MAX_LENGTH,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TSkill } from 'librechat-data-provider';
|
||||
import { useCreateSkillMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -22,6 +23,12 @@ interface CreateSkillDialogProps {
|
|||
defaultName?: string;
|
||||
defaultDescription?: string;
|
||||
defaultBody?: string;
|
||||
/**
|
||||
* Called with the created skill instead of navigating to its page. Lets the
|
||||
* dialog be reused in-context (e.g. the agent-builder skill picker) so creating
|
||||
* a skill keeps the user in place rather than routing to `/skills/:id`.
|
||||
*/
|
||||
onCreated?: (skill: TSkill) => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
|
|
@ -40,6 +47,7 @@ export default function CreateSkillDialog({
|
|||
defaultName = '',
|
||||
defaultDescription = '',
|
||||
defaultBody = '',
|
||||
onCreated,
|
||||
}: CreateSkillDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -60,6 +68,10 @@ export default function CreateSkillDialog({
|
|||
showToast({ status: 'success', message: localize('com_ui_skill_created') });
|
||||
setIsOpen(false);
|
||||
reset();
|
||||
if (onCreated) {
|
||||
onCreated(skill);
|
||||
return;
|
||||
}
|
||||
navigate(`/skills/${skill._id}`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
|
||||
import type { AgentToolType } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type MCPToolItemProps = {
|
||||
tool: AgentToolType;
|
||||
onAddTool: () => void;
|
||||
onRemoveTool: () => void;
|
||||
isInstalled?: boolean;
|
||||
isConfiguring?: boolean;
|
||||
isInitializing?: boolean;
|
||||
};
|
||||
|
||||
function MCPToolItem({
|
||||
tool,
|
||||
onAddTool,
|
||||
onRemoveTool,
|
||||
isInstalled = false,
|
||||
isConfiguring = false,
|
||||
isInitializing = false,
|
||||
}: MCPToolItemProps) {
|
||||
const localize = useLocalize();
|
||||
const handleClick = () => {
|
||||
if (isInstalled) {
|
||||
onRemoveTool();
|
||||
} else {
|
||||
onAddTool();
|
||||
}
|
||||
};
|
||||
|
||||
const name = tool.metadata?.name || tool.tool_id;
|
||||
const description = tool.metadata?.description || '';
|
||||
const icon = tool.metadata?.icon;
|
||||
|
||||
// Determine button state and text
|
||||
const getButtonState = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
text: localize('com_nav_tool_remove'),
|
||||
icon: <XCircle className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />,
|
||||
className: 'btn btn-neutral border-token-border-light relative',
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isConfiguring) {
|
||||
return {
|
||||
text: localize('com_ui_confirm'),
|
||||
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />,
|
||||
className: 'btn btn-primary relative',
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isInitializing) {
|
||||
return {
|
||||
text: localize('com_ui_initializing'),
|
||||
icon: <Wrench className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />,
|
||||
className: 'btn btn-primary relative opacity-75 cursor-not-allowed',
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: localize('com_ui_add'),
|
||||
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />,
|
||||
className: 'btn btn-primary relative',
|
||||
disabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
const buttonState = getButtonState();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-[70px] w-[70px] shrink-0">
|
||||
<div className="relative h-full w-full">
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt={localize('com_ui_logo', { 0: name })}
|
||||
className="h-full w-full rounded-[5px] bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-[5px] border border-border-medium bg-transparent">
|
||||
<Wrench className="h-8 w-8 text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-[5px] ring-1 ring-inset ring-black/10"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between">
|
||||
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
|
||||
{name}
|
||||
</div>
|
||||
<button
|
||||
className={buttonState.className}
|
||||
aria-label={`${buttonState.text} ${name}`}
|
||||
onClick={handleClick}
|
||||
disabled={buttonState.disabled}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{buttonState.text}
|
||||
{buttonState.icon}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MCPToolItem;
|
||||
|
|
@ -1269,7 +1269,6 @@
|
|||
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
|
||||
"com_ui_importing": "Importing",
|
||||
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
|
||||
"com_ui_initializing": "Initializing...",
|
||||
"com_ui_input": "Input",
|
||||
"com_ui_instructions": "Instructions",
|
||||
"com_ui_invalid_json": "Invalid JSON",
|
||||
|
|
@ -1327,6 +1326,8 @@
|
|||
"com_ui_mcp_invalid_url": "Please enter a valid URL",
|
||||
"com_ui_mcp_no_description": "No description available",
|
||||
"com_ui_mcp_oauth_cancelled": "OAuth login cancelled for {{0}}",
|
||||
"com_ui_mcp_oauth_qr_code_description": "QR code to open the OAuth login on another device",
|
||||
"com_ui_mcp_oauth_scan_qr": "Scan to open on your phone",
|
||||
"com_ui_mcp_oauth_timeout": "OAuth login timed out for {{0}}",
|
||||
"com_ui_mcp_programmatic": "Programmatic",
|
||||
"com_ui_mcp_programmatic_all": "Mark all as programmatic",
|
||||
|
|
@ -1930,6 +1931,7 @@
|
|||
"com_ui_tools_search_no_results": "Nothing matched your search",
|
||||
"com_ui_tools_section_count": "{{count}} enabled",
|
||||
"com_ui_tools_section_title": "Tools",
|
||||
"com_ui_tools_shared_by": "Shared by {{name}}",
|
||||
"com_ui_tools_skills_enabled_kill_switch": "Agent skills",
|
||||
"com_ui_tools_skills_enabled_kill_switch_hint": "Master switch for this agent's skills. When off, the agent ignores every skill you've selected, letting you disable them all at once without clearing your selection.",
|
||||
"com_ui_tools_source_action": "Custom action",
|
||||
|
|
@ -1953,7 +1955,6 @@
|
|||
"com_ui_unarchive_conversation": "Unarchive conversation",
|
||||
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
||||
"com_ui_unassigned": "Unassigned",
|
||||
"com_ui_unavailable": "Unavailable",
|
||||
"com_ui_unfavorite": "Remove from favorites",
|
||||
"com_ui_unknown": "Unknown",
|
||||
"com_ui_unknown_file_type": "Unknown file type",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue