diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js index c6b7c84b2c..38b353d560 100644 --- a/api/server/routes/settings.js +++ b/api/server/routes/settings.js @@ -1,4 +1,5 @@ const express = require('express'); +const { createToolFavoritesHandlers } = require('@librechat/api'); const { updateFavoritesController, getFavoritesController, @@ -8,9 +9,23 @@ const { updateSkillStatesController, } = require('~/server/controllers/SkillStatesController'); const { requireJwtAuth } = require('~/server/middleware'); +const { getToolFavorites, addToolFavorite, removeToolFavorite } = require('~/models'); const router = express.Router(); +const toolFavorites = createToolFavoritesHandlers({ + getToolFavorites, + addToolFavorite, + removeToolFavorite, +}); + +router.get('/favorites/tools', requireJwtAuth, toolFavorites.listToolFavorites); +router.put('/favorites/tools/:itemType/:itemId', requireJwtAuth, toolFavorites.addToolFavorite); +router.delete( + '/favorites/tools/:itemType/:itemId', + requireJwtAuth, + toolFavorites.removeToolFavorite, +); router.get('/favorites', requireJwtAuth, getFavoritesController); router.post('/favorites', requireJwtAuth, updateFavoritesController); router.get('/skills/active', requireJwtAuth, getSkillStatesController); diff --git a/client/src/App.jsx b/client/src/App.jsx index 78ed8438b0..5d20a99200 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,8 +4,8 @@ import { DndProvider } from 'react-dnd'; import { RouterProvider } from 'react-router-dom'; import * as RadixToast from '@radix-ui/react-toast'; import { HTML5Backend } from 'react-dnd-html5-backend'; -import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; +import { Toast, ThemeProvider, ToastProvider, useInputModality } from '@librechat/client'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import WakeLockManager from '~/components/System/WakeLockManager'; import QueryDevtoolsGate from '~/components/QueryDevtoolsGate'; @@ -17,6 +17,7 @@ import { router } from './routes'; const App = () => { const { setError } = useApiErrorBoundary(); + useInputModality(); const queryClient = new QueryClient({ defaultOptions: { diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index b0d74374b4..f465165e28 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -42,13 +42,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); - const { data: mcpData } = useMCPToolsQuery({ + const { data: mcpData, isFetching: mcpToolsFetching } = useMCPToolsQuery({ enabled: !isEphemeralAgent(agent_id) && !isLoading && availableMCPServers != null && availableMCPServers.length > 0, }); + /** Tools are still arriving when the query is in flight and nothing is cached + * yet (e.g., right after a hard refresh). Lets the MCP dialog show a skeleton. */ + const mcpToolsLoading = mcpToolsFetching && mcpData == null; const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); const mcpServerNames = useMemo( @@ -148,6 +151,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) agentsConfig, startupConfig, mcpServersMap, + mcpToolsLoading, setActivePanel, endpointsConfig, setCurrentAgentId, diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a27743a8f0..66e145fa61 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -233,6 +233,9 @@ export type AgentPanelContextType = { endpointsConfig?: t.TEndpointsConfig | null; /** Pre-computed MCP server information indexed by server key */ mcpServersMap: Map; + /** True while the MCP tools list is being fetched and no data has arrived yet, + * so consumers can show a skeleton instead of an empty "no tools" state. */ + mcpToolsLoading: boolean; availableMCPServers: MCPServerDefinition[]; availableMCPServersMap: t.MCPServersListResponse | undefined; }; diff --git a/client/src/components/MCP/McpOAuthDialog.tsx b/client/src/components/MCP/McpOAuthDialog.tsx new file mode 100644 index 0000000000..873478b184 --- /dev/null +++ b/client/src/components/MCP/McpOAuthDialog.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { QrCode, ExternalLink } from 'lucide-react'; +import { + Input, + Button, + OGDialog, + OGDialogTitle, + OGDialogContent, + OGDialogDescription, +} from '@librechat/client'; +import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { cn } from '~/utils'; + +interface McpOAuthDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName: string; + oauthUrl: string; + /** The MCP server's icon, shown beside the title when the server provides one. */ + iconUrl?: string; +} + +/** + * Dedicated second dialog, opened ONLY when connecting an MCP server requires + * OAuth. Offers three ways to finish: continue in this browser, copy the + * authorization URL to open elsewhere, or reveal a QR code to scan on a phone. + * Auto-closes once the server connects (the caller derives `open` from + * connection state). + */ +export default function McpOAuthDialog({ + open, + onOpenChange, + serverName, + oauthUrl, + iconUrl, +}: McpOAuthDialogProps) { + const localize = useLocalize(); + const [isCopying, setIsCopying] = useState(false); + const [showQR, setShowQR] = useState(false); + const [iconError, setIconError] = useState(false); + const copyUrl = useCopyToClipboard({ text: oauthUrl }); + + if (!oauthUrl) { + return null; + } + + return ( + + +
+ {iconUrl && !iconError && ( + + )} + + {localize('com_nav_mcp_connect_server', { 0: serverName })} + +
+ + {localize('com_ui_mcp_oauth_description')} + + +
+ {/* Auto-height reveal via grid-template-rows 0fr -> 1fr so the QR slides + * open smoothly without a hardcoded height, matching MCPToolItem. */} +
+
+
+
+ +
+ + {localize('com_ui_mcp_oauth_scan_qr')} + +
+
+
+ +
+ event.currentTarget.select()} + className="pr-10 text-text-secondary" + data-testid="mcp-oauth-url" + /> + { + if (!isCopying) { + copyUrl(setIsCopying); + } + }} + className="absolute right-1 top-1/2 -translate-y-1/2" + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx b/client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx new file mode 100644 index 0000000000..1a747ca32f --- /dev/null +++ b/client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx @@ -0,0 +1,100 @@ +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('~/components/Messages/Content/CopyButton', () => ({ + __esModule: true, + default: ({ onClick }: { onClick: () => void }) => ( + - + ); + })} - +
+ {onCancel && ( + + )} + +
+ ); } diff --git a/client/src/components/Plugins/Store/__tests__/PluginAuthForm.spec.tsx b/client/src/components/Plugins/Store/__tests__/PluginAuthForm.spec.tsx index d80e99e04e..0b3c7402cc 100644 --- a/client/src/components/Plugins/Store/__tests__/PluginAuthForm.spec.tsx +++ b/client/src/components/Plugins/Store/__tests__/PluginAuthForm.spec.tsx @@ -1,5 +1,5 @@ -import { render, screen } from 'test/layout-test-utils'; import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test/layout-test-utils'; import PluginAuthForm from '../PluginAuthForm'; describe('PluginAuthForm', () => { @@ -48,6 +48,17 @@ describe('PluginAuthForm', () => { expect(urlField.parentElement?.querySelector('button')).toBeNull(); }); + it('shows a format-hint placeholder for recognized credential fields', () => { + const openAiPlugin = { + pluginKey: 'dalle', + authConfig: [{ authField: 'DALLE3_API_KEY||DALLE_API_KEY', label: 'OpenAI API Key' }], + }; + //@ts-ignore - dont need all props of plugin + render(); + + expect(screen.getByLabelText('OpenAI API Key')).toHaveAttribute('placeholder', 'sk-...'); + }); + it('calls the onSubmit function with the form data when submitted', async () => { //@ts-ignore - dont need all props of plugin render(); @@ -65,4 +76,29 @@ describe('PluginAuthForm', () => { }, }); }); + + it('reflects an external saving state as a disabled, in-progress submit button', () => { + //@ts-ignore - dont need all props of plugin + render(); + + const button = screen.getByRole('button', { name: 'Saving...' }); + expect(button).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + }); + + it('renders a Cancel button when onCancel is provided and invokes it on click', async () => { + const onCancel = jest.fn(); + //@ts-ignore - dont need all props of plugin + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does not render a Cancel button by default', () => { + //@ts-ignore - dont need all props of plugin + render(); + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/SidePanel/Agents/ActionsInput.tsx b/client/src/components/SidePanel/Agents/ActionsInput.tsx index 82b7c7f07b..4311311734 100644 --- a/client/src/components/SidePanel/Agents/ActionsInput.tsx +++ b/client/src/components/SidePanel/Agents/ActionsInput.tsx @@ -1,12 +1,23 @@ import { useState, useEffect } from 'react'; import debounce from 'lodash/debounce'; +import { Maximize2 } from 'lucide-react'; import { useFormContext } from 'react-hook-form'; -import { Spinner, useToastContext } from '@librechat/client'; import { validateAndParseOpenAPISpec, openapiToFunction, AuthTypeEnum, } from 'librechat-data-provider'; +import { + Button, + Spinner, + Textarea, + OGDialog, + OGDialogTitle, + OGDialogHeader, + OGDialogContent, + OGDialogDescription, + useToastContext, +} from '@librechat/client'; import type { Action, FunctionTool, @@ -15,8 +26,8 @@ import type { } from 'librechat-data-provider'; import type { ActionAuthForm } from '~/common'; import type { Spec } from './ActionsTable'; +import { ActionsTable, ActionsTableSkeleton, columns } from './ActionsTable'; import ActionCallback from '~/components/SidePanel/Builder/ActionCallback'; -import { ActionsTable, columns } from './ActionsTable'; import { useUpdateAgentAction } from '~/data-provider'; import { useLocalize } from '~/hooks'; import { logger } from '~/utils'; @@ -29,19 +40,25 @@ const debouncedValidation = debounce( 800, ); +/** Placeholder rows shaped like the "Available actions" table (Name / Method / Path). */ export default function ActionsInput({ action, agent_id, setAction, + onCreated, + footerStart, }: { action?: Action; agent_id?: string; setAction: React.Dispatch>; + onCreated?: () => void; + footerStart?: React.ReactNode; }) { const handleResult = (result: ValidationResult) => { if (!result.status) { setData(null); setFunctions(null); + setIsValidating(false); } setValidationResult(result); }; @@ -51,6 +68,8 @@ export default function ActionsInput({ const { handleSubmit, reset } = useFormContext(); const [validationResult, setValidationResult] = useState(null); const [inputValue, setInputValue] = useState(''); + const [isValidating, setIsValidating] = useState(false); + const [isSchemaDialogOpen, setIsSchemaDialogOpen] = useState(false); const [data, setData] = useState(null); const [functions, setFunctions] = useState(null); @@ -61,7 +80,8 @@ export default function ActionsInput({ return; } setInputValue(rawSpec); - debouncedValidation(rawSpec, handleResult); + setIsValidating(true); + handleResult(validateAndParseOpenAPISpec(rawSpec)); }, [action?.metadata.raw_spec]); useEffect(() => { @@ -82,16 +102,21 @@ export default function ActionsInput({ setData(specs); setValidationResult(null); setFunctions(functionSignatures.map((f) => f.toObjectTool())); + setIsValidating(false); }, [validationResult]); const updateAgentAction = useUpdateAgentAction({ onSuccess(data) { + const wasCreate = !action?.action_id; showToast({ message: localize('com_assistants_update_actions_success'), status: 'success', }); reset(); setAction(data[1]); + if (wasCreate) { + onCreated?.(); + } }, onError(error) { showToast({ @@ -183,8 +208,10 @@ export default function ActionsInput({ if (!newValue) { setData(null); setFunctions(null); + setIsValidating(false); return setValidationResult(null); } + setIsValidating(true); debouncedValidation(newValue, handleResult); }; @@ -200,72 +227,103 @@ export default function ActionsInput({ return localize('com_ui_create'); }; + const validationError = + validationResult && validationResult.message !== 'OpenAPI spec is valid.' + ? validationResult.message + : null; + const showSkeleton = isValidating && !data; + return ( <> -
-
+
+
+
-
-
-