mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20:15 +01:00
🔦 feat: MCP Support for Non-Agent Endpoints (#6775)
* wip: mcp select * refactor: Update useAvailableToolsQuery to support generic data types * feat: Enhance MCPSelect to dynamically load server options and improve MultiSelect component styling * WIP: ephemeral agents * wip: Add null check for MCPSelect and improve MultiSelect focus handling * feat: Pass conversationId prop to MCPSelect in BadgeRow to optimize badge rendering * feat: useApplyNewAgentTemplate hook to manage ephemeral agent upon conversation creation * WIP: eph. agent payload * refactor(OpenAIClient): streamline message processing by replacing content handling with parseTextParts function * feat: enhance applyAgentTemplate function to accept source conversation ID for improved template application * feat(parsers): add skipReasoning parameter to parseTextParts for conditional reasoning handling * WIP: first pass, ephemeral agent backend processing * chore: import order * feat: update loadEphemeralAgent and loadAgent functions to accept model_parameters for enhanced agent configuration * feat: add showMCPServers prop to BadgeRow for conditional rendering of MCPSelect, fix react rule violation * feat: enhance MCPSelect with localized placeholder and custom icon, add renderSelectedValues callback * feat: simplify message processing in AnthropicClient by replacing content handling with parseTextParts function * feat: implement useLocalStorage hook for managing MCP values and update MCPSelect to utilize it * chore: remove chatGPTBrowserSchema from endpoint schemas and update types for improved schema management * chore: remove compactChatGPTSchema from endpoint schemas and update types for better schema management * refactor: rename schemas for clarity and improve schema management * feat: extend model detection to include 'codestral' alongside 'mistral' * feat: add endpointType parameter to buildOptions and initializeClient functions * fix: update condition for handling completion in BaseClient to include agents client * refactor: simplify payload parsing logic in AgentClient and remove unused providerParsers * refactor: change useSetRecoilState to useRecoilState for better state management in MCPSelect component * refactor: streamline chat route handlers by consolidating middleware and improving endpoint structure * style: update MCPSelect and MultiSelect components for improved layout in mobile view * v0.7.790 * feat: add getMessageMapMethod to process message text and content in GoogleClient * chore: include LAST_MCP_ key prefix in clearLocalStorage function for proper teardown on logout
This commit is contained in:
parent
018143b5cc
commit
910c73359b
31 changed files with 741 additions and 285 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import React, {
|
||||
memo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
|
|
@ -12,11 +13,14 @@ import type { LucideIcon } from 'lucide-react';
|
|||
import type { BadgeItem } from '~/common';
|
||||
import { useChatBadges } from '~/hooks';
|
||||
import { Badge } from '~/components/ui';
|
||||
import MCPSelect from './MCPSelect';
|
||||
import store from '~/store';
|
||||
|
||||
interface BadgeRowProps {
|
||||
showMCPServers?: boolean;
|
||||
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
|
||||
onToggle?: (badgeId: string, currentActive: boolean) => void;
|
||||
conversationId?: string | null;
|
||||
isInChat: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +37,8 @@ interface BadgeWrapperProps {
|
|||
const BadgeWrapper = React.memo(
|
||||
forwardRef<HTMLDivElement, BadgeWrapperProps>(
|
||||
({ badge, isEditing, isInChat, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
|
||||
const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
|
||||
const atomBadge = useRecoilValue(badge.atom);
|
||||
const isActive = badge.atom ? atomBadge : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -126,7 +131,7 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
|
|||
}
|
||||
};
|
||||
|
||||
export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
|
||||
function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat }: BadgeRowProps) {
|
||||
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
|
||||
const [dragState, dispatch] = useReducer(dragReducer, {
|
||||
draggedBadge: null,
|
||||
|
|
@ -340,6 +345,7 @@ export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{showMCPServers === true && <MCPSelect conversationId={conversationId} />}
|
||||
{ghostBadge && (
|
||||
<div
|
||||
className="ghost-badge h-full"
|
||||
|
|
@ -367,3 +373,5 @@ export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(BadgeRow);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Constants, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useChatContext,
|
||||
useChatFormContext,
|
||||
|
|
@ -28,8 +28,8 @@ import CollapseChat from './CollapseChat';
|
|||
import StreamAudio from './StreamAudio';
|
||||
import StopButton from './StopButton';
|
||||
import SendButton from './SendButton';
|
||||
import { BadgeRow } from './BadgeRow';
|
||||
import EditBadges from './EditBadges';
|
||||
import BadgeRow from './BadgeRow';
|
||||
import Mention from './Mention';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -289,7 +289,9 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
<AttachFileChat disableInputs={disableInputs} />
|
||||
</div>
|
||||
<BadgeRow
|
||||
onChange={(newBadges) => setBadges(newBadges)}
|
||||
showMCPServers={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
conversationId={conversation?.conversationId ?? Constants.NEW_CONVO}
|
||||
onChange={setBadges}
|
||||
isInChat={
|
||||
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
|
||||
}
|
||||
|
|
|
|||
82
client/src/components/Chat/Input/MCPSelect.tsx
Normal file
82
client/src/components/Chat/Input/MCPSelect.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React, { memo, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery } from '~/data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import MultiSelect from '~/components/ui/MultiSelect';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import MCPIcon from '~/components/ui/MCPIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
|
||||
`${LocalStorageKeys.LAST_MCP_}${key}`,
|
||||
ephemeralAgent?.mcp ?? [],
|
||||
setSelectedValues,
|
||||
);
|
||||
const { data: mcpServers } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data) => {
|
||||
const serverNames = new Set<string>();
|
||||
data.forEach((tool) => {
|
||||
if (tool.pluginKey.includes(Constants.mcp_delimiter)) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
serverNames.add(parts[parts.length - 1]);
|
||||
}
|
||||
});
|
||||
return [...serverNames];
|
||||
},
|
||||
});
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
if (values.length === 0) {
|
||||
return placeholder || localize('com_ui_select') + '...';
|
||||
}
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
return localize('com_ui_x_selected', { 0: values.length });
|
||||
},
|
||||
[localize],
|
||||
);
|
||||
|
||||
if (!mcpServers || mcpServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
items={mcpServers ?? []}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={setMCPValues}
|
||||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
placeholder={localize('com_ui_mcp_servers')}
|
||||
popoverClassName="min-w-[200px]"
|
||||
className="badge-icon h-full min-w-[150px]"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-shadow md:w-full size-9 p-2 md:p-3 bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MCPSelect);
|
||||
31
client/src/components/ui/MCPIcon.tsx
Normal file
31
client/src/components/ui/MCPIcon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export default function MCPIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="195"
|
||||
height="195"
|
||||
viewBox="0 2 195 195"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
128
client/src/components/ui/MultiSelect.tsx
Normal file
128
client/src/components/ui/MultiSelect.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectArrow,
|
||||
SelectItem,
|
||||
SelectItemCheck,
|
||||
SelectLabel,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
} from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MultiSelectProps<T extends string> {
|
||||
items: T[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultSelectedValues?: T[];
|
||||
onSelectedValuesChange?: (values: T[]) => void;
|
||||
renderSelectedValues?: (values: T[], placeholder?: string) => React.ReactNode;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
labelClassName?: string;
|
||||
selectClassName?: string;
|
||||
selectIcon?: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
selectItemsClassName?: string;
|
||||
selectedValues: T[];
|
||||
setSelectedValues: (values: T[]) => void;
|
||||
}
|
||||
|
||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||
if (values.length === 0) {
|
||||
return placeholder || 'Select...';
|
||||
}
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
return `${values.length} items selected`;
|
||||
}
|
||||
|
||||
export default function MultiSelect<T extends string>({
|
||||
items,
|
||||
label,
|
||||
placeholder = 'Select...',
|
||||
defaultSelectedValues = [],
|
||||
onSelectedValuesChange,
|
||||
renderSelectedValues = defaultRender,
|
||||
className,
|
||||
selectIcon,
|
||||
itemClassName,
|
||||
labelClassName,
|
||||
selectClassName,
|
||||
popoverClassName,
|
||||
selectItemsClassName,
|
||||
selectedValues = [],
|
||||
setSelectedValues,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
// const [selectedValues, setSelectedValues] = React.useState<T[]>(defaultSelectedValues);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
if (onSelectedValuesChange) {
|
||||
onSelectedValuesChange(values);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('h-full', className)}>
|
||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
||||
{label && (
|
||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||
{label}
|
||||
</SelectLabel>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm',
|
||||
'bg-surface-tertiary text-text-primary shadow-sm hover:cursor-pointer hover:bg-surface-hover',
|
||||
'outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
|
||||
selectClassName,
|
||||
selectedValues.length > 0 && selectItemsClassName != null && selectItemsClassName,
|
||||
)}
|
||||
>
|
||||
{selectIcon && selectIcon}
|
||||
<span className="hidden truncate md:block">
|
||||
{renderSelectedValues(selectedValues, placeholder)}
|
||||
</span>
|
||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
||||
</Select>
|
||||
<SelectPopover
|
||||
gutter={4}
|
||||
sameWidth
|
||||
modal
|
||||
unmountOnHide
|
||||
finalFocus={selectRef}
|
||||
className={cn(
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'outline-none',
|
||||
popoverClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 text-sm',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
<SelectItemCheck className="text-primary" />
|
||||
<span className="truncate">{value}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ export * from './Pagination';
|
|||
export * from './Progress';
|
||||
export * from './InputOTP';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as MCPIcon } from './MCPIcon';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as SplitText } from './SplitText';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue