mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-23 10:46:12 +01:00
Merge branch 'dev' into feat/prompt-enhancement
This commit is contained in:
commit
3d261a969d
365 changed files with 23826 additions and 8790 deletions
|
|
@ -65,6 +65,7 @@
|
|||
"export-from-json": "^1.7.2",
|
||||
"filenamify": "^6.0.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"heic-to": "^1.1.14",
|
||||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
|
|
@ -74,6 +75,7 @@
|
|||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rc-input-number": "^7.4.2",
|
||||
"react": "^18.2.0",
|
||||
|
|
|
|||
97
client/src/Providers/AgentPanelContext.tsx
Normal file
97
client/src/Providers/AgentPanelContext.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider';
|
||||
import type { AgentPanelContextType } from '~/common';
|
||||
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||
|
||||
export function useAgentPanelContext() {
|
||||
const context = useContext(AgentPanelContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAgentPanelContext must be used within an AgentPanelProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */
|
||||
export function AgentPanelProvider({ children }: { children: React.ReactNode }) {
|
||||
const localize = useLocalize();
|
||||
const [mcp, setMcp] = useState<MCP | undefined>(undefined);
|
||||
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
|
||||
const [action, setAction] = useState<Action | undefined>(undefined);
|
||||
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
|
||||
const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
|
||||
enabled: !!agent_id,
|
||||
});
|
||||
|
||||
const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
enabled: !!agent_id,
|
||||
});
|
||||
|
||||
const tools =
|
||||
pluginTools?.map((tool) => ({
|
||||
tool_id: tool.pluginKey,
|
||||
metadata: tool as TPlugin,
|
||||
agent_id: agent_id || '',
|
||||
})) || [];
|
||||
|
||||
const groupedTools =
|
||||
tools?.reduce(
|
||||
(acc, tool) => {
|
||||
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
|
||||
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
|
||||
const groupKey = `${serverName.toLowerCase()}`;
|
||||
if (!acc[groupKey]) {
|
||||
acc[groupKey] = {
|
||||
tool_id: groupKey,
|
||||
metadata: {
|
||||
name: `${serverName}`,
|
||||
pluginKey: groupKey,
|
||||
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
|
||||
icon: tool.metadata.icon || '',
|
||||
} as TPlugin,
|
||||
agent_id: agent_id || '',
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
acc[groupKey].tools?.push({
|
||||
tool_id: tool.tool_id,
|
||||
metadata: tool.metadata,
|
||||
agent_id: agent_id || '',
|
||||
});
|
||||
} else {
|
||||
acc[tool.tool_id] = {
|
||||
tool_id: tool.tool_id,
|
||||
metadata: tool.metadata,
|
||||
agent_id: agent_id || '',
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
|
||||
) || {};
|
||||
|
||||
const value = {
|
||||
action,
|
||||
setAction,
|
||||
mcp,
|
||||
setMcp,
|
||||
mcps,
|
||||
setMcps,
|
||||
activePanel,
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id,
|
||||
groupedTools,
|
||||
/** Query data for actions and tools */
|
||||
actions,
|
||||
tools,
|
||||
};
|
||||
|
||||
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { defaultAgentFormValues } from 'librechat-data-provider';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { getDefaultAgentFormValues } from '~/utils';
|
||||
|
||||
type AgentsContextType = UseFormReturn<AgentForm>;
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export function useAgentsContext() {
|
|||
|
||||
export default function AgentsProvider({ children }) {
|
||||
const methods = useForm<AgentForm>({
|
||||
defaultValues: defaultAgentFormValues,
|
||||
defaultValues: getDefaultAgentFormValues(),
|
||||
});
|
||||
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { default as ToastProvider } from './ToastContext';
|
||||
export { default as AssistantsProvider } from './AssistantsContext';
|
||||
export { default as AgentsProvider } from './AgentsContext';
|
||||
export { default as ToastProvider } from './ToastContext';
|
||||
export * from './AgentPanelContext';
|
||||
export * from './ChatContext';
|
||||
export * from './ShareContext';
|
||||
export * from './ToastContext';
|
||||
|
|
|
|||
26
client/src/common/mcp.ts
Normal file
26
client/src/common/mcp.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
AuthorizationTypeEnum,
|
||||
AuthTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import { MCPForm } from '~/common/types';
|
||||
|
||||
export const defaultMCPFormValues: MCPForm = {
|
||||
type: AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
api_key: '',
|
||||
authorization_type: AuthorizationTypeEnum.Basic,
|
||||
custom_auth_header: '',
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
authorization_url: '',
|
||||
client_url: '',
|
||||
scope: '',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
name: '',
|
||||
description: '',
|
||||
url: '',
|
||||
tools: [],
|
||||
icon: '',
|
||||
trust: false,
|
||||
};
|
||||
|
|
@ -143,6 +143,7 @@ export enum Panel {
|
|||
actions = 'actions',
|
||||
model = 'model',
|
||||
version = 'version',
|
||||
mcp = 'mcp',
|
||||
}
|
||||
|
||||
export type FileSetter =
|
||||
|
|
@ -166,6 +167,15 @@ export type ActionAuthForm = {
|
|||
token_exchange_method: t.TokenExchangeMethodEnum;
|
||||
};
|
||||
|
||||
export type MCPForm = ActionAuthForm & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
tools?: string[];
|
||||
icon?: string;
|
||||
trust?: boolean;
|
||||
};
|
||||
|
||||
export type ActionWithNullableMetadata = Omit<t.Action, 'metadata'> & {
|
||||
metadata: t.ActionMetadata | null;
|
||||
};
|
||||
|
|
@ -188,16 +198,35 @@ export type AgentPanelProps = {
|
|||
index?: number;
|
||||
agent_id?: string;
|
||||
activePanel?: string;
|
||||
mcp?: t.MCP;
|
||||
mcps?: t.MCP[];
|
||||
action?: t.Action;
|
||||
actions?: t.Action[];
|
||||
createMutation: UseMutationResult<t.Agent, Error, t.AgentCreateParams>;
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
||||
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
|
||||
endpointsConfig?: t.TEndpointsConfig;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
agentsConfig?: t.TAgentsEndpoint | null;
|
||||
};
|
||||
|
||||
export type AgentPanelContextType = {
|
||||
action?: t.Action;
|
||||
actions?: t.Action[];
|
||||
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
|
||||
mcp?: t.MCP;
|
||||
mcps?: t.MCP[];
|
||||
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
||||
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
|
||||
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
|
||||
tools: t.AgentToolType[];
|
||||
activePanel?: string;
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
agent_id?: string;
|
||||
};
|
||||
|
||||
export type AgentModelPanelProps = {
|
||||
agent_id?: string;
|
||||
providers: Option[];
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const defaultType = 'unknown';
|
|||
const defaultIdentifier = 'lc-no-identifier';
|
||||
|
||||
export function Artifact({
|
||||
node,
|
||||
node: _node,
|
||||
...props
|
||||
}: Artifact & {
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||
|
|
@ -95,7 +95,7 @@ export function Artifact({
|
|||
setArtifacts((prevArtifacts) => {
|
||||
if (
|
||||
prevArtifacts?.[artifactKey] != null &&
|
||||
prevArtifacts[artifactKey].content === content
|
||||
prevArtifacts[artifactKey]?.content === content
|
||||
) {
|
||||
return prevArtifacts;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
// client/src/hooks/useDebounceCodeBlock.ts
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { codeBlocksState, codeBlockIdsState } from '~/store/artifacts';
|
||||
import type { CodeBlock } from '~/common';
|
||||
|
||||
export function useDebounceCodeBlock() {
|
||||
const setCodeBlocks = useSetRecoilState(codeBlocksState);
|
||||
const setCodeBlockIds = useSetRecoilState(codeBlockIdsState);
|
||||
|
||||
const updateCodeBlock = useCallback((codeBlock: CodeBlock) => {
|
||||
console.log('Updating code block:', codeBlock);
|
||||
setCodeBlocks((prev) => ({
|
||||
...prev,
|
||||
[codeBlock.id]: codeBlock,
|
||||
}));
|
||||
setCodeBlockIds((prev) =>
|
||||
prev.includes(codeBlock.id) ? prev : [...prev, codeBlock.id],
|
||||
);
|
||||
}, [setCodeBlocks, setCodeBlockIds]);
|
||||
|
||||
const debouncedUpdateCodeBlock = useCallback(
|
||||
debounce((codeBlock: CodeBlock) => {
|
||||
updateCodeBlock(codeBlock);
|
||||
}, 25),
|
||||
[updateCodeBlock],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedUpdateCodeBlock.cancel();
|
||||
};
|
||||
}, [debouncedUpdateCodeBlock]);
|
||||
|
||||
return debouncedUpdateCodeBlock;
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { Label, OGDialog, OGDialogTrigger, TooltipAnchor } from '~/components/ui';
|
||||
import { Button, TrashIcon, Label, OGDialog, OGDialogTrigger, TooltipAnchor } from '~/components';
|
||||
import { useDeleteConversationTagMutation } from '~/data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DeleteBookmarkButton: FC<{
|
||||
|
|
@ -36,31 +35,26 @@ const DeleteBookmarkButton: FC<{
|
|||
await deleteBookmarkMutation.mutateAsync(bookmark);
|
||||
}, [bookmark, deleteBookmarkMutation]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
role="button"
|
||||
aria-label={localize('com_ui_bookmarks_delete')}
|
||||
description={localize('com_ui_delete')}
|
||||
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
|
||||
tabIndex={tabIndex}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onClick={() => setOpen(!open)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</TooltipAnchor>
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={localize('com_ui_bookmarks_delete')}
|
||||
tabIndex={tabIndex}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, OGDialogTrigger } from '~/components/ui';
|
||||
import { TooltipAnchor, OGDialogTrigger, EditIcon, Button } from '~/components';
|
||||
import BookmarkEditDialog from './BookmarkEditDialog';
|
||||
import { EditIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const EditBookmarkButton: FC<{
|
||||
|
|
@ -15,12 +14,6 @@ const EditBookmarkButton: FC<{
|
|||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BookmarkEditDialog
|
||||
context="EditBookmarkButton"
|
||||
|
|
@ -30,18 +23,21 @@ const EditBookmarkButton: FC<{
|
|||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
role="button"
|
||||
aria-label={localize('com_ui_bookmarks_edit')}
|
||||
description={localize('com_ui_edit')}
|
||||
tabIndex={tabIndex}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<EditIcon />
|
||||
</TooltipAnchor>
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={localize('com_ui_bookmarks_edit')}
|
||||
tabIndex={tabIndex}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</BookmarkEditDialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default function Header() {
|
|||
<div className="mx-1 flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!isSmallScreen ? 'transition-all duration-500 ease-in-out' : ''
|
||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
|
||||
} ${
|
||||
!navVisible
|
||||
? 'translate-x-0 opacity-100'
|
||||
|
|
@ -51,7 +51,7 @@ export default function Header() {
|
|||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!isSmallScreen ? 'transition-all duration-500 ease-in-out' : ''
|
||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
|
||||
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
|
||||
>
|
||||
<ModelSelector startupConfig={startupConfig} />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
|
||||
import { useAvailableToolsQuery } from '~/data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import MultiSelect from '~/components/ui/MultiSelect';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import MCPIcon from '~/components/ui/MCPIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface McpServerInfo {
|
||||
name: string;
|
||||
pluginKey: string;
|
||||
authConfig?: TPluginAuthConfig[];
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName
|
||||
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
|
||||
const parts = fullPluginKey.split(Constants.mcp_delimiter);
|
||||
return Constants.mcp_prefix + parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
|
|
@ -24,20 +42,45 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
|||
|
||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const hasSetFetched = useRef<string | null>(null);
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
|
||||
|
||||
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data) => {
|
||||
const serverNames = new Set<string>();
|
||||
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data: TPlugin[]) => {
|
||||
const mcpToolsMap = new Map<string, McpServerInfo>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP && tool.chatMenu !== false) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
serverNames.add(parts[parts.length - 1]);
|
||||
const serverName = parts[parts.length - 1];
|
||||
if (!mcpToolsMap.has(serverName)) {
|
||||
mcpToolsMap.set(serverName, {
|
||||
name: serverName,
|
||||
pluginKey: tool.pluginKey,
|
||||
authConfig: tool.authConfig,
|
||||
authenticated: tool.authenticated,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return serverNames;
|
||||
return Array.from(mcpToolsMap.values());
|
||||
},
|
||||
});
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: () => {
|
||||
setIsConfigModalOpen(false);
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Error updating MCP auth:', error);
|
||||
showToast({
|
||||
message: localize('com_nav_mcp_vars_update_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -76,12 +119,12 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
return;
|
||||
}
|
||||
hasSetFetched.current = key;
|
||||
if ((mcpServerSet?.size ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
|
||||
if ((mcpToolDetails?.length ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
|
||||
return;
|
||||
}
|
||||
setMCPValues([]);
|
||||
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
|
||||
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
|
|
@ -96,28 +139,140 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
[localize],
|
||||
);
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
return Array.from(mcpServerSet ?? []);
|
||||
}, [mcpServerSet]);
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
if (!mcpServerSet || mcpServerSet.size === 0) {
|
||||
const handleConfigSave = useCallback(
|
||||
(targetName: string, authData: Record<string, string>) => {
|
||||
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: basePluginKey,
|
||||
action: 'install',
|
||||
auth: authData,
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
}
|
||||
},
|
||||
[selectedToolForConfig, updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const handleConfigRevoke = useCallback(
|
||||
(targetName: string) => {
|
||||
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: basePluginKey,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
}
|
||||
},
|
||||
[selectedToolForConfig, updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const renderItemContent = useCallback(
|
||||
(serverName: string, defaultContent: React.ReactNode) => {
|
||||
const tool = mcpToolDetails?.find((t) => t.name === serverName);
|
||||
const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0;
|
||||
|
||||
// Common wrapper for the main content (check mark + text)
|
||||
// Ensures Check & Text are adjacent and the group takes available space.
|
||||
const mainContentWrapper = (
|
||||
<div className="flex flex-grow items-center">{defaultContent}</div>
|
||||
);
|
||||
|
||||
if (tool && hasAuthConfig) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{mainContentWrapper}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setSelectedToolForConfig(tool);
|
||||
setIsConfigModalOpen(true);
|
||||
}}
|
||||
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
aria-label={`Configure ${serverName}`}
|
||||
>
|
||||
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// For items without a settings icon, return the consistently wrapped main content.
|
||||
return mainContentWrapper;
|
||||
},
|
||||
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
|
||||
);
|
||||
|
||||
if (!mcpToolDetails || mcpToolDetails.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-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
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-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
<>
|
||||
<MultiSelect
|
||||
items={mcpServerNames}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={setMCPValues}
|
||||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
renderItemContent={renderItemContent}
|
||||
placeholder={localize('com_ui_mcp_servers')}
|
||||
popoverClassName="min-w-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
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-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
{selectedToolForConfig && (
|
||||
<MCPConfigDialog
|
||||
isOpen={isConfigModalOpen}
|
||||
onOpenChange={setIsConfigModalOpen}
|
||||
serverName={selectedToolForConfig.name}
|
||||
fieldsSchema={(() => {
|
||||
const schema: Record<string, ConfigFieldDetail> = {};
|
||||
if (selectedToolForConfig?.authConfig) {
|
||||
selectedToolForConfig.authConfig.forEach((field) => {
|
||||
schema[field.authField] = {
|
||||
title: field.label,
|
||||
description: field.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
return schema;
|
||||
})()}
|
||||
initialValues={(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
// Note: Actual initial values might need to be fetched if they are stored user-specifically
|
||||
if (selectedToolForConfig?.authConfig) {
|
||||
selectedToolForConfig.authConfig.forEach((field) => {
|
||||
initial[field.authField] = ''; // Or fetched value
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
})()}
|
||||
onSave={(authData) => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigSave(selectedToolForConfig.name, authData);
|
||||
}
|
||||
}}
|
||||
onRevoke={() => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigRevoke(selectedToolForConfig.name);
|
||||
}
|
||||
}}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -157,8 +157,9 @@ const BookmarkMenu: FC = () => {
|
|||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
|
||||
<DropdownPopup
|
||||
focusLoop={true}
|
||||
portal={true}
|
||||
menuId={menuId}
|
||||
focusLoop={true}
|
||||
isOpen={isMenuOpen}
|
||||
unmountOnHide={true}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
|
|||
blurOnHoverEnd: false,
|
||||
...props,
|
||||
className: cn(
|
||||
'flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full',
|
||||
'relative flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
|
||||
props.className,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
|
|
@ -72,6 +73,7 @@ const ContentParts = memo(
|
|||
|
||||
return hasThinkPart && allThinkPartsHaveContent;
|
||||
}, [content]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -103,6 +105,7 @@ const ContentParts = memo(
|
|||
return (
|
||||
<>
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources />
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_save')}
|
||||
description={localize('com_ui_download')}
|
||||
render={
|
||||
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
|
||||
<ArrowDownToLine className="size-6" />
|
||||
|
|
@ -108,7 +108,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
alt="Image"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 6rem)', // Account for header and padding
|
||||
maxHeight: 'calc(100vh - 6rem)',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -117,7 +117,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
|
||||
{/* Side Panel */}
|
||||
<div
|
||||
className={`shadow-l-lg fixed right-0 top-0 z-20 h-full w-80 transform rounded-l-2xl border-l border-border-light bg-surface-secondary transition-transform duration-500 ease-in-out ${
|
||||
className={`shadow-l-lg fixed right-0 top-0 z-20 h-full w-80 transform rounded-l-2xl border-l border-border-light bg-surface-primary transition-transform duration-500 ease-in-out ${
|
||||
isPromptOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -132,7 +132,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
<div className="space-y-6">
|
||||
{/* Prompt Section */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-text-secondary">
|
||||
<h4 className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_prompt')}
|
||||
</h4>
|
||||
<div className="rounded-md bg-surface-tertiary p-3">
|
||||
|
|
@ -144,20 +144,18 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
|
||||
{/* Generation Settings */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-text-secondary">
|
||||
<h4 className="mb-3 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_generation_settings')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">{localize('com_ui_size')}:</span>
|
||||
<span className="text-sm text-text-primary">{localize('com_ui_size')}:</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{args?.size || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{localize('com_ui_quality')}:
|
||||
</span>
|
||||
<span className="text-sm text-text-primary">{localize('com_ui_quality')}:</span>
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium capitalize ${
|
||||
args?.quality === 'high'
|
||||
|
|
@ -171,7 +169,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">
|
||||
<span className="text-sm text-text-primary">
|
||||
{localize('com_ui_file_size')}:
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
|
|
|
|||
|
|
@ -46,13 +46,33 @@ const Image = ({
|
|||
[placeholderDimensions, height, width],
|
||||
);
|
||||
|
||||
const downloadImage = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = imagePath;
|
||||
link.download = altText;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imagePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = altText || 'image.png';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
const link = document.createElement('a');
|
||||
link.href = imagePath;
|
||||
link.download = altText || 'image.png';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
|||
remarkGfm,
|
||||
remarkDirective,
|
||||
artifactPlugin,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
unicodeCitation,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const MarkdownLite = memo(
|
|||
/** @ts-ignore */
|
||||
supersub,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
/** @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
|
|
|
|||
143
client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx
Normal file
143
client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { Tools } from 'librechat-data-provider';
|
||||
import { useState, useRef, useMemo, useLayoutEffect, useEffect } from 'react';
|
||||
import type { MemoryArtifact, TAttachment } from 'librechat-data-provider';
|
||||
import MemoryInfo from './MemoryInfo';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function MemoryArtifacts({ attachments }: { attachments?: TAttachment[] }) {
|
||||
const localize = useLocalize();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const prevShowInfoRef = useRef<boolean>(showInfo);
|
||||
|
||||
const memoryArtifacts = useMemo(() => {
|
||||
const result: MemoryArtifact[] = [];
|
||||
for (const attachment of attachments ?? []) {
|
||||
if (attachment?.[Tools.memory] != null) {
|
||||
result.push(attachment[Tools.memory]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [attachments]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (showInfo !== prevShowInfoRef.current) {
|
||||
prevShowInfoRef.current = showInfo;
|
||||
setIsAnimating(true);
|
||||
|
||||
if (showInfo && contentRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
if (contentRef.current) {
|
||||
const height = contentRef.current.scrollHeight;
|
||||
setContentHeight(height + 4);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setContentHeight(0);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (showInfo && !isAnimating) {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === contentRef.current) {
|
||||
setContentHeight(entry.contentRect.height + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(contentRef.current);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showInfo, isAnimating]);
|
||||
|
||||
if (!memoryArtifacts || memoryArtifacts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-block">
|
||||
<button
|
||||
className="outline-hidden my-1 flex items-center gap-1 text-sm font-semibold text-text-secondary-alt transition-colors hover:text-text-primary"
|
||||
type="button"
|
||||
onClick={() => setShowInfo((prev) => !prev)}
|
||||
aria-expanded={showInfo}
|
||||
aria-label={localize('com_ui_memory_updated')}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="mb-[-1px]"
|
||||
>
|
||||
<path
|
||||
d="M6 3C4.89543 3 4 3.89543 4 5V13C4 14.1046 4.89543 15 6 15L6 3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7 3V15H8.18037L8.4899 13.4523C8.54798 13.1619 8.69071 12.8952 8.90012 12.6858L12.2931 9.29289C12.7644 8.82153 13.3822 8.58583 14 8.58578V3.5C14 3.22386 13.7761 3 13.5 3H7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.3512 15.5297L9.73505 15.8529C9.38519 15.9229 9.07673 15.6144 9.14671 15.2646L9.46993 13.6484C9.48929 13.5517 9.53687 13.4628 9.60667 13.393L12.9996 10C13.5519 9.44771 14.4473 9.44771 14.9996 10C15.5519 10.5523 15.5519 11.4477 14.9996 12L11.6067 15.393C11.5369 15.4628 11.448 15.5103 11.3512 15.5297Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{localize('com_ui_memory_updated')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: showInfo ? contentHeight : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: showInfo ? 1 : 0,
|
||||
transformOrigin: 'top',
|
||||
willChange: 'height, opacity',
|
||||
perspective: '1000px',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border-light bg-surface-primary-alt shadow-md',
|
||||
showInfo && 'shadow-lg',
|
||||
)}
|
||||
style={{
|
||||
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
|
||||
opacity: showInfo ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{showInfo && <MemoryInfo key="memory-info" memoryArtifacts={memoryArtifacts} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
client/src/components/Chat/Messages/Content/MemoryInfo.tsx
Normal file
61
client/src/components/Chat/Messages/Content/MemoryInfo.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { MemoryArtifact } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) {
|
||||
const localize = useLocalize();
|
||||
if (memoryArtifacts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Group artifacts by type
|
||||
const updatedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'update');
|
||||
const deletedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'delete');
|
||||
|
||||
if (updatedMemories.length === 0 && deletedMemories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{updatedMemories.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-primary">
|
||||
{localize('com_ui_memory_updated_items')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{updatedMemories.map((artifact, index) => (
|
||||
<div key={`update-${index}`} className="rounded-lg p-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{artifact.key}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-text-primary">
|
||||
{artifact.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletedMemories.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-primary">
|
||||
{localize('com_ui_memory_deleted_items')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{deletedMemories.map((artifact, index) => (
|
||||
<div key={`delete-${index}`} className="rounded-lg p-3 opacity-60">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{artifact.key}
|
||||
</div>
|
||||
<div className="text-sm italic text-text-secondary">
|
||||
{localize('com_ui_memory_deleted')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,9 +117,9 @@ const EditTextPart = ({
|
|||
messages.map((msg) =>
|
||||
msg.messageId === messageId
|
||||
? {
|
||||
...msg,
|
||||
content: updatedContent,
|
||||
}
|
||||
...msg,
|
||||
content: updatedContent,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -178,17 +178,6 @@ export default function OpenAIImageGen({
|
|||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
||||
</div>
|
||||
|
||||
{/* {showInfo && hasInfo && (
|
||||
<ToolCallInfo
|
||||
key="tool-call-info"
|
||||
input={args ?? ''}
|
||||
output={output}
|
||||
function_name={function_name}
|
||||
pendingAuth={authDomain.length > 0 && !cancelled && initialProgress < 1}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<div className="relative mb-2 flex w-full justify-start">
|
||||
<div ref={containerRef} className="w-full max-w-lg">
|
||||
{dimensions.width !== 'auto' && progress < 1 && (
|
||||
|
|
|
|||
|
|
@ -216,18 +216,12 @@ function FeedbackButtons({
|
|||
|
||||
function buttonClasses(isActive: boolean, isLast: boolean) {
|
||||
return cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
'data-[state=open]:active data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500',
|
||||
isActive ? 'text-gray-500 dark:text-gray-200 font-bold' : 'dark:text-gray-400/70',
|
||||
'dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
isLast
|
||||
? ''
|
||||
: 'data-[state=open]:opacity-100 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100',
|
||||
'md:group-focus-within:visible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
isActive && 'active text-text-primary bg-surface-hover',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,14 +211,12 @@ export default function Fork({
|
|||
});
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
isActive && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
|
||||
isActive && 'active text-text-primary bg-surface-hover',
|
||||
);
|
||||
|
||||
const forkConvo = useForkConvoMutation({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type THoverButtons = {
|
|||
};
|
||||
|
||||
type HoverButtonProps = {
|
||||
id?: string;
|
||||
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
|
|
@ -67,6 +68,7 @@ const extractMessageContent = (message: TMessage): string => {
|
|||
|
||||
const HoverButton = memo(
|
||||
({
|
||||
id,
|
||||
onClick,
|
||||
title,
|
||||
icon,
|
||||
|
|
@ -77,26 +79,19 @@ const HoverButton = memo(
|
|||
className = '',
|
||||
}: HoverButtonProps) => {
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
|
||||
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
!isVisible && 'opacity-0',
|
||||
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
|
||||
isActive && isVisible && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
|
||||
|
||||
isActive && isVisible && 'active text-text-primary bg-surface-hover',
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
className={buttonStyle}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
|
@ -221,6 +216,7 @@ const HoverButtons = ({
|
|||
{/* Edit Button */}
|
||||
{isEditableEndpoint && (
|
||||
<HoverButton
|
||||
id={`edit-${message.messageId}`}
|
||||
onClick={onEdit}
|
||||
title={localize('com_ui_edit')}
|
||||
icon={<EditIcon size="19" />}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ function MessageAudio(props: TMessageAudio) {
|
|||
};
|
||||
|
||||
const SelectedTTS = TTSComponents[engineTTS];
|
||||
if (!SelectedTTS) {
|
||||
return null;
|
||||
}
|
||||
return <SelectedTTS {...props} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -22,57 +23,46 @@ export default function SiblingSwitch({
|
|||
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
|
||||
};
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
);
|
||||
|
||||
return siblingCount > 1 ? (
|
||||
<div className="visible flex items-center justify-center gap-1 self-center pt-0 text-xs">
|
||||
<nav
|
||||
className="visible flex items-center justify-center gap-2 self-center pt-0 text-xs"
|
||||
aria-label="Sibling message navigation"
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
)}
|
||||
className={buttonStyle}
|
||||
type="button"
|
||||
onClick={previous}
|
||||
disabled={siblingIdx == 0}
|
||||
aria-label="Previous sibling message"
|
||||
aria-disabled={siblingIdx == 0}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
<ChevronLeft size="19" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="flex-shrink-0 flex-grow tabular-nums">
|
||||
<span
|
||||
className="flex-shrink-0 flex-grow tabular-nums"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
role="status"
|
||||
>
|
||||
{siblingIdx + 1} / {siblingCount}
|
||||
</span>
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
)}
|
||||
className={buttonStyle}
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={siblingIdx == siblingCount - 1}
|
||||
aria-label="Next sibling message"
|
||||
aria-disabled={siblingIdx == siblingCount - 1}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<ChevronRight size="19" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const NavMask = memo(
|
|||
id="mobile-nav-mask-toggle"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`nav-mask transition-opacity duration-500 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`}
|
||||
className={`nav-mask transition-opacity duration-200 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`}
|
||||
onClick={toggleNavVisible}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
|
@ -186,7 +186,7 @@ const Nav = memo(
|
|||
<div
|
||||
data-testid="nav"
|
||||
className={cn(
|
||||
'nav active max-w-[320px] flex-shrink-0 transform overflow-x-hidden bg-surface-primary-alt transition-all duration-500 ease-in-out',
|
||||
'nav active max-w-[320px] flex-shrink-0 transform overflow-x-hidden bg-surface-primary-alt transition-all duration-200 ease-in-out',
|
||||
'md:max-w-[260px]',
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -197,7 +197,7 @@ const Nav = memo(
|
|||
<div className="h-full w-[320px] md:w-[260px]">
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={`flex h-full flex-col transition-opacity duration-500 ease-in-out ${navVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`flex h-full flex-col transition-opacity duration-200 ease-in-out ${navVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<nav
|
||||
|
|
|
|||
|
|
@ -5,9 +5,27 @@ import { SettingsTabValues } from 'librechat-data-provider';
|
|||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
|
||||
import { General, Chat, Speech, Beta, Commands, Data, Account, Balance } from './SettingsTabs';
|
||||
import {
|
||||
GearIcon,
|
||||
DataIcon,
|
||||
SpeechIcon,
|
||||
UserIcon,
|
||||
ExperimentIcon,
|
||||
PersonalizationIcon,
|
||||
} from '~/components/svg';
|
||||
import {
|
||||
General,
|
||||
Chat,
|
||||
Speech,
|
||||
Beta,
|
||||
Commands,
|
||||
Data,
|
||||
Account,
|
||||
Balance,
|
||||
Personalization,
|
||||
} from './SettingsTabs';
|
||||
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
|
|
@ -16,6 +34,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
const localize = useLocalize();
|
||||
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
|
||||
const tabRefs = useRef({});
|
||||
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const tabs: SettingsTabValues[] = [
|
||||
|
|
@ -24,6 +43,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
SettingsTabValues.BETA,
|
||||
SettingsTabValues.COMMANDS,
|
||||
SettingsTabValues.SPEECH,
|
||||
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
|
||||
SettingsTabValues.DATA,
|
||||
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
|
||||
SettingsTabValues.ACCOUNT,
|
||||
|
|
@ -80,6 +100,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
icon: <SpeechIcon className="icon-sm" />,
|
||||
label: 'com_nav_setting_speech',
|
||||
},
|
||||
...(hasAnyPersonalizationFeature
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.PERSONALIZATION,
|
||||
icon: <PersonalizationIcon />,
|
||||
label: 'com_nav_setting_personalization' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: SettingsTabValues.DATA,
|
||||
icon: <DataIcon />,
|
||||
|
|
@ -87,11 +116,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
},
|
||||
...(startupConfig?.balance?.enabled
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.BALANCE,
|
||||
{
|
||||
value: SettingsTabValues.BALANCE,
|
||||
icon: <DollarSign size={18} />,
|
||||
label: 'com_nav_setting_balance' as TranslationKeys,
|
||||
},
|
||||
label: 'com_nav_setting_balance' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
|
||||
{
|
||||
|
|
@ -213,6 +242,14 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<Tabs.Content value={SettingsTabValues.SPEECH}>
|
||||
<Speech />
|
||||
</Tabs.Content>
|
||||
{hasAnyPersonalizationFeature && (
|
||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
|
||||
<Personalization
|
||||
hasMemoryOptOut={hasMemoryOptOut}
|
||||
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.DATA}>
|
||||
<Data />
|
||||
</Tabs.Content>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits }) =>
|
|||
{/* Left Section: Label */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light">{localize('com_nav_balance')}</Label>
|
||||
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
|
||||
<HoverCardSettings side="bottom" text="com_nav_info_balance" />
|
||||
</div>
|
||||
|
||||
{/* Right Section: tokenCredits Value */}
|
||||
|
|
|
|||
87
client/src/components/Nav/SettingsTabs/Personalization.tsx
Normal file
87
client/src/components/Nav/SettingsTabs/Personalization.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PersonalizationProps {
|
||||
hasMemoryOptOut: boolean;
|
||||
hasAnyPersonalizationFeature: boolean;
|
||||
}
|
||||
|
||||
export default function Personalization({
|
||||
hasMemoryOptOut,
|
||||
hasAnyPersonalizationFeature,
|
||||
}: PersonalizationProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: user } = useGetUserQuery();
|
||||
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
|
||||
|
||||
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_preferences_updated'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_error_updating_preferences'),
|
||||
status: 'error',
|
||||
});
|
||||
// Revert the toggle on error
|
||||
setReferenceSavedMemories((prev) => !prev);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize state from user data
|
||||
useEffect(() => {
|
||||
if (user?.personalization?.memories !== undefined) {
|
||||
setReferenceSavedMemories(user.personalization.memories);
|
||||
}
|
||||
}, [user?.personalization?.memories]);
|
||||
|
||||
const handleMemoryToggle = (checked: boolean) => {
|
||||
setReferenceSavedMemories(checked);
|
||||
updateMemoryPreferencesMutation.mutate({ memories: checked });
|
||||
};
|
||||
|
||||
if (!hasAnyPersonalizationFeature) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<div className="text-text-secondary">{localize('com_ui_no_personalization_available')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
{/* Memory Settings Section */}
|
||||
{hasMemoryOptOut && (
|
||||
<>
|
||||
<div className="border-b border-border-medium pb-3">
|
||||
<div className="text-base font-semibold">{localize('com_ui_memory')}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{localize('com_ui_reference_saved_memories')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_reference_saved_memories_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
aria-label={localize('com_ui_reference_saved_memories')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -76,16 +76,10 @@ function Speech() {
|
|||
playbackRate: { value: playbackRate, setFunc: setPlaybackRate },
|
||||
};
|
||||
|
||||
if (
|
||||
(settings[key].value !== newValue || settings[key].value === newValue || !settings[key]) &&
|
||||
settings[key].value === 'sttExternal' &&
|
||||
settings[key].value === 'ttsExternal'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setting = settings[key];
|
||||
setting.setFunc(newValue);
|
||||
if (setting) {
|
||||
setting.setFunc(newValue);
|
||||
}
|
||||
},
|
||||
[
|
||||
sttExternal,
|
||||
|
|
@ -130,13 +124,20 @@ function Speech() {
|
|||
useEffect(() => {
|
||||
if (data && data.message !== 'not_found') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
updateSetting(key, value);
|
||||
// Only apply config values as defaults if no user preference exists in localStorage
|
||||
const existingValue = localStorage.getItem(key);
|
||||
if (existingValue === null && key !== 'sttExternal' && key !== 'ttsExternal') {
|
||||
updateSetting(key, value);
|
||||
} else if (key === 'sttExternal' || key === 'ttsExternal') {
|
||||
updateSetting(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
// Reset engineTTS if it is set to a removed/invalid value (e.g., 'edge')
|
||||
// TODO: remove this once the 'edge' engine is fully deprecated
|
||||
useEffect(() => {
|
||||
const validEngines = ['browser', 'external'];
|
||||
if (!validEngines.includes(engineTTS)) {
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ export { RevokeKeysButton } from './Data/RevokeKeysButton';
|
|||
export { default as Account } from './Account/Account';
|
||||
export { default as Balance } from './Balance/Balance';
|
||||
export { default as Speech } from './Speech/Speech';
|
||||
export { default as Personalization } from './Personalization';
|
||||
|
|
|
|||
72
client/src/components/OAuth/OAuthError.tsx
Normal file
72
client/src/components/OAuth/OAuthError.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function OAuthError() {
|
||||
const localize = useLocalize();
|
||||
const [searchParams] = useSearchParams();
|
||||
const error = searchParams.get('error') || 'unknown_error';
|
||||
|
||||
const getErrorMessage = (error: string): string => {
|
||||
switch (error) {
|
||||
case 'missing_code':
|
||||
return (
|
||||
localize('com_ui_oauth_error_missing_code') ||
|
||||
'Authorization code is missing. Please try again.'
|
||||
);
|
||||
case 'missing_state':
|
||||
return (
|
||||
localize('com_ui_oauth_error_missing_state') ||
|
||||
'State parameter is missing. Please try again.'
|
||||
);
|
||||
case 'invalid_state':
|
||||
return (
|
||||
localize('com_ui_oauth_error_invalid_state') ||
|
||||
'Invalid state parameter. Please try again.'
|
||||
);
|
||||
case 'callback_failed':
|
||||
return (
|
||||
localize('com_ui_oauth_error_callback_failed') ||
|
||||
'Authentication callback failed. Please try again.'
|
||||
);
|
||||
default:
|
||||
return localize('com_ui_oauth_error_generic') || error.replace(/_/g, ' ');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||
{localize('com_ui_oauth_error_title') || 'Authentication Failed'}
|
||||
</h1>
|
||||
<p className="mb-6 text-sm text-gray-600">{getErrorMessage(error)}</p>
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
aria-label={localize('com_ui_close_window') || 'Close Window'}
|
||||
>
|
||||
{localize('com_ui_close_window') || 'Close Window'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
client/src/components/OAuth/OAuthSuccess.tsx
Normal file
47
client/src/components/OAuth/OAuthSuccess.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function OAuthSuccess() {
|
||||
const localize = useLocalize();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [secondsLeft, setSecondsLeft] = useState(3);
|
||||
const serverName = searchParams.get('serverName');
|
||||
|
||||
useEffect(() => {
|
||||
const countdown = setInterval(() => {
|
||||
setSecondsLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(countdown);
|
||||
window.close();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(countdown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
|
||||
</h1>
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
{localize('com_ui_oauth_success_description') ||
|
||||
'Your authentication was successful. This window will close in'}{' '}
|
||||
<span className="font-medium text-indigo-500">{secondsLeft}</span>{' '}
|
||||
{localize('com_ui_seconds') || 'seconds'}.
|
||||
</p>
|
||||
{serverName && (
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
{localize('com_ui_oauth_connected_to') || 'Connected to'}:{' '}
|
||||
<span className="font-medium">{serverName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
client/src/components/OAuth/index.ts
Normal file
2
client/src/components/OAuth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as OAuthSuccess } from './OAuthSuccess';
|
||||
export { default as OAuthError } from './OAuthError';
|
||||
|
|
@ -81,7 +81,7 @@ const AdminSettings = () => {
|
|||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]?.permissions) {
|
||||
return roles[selectedRole].permissions[PermissionTypes.PROMPTS];
|
||||
return roles[selectedRole]?.permissions[PermissionTypes.PROMPTS];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS];
|
||||
}, [roles, selectedRole]);
|
||||
|
|
@ -99,11 +99,7 @@ const AdminSettings = () => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]) {
|
||||
reset(roles[selectedRole].permissions[PermissionTypes.PROMPTS]);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS]);
|
||||
}
|
||||
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]);
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,12 @@ export default function FilterPrompts({
|
|||
|
||||
const categoryOptions = categories?.length
|
||||
? [...categories]
|
||||
: [{ value: SystemCategories.NO_CATEGORY, label: localize('com_ui_no_category') }];
|
||||
: [
|
||||
{
|
||||
value: SystemCategories.NO_CATEGORY,
|
||||
label: localize('com_ui_no_category'),
|
||||
},
|
||||
];
|
||||
|
||||
return [...baseOptions, ...categoryOptions];
|
||||
}, [categories, localize]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
||||
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
||||
import { useMediaQuery, usePromptGroupsNav } from '~/hooks';
|
||||
import List from '~/components/Prompts/Groups/List';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -36,14 +37,17 @@ export default function GroupSidePanel({
|
|||
<div className="flex-grow overflow-y-auto">
|
||||
<List groups={promptGroups} isChatRoute={isChatRoute} isLoading={!!groupsQuery.isLoading} />
|
||||
</div>
|
||||
<PanelNavigation
|
||||
nextPage={nextPage}
|
||||
prevPage={prevPage}
|
||||
isFetching={isFetching}
|
||||
hasNextPage={hasNextPage}
|
||||
isChatRoute={isChatRoute}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
{isChatRoute && <ManagePrompts className="select-none" />}
|
||||
<PanelNavigation
|
||||
nextPage={nextPage}
|
||||
prevPage={prevPage}
|
||||
isFetching={isFetching}
|
||||
hasNextPage={hasNextPage}
|
||||
isChatRoute={isChatRoute}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function PanelNavigation({
|
|||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export default function VariableForm({
|
|||
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
|
||||
<ReactMarkdown
|
||||
/** @ts-ignore */
|
||||
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
|
||||
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[
|
||||
/** @ts-ignore */
|
||||
[rehypeKatex],
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
|
|||
/** @ts-ignore */
|
||||
supersub,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
rehypePlugins={[
|
||||
/** @ts-ignore */
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
|||
/** @ts-ignore */
|
||||
supersub,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
/** @ts-ignore */
|
||||
rehypePlugins={rehypePlugins}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,27 @@
|
|||
import { useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { AgentPanelProps, ActionAuthForm } from '~/common';
|
||||
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useDeleteAgentAction } from '~/data-provider';
|
||||
import type { ActionAuthForm } from '~/common';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import ActionsInput from './ActionsInput';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function ActionsPanel({
|
||||
// activePanel,
|
||||
action,
|
||||
setAction,
|
||||
agent_id,
|
||||
setActivePanel,
|
||||
}: AgentPanelProps) {
|
||||
export default function ActionsPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { setActivePanel, action, setAction, agent_id } = useAgentPanelContext();
|
||||
const deleteAgentAction = useDeleteAgentAction({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
|
|
@ -62,7 +58,7 @@ export default function ActionsPanel({
|
|||
},
|
||||
});
|
||||
|
||||
const { reset, watch } = methods;
|
||||
const { reset } = methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (action?.metadata.auth) {
|
||||
|
|
@ -128,7 +124,7 @@ export default function ActionsPanel({
|
|||
selectHandler: () => {
|
||||
if (!agent_id) {
|
||||
return showToast({
|
||||
message: 'No agent_id found, is the agent created?',
|
||||
message: localize('com_agents_no_agent_id_error'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import { QueryKeys, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
||||
import { useToastContext, useFileMapContext } from '~/Providers';
|
||||
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
|
||||
import Action from '~/components/SidePanel/Builder/Action';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
|
@ -29,23 +27,16 @@ const inputClass = cn(
|
|||
);
|
||||
|
||||
export default function AgentConfig({
|
||||
setAction,
|
||||
actions = [],
|
||||
agentsConfig,
|
||||
createMutation,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
}: AgentPanelProps) {
|
||||
const fileMap = useFileMapContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const allTools = queryClient.getQueryData<TPlugin[]>([QueryKeys.tools]) ?? [];
|
||||
const { showToast } = useToastContext();
|
||||
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const [showToolDialog, setShowToolDialog] = useState(false);
|
||||
|
||||
const fileMap = useFileMapContext();
|
||||
const { showToast } = useToastContext();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const [showToolDialog, setShowToolDialog] = useState(false);
|
||||
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
|
||||
|
||||
const { control } = methods;
|
||||
const provider = useWatch({ control, name: 'provider' });
|
||||
|
|
@ -172,6 +163,20 @@ export default function AgentConfig({
|
|||
Icon = icons[iconKey];
|
||||
}
|
||||
|
||||
// Determine what to show
|
||||
const selectedToolIds = tools ?? [];
|
||||
const visibleToolIds = new Set(selectedToolIds);
|
||||
|
||||
// Check what group parent tools should be shown if any subtool is present
|
||||
Object.entries(allTools).forEach(([toolId, toolObj]) => {
|
||||
if (toolObj.tools?.length) {
|
||||
// if any subtool of this group is selected, ensure group parent tool rendered
|
||||
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
|
||||
visibleToolIds.add(toolId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
|
||||
|
|
@ -290,28 +295,37 @@ export default function AgentConfig({
|
|||
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
|
||||
${actionsEnabled === true ? localize('com_assistants_actions') : ''}`}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{tools?.map((func, i) => (
|
||||
<AgentTool
|
||||
key={`${func}-${i}-${agent_id}`}
|
||||
tool={func}
|
||||
allTools={allTools}
|
||||
agent_id={agent_id}
|
||||
/>
|
||||
))}
|
||||
{actions
|
||||
.filter((action) => action.agent_id === agent_id)
|
||||
.map((action, i) => (
|
||||
<Action
|
||||
key={i}
|
||||
action={action}
|
||||
onClick={() => {
|
||||
setAction(action);
|
||||
setActivePanel(Panel.actions);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex space-x-2">
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
{/* // Render all visible IDs (including groups with subtools selected) */}
|
||||
{[...visibleToolIds].map((toolId, i) => {
|
||||
const tool = allTools[toolId];
|
||||
if (!tool) return null;
|
||||
return (
|
||||
<AgentTool
|
||||
key={`${toolId}-${i}-${agent_id}`}
|
||||
tool={toolId}
|
||||
allTools={allTools}
|
||||
agent_id={agent_id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(actions ?? [])
|
||||
.filter((action) => action.agent_id === agent_id)
|
||||
.map((action, i) => (
|
||||
<Action
|
||||
key={i}
|
||||
action={action}
|
||||
onClick={() => {
|
||||
setAction(action);
|
||||
setActivePanel(Panel.actions);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex space-x-2">
|
||||
{(toolsEnabled ?? false) && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -340,11 +354,12 @@ export default function AgentConfig({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* MCP Section */}
|
||||
{/* <MCPSection /> */}
|
||||
</div>
|
||||
<ToolSelectDialog
|
||||
isOpen={showToolDialog}
|
||||
setIsOpen={setShowToolDialog}
|
||||
toolsFormKey="tools"
|
||||
endpoint={EModelEndpoint.agents}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -7,20 +7,22 @@ import {
|
|||
Constants,
|
||||
SystemRoles,
|
||||
EModelEndpoint,
|
||||
TAgentsEndpoint,
|
||||
TEndpointsConfig,
|
||||
isAssistantsEndpoint,
|
||||
defaultAgentFormValues,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps, StringOption } from '~/common';
|
||||
import type { AgentForm, StringOption } from '~/common';
|
||||
import {
|
||||
useCreateAgentMutation,
|
||||
useUpdateAgentMutation,
|
||||
useGetAgentByIdQuery,
|
||||
} from '~/data-provider';
|
||||
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import AgentPanelSkeleton from './AgentPanelSkeleton';
|
||||
import { createProviderOption } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import AdvancedPanel from './Advanced/AdvancedPanel';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import AgentConfig from './AgentConfig';
|
||||
import AgentSelect from './AgentSelect';
|
||||
import AgentFooter from './AgentFooter';
|
||||
|
|
@ -29,18 +31,21 @@ import ModelPanel from './ModelPanel';
|
|||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanel({
|
||||
setAction,
|
||||
activePanel,
|
||||
actions = [],
|
||||
setActivePanel,
|
||||
agent_id: current_agent_id,
|
||||
setCurrentAgentId,
|
||||
agentsConfig,
|
||||
endpointsConfig,
|
||||
}: AgentPanelProps) {
|
||||
}: {
|
||||
agentsConfig: TAgentsEndpoint | null;
|
||||
endpointsConfig: TEndpointsConfig;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const {
|
||||
activePanel,
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id: current_agent_id,
|
||||
} = useAgentPanelContext();
|
||||
|
||||
const { onSelect: onSelectAgent } = useSelectAgent();
|
||||
|
||||
|
|
@ -51,7 +56,7 @@ export default function AgentPanel({
|
|||
|
||||
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
|
||||
const methods = useForm<AgentForm>({
|
||||
defaultValues: defaultAgentFormValues,
|
||||
defaultValues: getDefaultAgentFormValues(),
|
||||
});
|
||||
|
||||
const { control, handleSubmit, reset } = methods;
|
||||
|
|
@ -277,7 +282,7 @@ export default function AgentPanel({
|
|||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
reset(defaultAgentFormValues);
|
||||
reset(getDefaultAgentFormValues());
|
||||
setCurrentAgentId(undefined);
|
||||
}}
|
||||
disabled={agentQuery.isInitialLoading}
|
||||
|
|
@ -315,22 +320,13 @@ export default function AgentPanel({
|
|||
</div>
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.model && (
|
||||
<ModelPanel
|
||||
setActivePanel={setActivePanel}
|
||||
agent_id={agent_id}
|
||||
providers={providers}
|
||||
models={models}
|
||||
/>
|
||||
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
|
||||
<AgentConfig
|
||||
actions={actions}
|
||||
setAction={setAction}
|
||||
createMutation={create}
|
||||
agentsConfig={agentsConfig}
|
||||
setActivePanel={setActivePanel}
|
||||
endpointsConfig={endpointsConfig}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
/>
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { ActionsEndpoint } from '~/common';
|
||||
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
|
||||
import type { TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import VersionPanel from './Version/VersionPanel';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import ActionsPanel from './ActionsPanel';
|
||||
import AgentPanel from './AgentPanel';
|
||||
import VersionPanel from './Version/VersionPanel';
|
||||
import MCPPanel from './MCPPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanelSwitch() {
|
||||
const { conversation, index } = useChatContext();
|
||||
const [activePanel, setActivePanel] = useState(Panel.builder);
|
||||
const [action, setAction] = useState<Action | undefined>(undefined);
|
||||
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
|
||||
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
|
||||
return (
|
||||
<AgentPanelProvider>
|
||||
<AgentPanelSwitchWithContext />
|
||||
</AgentPanelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPanelSwitchWithContext() {
|
||||
const { conversation } = useChatContext();
|
||||
const { activePanel, setCurrentAgentId } = useAgentPanelContext();
|
||||
|
||||
// TODO: Implement MCP endpoint
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const createMutation = useCreateAgentMutation();
|
||||
|
||||
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||
|
|
@ -35,39 +42,20 @@ export default function AgentPanelSwitch() {
|
|||
if (agent_id) {
|
||||
setCurrentAgentId(agent_id);
|
||||
}
|
||||
}, [conversation?.agent_id]);
|
||||
}, [setCurrentAgentId, conversation?.agent_id]);
|
||||
|
||||
if (!conversation?.endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
index,
|
||||
action,
|
||||
actions,
|
||||
setAction,
|
||||
activePanel,
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id: currentAgentId,
|
||||
createMutation,
|
||||
};
|
||||
|
||||
if (activePanel === Panel.actions) {
|
||||
return <ActionsPanel {...commonProps} />;
|
||||
return <ActionsPanel />;
|
||||
}
|
||||
|
||||
if (activePanel === Panel.version) {
|
||||
return (
|
||||
<VersionPanel
|
||||
setActivePanel={setActivePanel}
|
||||
agentsConfig={agentsConfig}
|
||||
selectedAgentId={currentAgentId}
|
||||
/>
|
||||
);
|
||||
return <VersionPanel />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
|
||||
);
|
||||
if (activePanel === Panel.mcp) {
|
||||
return <MCPPanel />;
|
||||
}
|
||||
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provid
|
|||
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
|
||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||
import type { TAgentCapabilities, AgentForm } from '~/common';
|
||||
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import { cn, createProviderOption, processAgentOption } from '~/utils';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -32,7 +32,10 @@ export default function AgentSelect({
|
|||
select: (res) =>
|
||||
res.data.map((agent) =>
|
||||
processAgentOption({
|
||||
agent,
|
||||
agent: {
|
||||
...agent,
|
||||
name: agent.name || agent.id,
|
||||
},
|
||||
instanceProjectId: startupConfig?.instanceProjectId,
|
||||
}),
|
||||
),
|
||||
|
|
@ -124,9 +127,7 @@ export default function AgentSelect({
|
|||
createMutation.reset();
|
||||
if (!agentExists) {
|
||||
setCurrentAgentId(undefined);
|
||||
return reset({
|
||||
...defaultAgentFormValues,
|
||||
});
|
||||
return reset(getDefaultAgentFormValues());
|
||||
}
|
||||
|
||||
setCurrentAgentId(selectedId);
|
||||
|
|
@ -179,7 +180,7 @@ export default function AgentSelect({
|
|||
containerClassName="px-0"
|
||||
selectedValue={(field?.value?.value ?? '') + ''}
|
||||
displayValue={field?.value?.label ?? ''}
|
||||
selectPlaceholder={createAgent}
|
||||
selectPlaceholder={field?.value?.value ?? createAgent}
|
||||
iconSide="right"
|
||||
searchPlaceholder={localize('com_agents_search_name')}
|
||||
SelectIcon={field?.value?.icon}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,69 @@
|
|||
import React, { useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||
import type { AgentToolType } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
|
||||
import { OGDialog, OGDialogTrigger, Label, Checkbox } from '~/components/ui';
|
||||
import { TrashIcon, CircleHelpIcon } from '~/components/svg';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function AgentTool({
|
||||
tool,
|
||||
allTools,
|
||||
agent_id = '',
|
||||
}: {
|
||||
tool: string;
|
||||
allTools: TPlugin[];
|
||||
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
|
||||
agent_id?: string;
|
||||
}) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
|
||||
const [accordionValue, setAccordionValue] = useState<string>('');
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { getValues, setValue } = useFormContext();
|
||||
const currentTool = allTools.find((t) => t.pluginKey === tool);
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const currentTool = allTools[tool];
|
||||
|
||||
const getSelectedTools = () => {
|
||||
if (!currentTool?.tools) return [];
|
||||
const formTools = getValues('tools') || [];
|
||||
return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
|
||||
};
|
||||
|
||||
const updateFormTools = (newSelectedTools: string[]) => {
|
||||
const currentTools = getValues('tools') || [];
|
||||
const otherTools = currentTools.filter(
|
||||
(t: string) => !currentTool?.tools?.some((st) => st.tool_id === t),
|
||||
);
|
||||
setValue('tools', [...otherTools, ...newSelectedTools]);
|
||||
};
|
||||
|
||||
const removeTool = (toolId: string) => {
|
||||
if (toolId) {
|
||||
const toolIdsToRemove =
|
||||
isGroup && currentTool.tools
|
||||
? [toolId, ...currentTool.tools.map((t) => t.tool_id)]
|
||||
: [toolId];
|
||||
|
||||
const removeTool = (tool: string) => {
|
||||
if (tool) {
|
||||
updateUserPlugins.mutate(
|
||||
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
|
||||
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
const tools = getValues('tools').filter((fn: string) => fn !== tool);
|
||||
setValue('tools', tools);
|
||||
const remainingToolIds = getValues('tools')?.filter(
|
||||
(toolId: string) => !toolIdsToRemove.includes(toolId),
|
||||
);
|
||||
setValue('tools', remainingToolIds);
|
||||
showToast({ message: 'Tool deleted successfully', status: 'success' });
|
||||
},
|
||||
},
|
||||
|
|
@ -47,41 +75,309 @@ export default function AgentTool({
|
|||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<div
|
||||
className={cn('flex w-full items-center rounded-lg text-sm', !agent_id ? 'opacity-40' : '')}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
{currentTool.icon && (
|
||||
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="h-9 grow px-3 py-2"
|
||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||
>
|
||||
{currentTool.name}
|
||||
</div>
|
||||
</div>
|
||||
const isGroup = currentTool.tools && currentTool.tools.length > 0;
|
||||
const selectedTools = getSelectedTools();
|
||||
const isExpanded = accordionValue === currentTool.tool_id;
|
||||
|
||||
if (!isGroup) {
|
||||
return (
|
||||
<OGDialog>
|
||||
<div
|
||||
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={(e) => {
|
||||
// Check if focus is moving to a child element
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
{currentTool.metadata.icon && (
|
||||
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||
style={{
|
||||
backgroundImage: `url(${currentTool.metadata.icon})`,
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="grow px-2 py-1.5"
|
||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||
>
|
||||
{currentTool.metadata.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHovering && (
|
||||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
|
||||
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||
'focus:opacity-100',
|
||||
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-label={`Delete ${currentTool.metadata.name}`}
|
||||
tabIndex={0}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
>
|
||||
<TrashIcon />
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_tool')}
|
||||
mainClassName="px-0"
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_tool_confirm')}
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => removeTool(currentTool.tool_id),
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Group tool with accordion
|
||||
return (
|
||||
<OGDialog>
|
||||
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
|
||||
<AccordionItem value={currentTool.tool_id} className="group relative w-full border-none">
|
||||
<div
|
||||
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={(e) => {
|
||||
// Check if focus is moving to a child element
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionPrimitive.Header asChild>
|
||||
<AccordionPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex grow items-center gap-1 rounded bg-transparent p-0 text-left transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||
)}
|
||||
>
|
||||
{currentTool.metadata.icon && (
|
||||
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||
style={{
|
||||
backgroundImage: `url(${currentTool.metadata.icon})`,
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="grow px-2 py-1.5"
|
||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||
>
|
||||
{currentTool.metadata.name}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Container for grouped checkbox and chevron */}
|
||||
<div className="relative flex items-center">
|
||||
{/* Grouped checkbox and chevron that slide together */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 transition-all duration-300',
|
||||
isHovering || isFocused ? '-translate-x-8' : 'translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-checkbox-container
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-1"
|
||||
>
|
||||
<Checkbox
|
||||
id={`select-all-${currentTool.tool_id}`}
|
||||
checked={selectedTools.length === currentTool.tools?.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (currentTool.tools) {
|
||||
const newSelectedTools = checked
|
||||
? currentTool.tools.map((t) => t.tool_id)
|
||||
: [];
|
||||
updateFormTools(newSelectedTools);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
|
||||
isExpanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const checkbox = e.currentTarget as HTMLButtonElement;
|
||||
checkbox.click();
|
||||
}
|
||||
}}
|
||||
tabIndex={isExpanded ? 0 : -1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none flex h-4 w-4 items-center justify-center transition-transform duration-300',
|
||||
isExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button slides in from behind */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 transition-all duration-300',
|
||||
isHovering || isFocused
|
||||
? 'translate-x-0 opacity-100'
|
||||
: 'translate-x-8 opacity-0',
|
||||
)}
|
||||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
||||
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||
'focus:translate-x-0 focus:opacity-100',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Delete ${currentTool.metadata.name}`}
|
||||
tabIndex={0}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
</div>
|
||||
|
||||
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
|
||||
<div className="space-y-1">
|
||||
{currentTool.tools?.map((subTool) => (
|
||||
<label
|
||||
key={subTool.tool_id}
|
||||
htmlFor={subTool.tool_id}
|
||||
className={cn(
|
||||
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
|
||||
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
|
||||
onMouseLeave={() => setHoveredToolId(null)}
|
||||
>
|
||||
<Checkbox
|
||||
id={subTool.tool_id}
|
||||
checked={selectedTools.includes(subTool.tool_id)}
|
||||
onCheckedChange={(_checked) => {
|
||||
const newSelectedTools = selectedTools.includes(subTool.tool_id)
|
||||
? selectedTools.filter((t) => t !== subTool.tool_id)
|
||||
: [...selectedTools, subTool.tool_id];
|
||||
updateFormTools(newSelectedTools);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const checkbox = e.currentTarget as HTMLButtonElement;
|
||||
checkbox.click();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-gray-300 transition-[border-color] duration-200 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background dark:border-gray-600 dark:hover:border-gray-500"
|
||||
/>
|
||||
<span className="text-token-text-primary">{subTool.metadata.name}</span>
|
||||
{subTool.metadata.description && (
|
||||
<Ariakit.HovercardProvider placement="left-start">
|
||||
<div className="ml-auto flex h-6 w-6 items-center justify-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<Ariakit.Button
|
||||
className={cn(
|
||||
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
|
||||
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
aria-label={localize('com_ui_tool_info')}
|
||||
>
|
||||
<CircleHelpIcon className="h-4 w-4" />
|
||||
<Ariakit.VisuallyHidden>
|
||||
{localize('com_ui_tool_info')}
|
||||
</Ariakit.VisuallyHidden>
|
||||
</Ariakit.Button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure
|
||||
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label={localize('com_ui_tool_more_info')}
|
||||
aria-expanded={hoveredToolId === subTool.tool_id}
|
||||
aria-controls={`tool-description-${subTool.tool_id}`}
|
||||
>
|
||||
<Ariakit.VisuallyHidden>
|
||||
{localize('com_ui_tool_more_info')}
|
||||
</Ariakit.VisuallyHidden>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
id={`tool-description-${subTool.tool_id}`}
|
||||
gutter={14}
|
||||
shift={40}
|
||||
flip={false}
|
||||
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
role="tooltip"
|
||||
aria-label={subTool.metadata.description}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{subTool.metadata.description}
|
||||
</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_tool')}
|
||||
|
|
@ -93,7 +389,7 @@ export default function AgentTool({
|
|||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => removeTool(currentTool.pluginKey),
|
||||
selectHandler: () => removeTool(currentTool.tool_id),
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { defaultAgentFormValues } from 'librechat-data-provider';
|
||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import { cn, logger, removeFocusOutlines, getDefaultAgentFormValues } from '~/utils';
|
||||
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||
import { useChatContext, useToastContext } from '~/Providers';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useChatContext, useToastContext } from '~/Providers';
|
||||
import { useLocalize, useSetIndexOptions } from '~/hooks';
|
||||
import { cn, removeFocusOutlines, logger } from '~/utils';
|
||||
import { useDeleteAgentMutation } from '~/data-provider';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
|
||||
|
|
@ -45,9 +44,7 @@ export default function DeleteButton({
|
|||
const firstAgent = updatedList[0] as Agent | undefined;
|
||||
if (!firstAgent) {
|
||||
setCurrentAgentId(undefined);
|
||||
reset({
|
||||
...defaultAgentFormValues,
|
||||
});
|
||||
reset(getDefaultAgentFormValues());
|
||||
return setOption('agent_id')('');
|
||||
}
|
||||
|
||||
|
|
|
|||
64
client/src/components/SidePanel/Agents/MCPIcon.tsx
Normal file
64
client/src/components/SidePanel/Agents/MCPIcon.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import SquirclePlusIcon from '~/components/svg/SquirclePlusIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface MCPIconProps {
|
||||
icon?: string;
|
||||
onIconChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const localize = useLocalize();
|
||||
|
||||
useEffect(() => {
|
||||
if (icon) {
|
||||
setPreviewUrl(icon);
|
||||
} else {
|
||||
setPreviewUrl('');
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-[1.5rem] border-2 border-dashed"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
className="h-full w-full rounded-[1.5rem] object-cover"
|
||||
alt="MCP Icon"
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
) : (
|
||||
<SquirclePlusIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="token-text-secondary text-sm">
|
||||
{localize('com_ui_icon')} {localize('com_ui_optional')}
|
||||
</span>
|
||||
<span className="token-text-tertiary text-xs">{localize('com_agents_mcp_icon_size')}</span>
|
||||
</div>
|
||||
<input
|
||||
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
|
||||
multiple={false}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onIconChange}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
client/src/components/SidePanel/Agents/MCPInput.tsx
Normal file
288
client/src/components/SidePanel/Agents/MCPInput.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { MCP } from 'librechat-data-provider/dist/types/types/assistants';
|
||||
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
|
||||
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
|
||||
import { Label, Checkbox } from '~/components/ui';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { MCPForm } from '~/common/types';
|
||||
|
||||
function useUpdateAgentMCP({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: (data: [string, MCP]) => void;
|
||||
onError: (error: Error) => void;
|
||||
}) {
|
||||
return {
|
||||
mutate: async ({
|
||||
mcp_id,
|
||||
metadata,
|
||||
agent_id,
|
||||
}: {
|
||||
mcp_id?: string;
|
||||
metadata: MCP['metadata'];
|
||||
agent_id: string;
|
||||
}) => {
|
||||
try {
|
||||
// TODO: Implement MCP endpoint
|
||||
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface MCPInputProps {
|
||||
mcp?: MCP;
|
||||
agent_id?: string;
|
||||
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>;
|
||||
}
|
||||
|
||||
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = useFormContext<MCPForm>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<string[]>([]);
|
||||
|
||||
// Initialize tools list if editing existing MCP
|
||||
useEffect(() => {
|
||||
if (mcp?.mcp_id && mcp.metadata.tools) {
|
||||
setShowTools(true);
|
||||
setSelectedTools(mcp.metadata.tools);
|
||||
}
|
||||
}, [mcp]);
|
||||
|
||||
const updateAgentMCP = useUpdateAgentMCP({
|
||||
onSuccess(data) {
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
setMCP(data[1]);
|
||||
setShowTools(true);
|
||||
setSelectedTools(data[1].metadata.tools ?? []);
|
||||
setIsLoading(false);
|
||||
},
|
||||
onError(error) {
|
||||
showToast({
|
||||
message: (error as Error).message || localize('com_ui_update_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const saveMCP = handleSubmit(async (data: MCPForm) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await updateAgentMCP.mutate({
|
||||
agent_id: agent_id ?? '',
|
||||
mcp_id: mcp?.mcp_id,
|
||||
metadata: {
|
||||
...data,
|
||||
tools: selectedTools,
|
||||
},
|
||||
});
|
||||
setMCP(response[1]);
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
} catch {
|
||||
showToast({
|
||||
message: localize('com_ui_update_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (mcp?.metadata.tools) {
|
||||
setSelectedTools(mcp.metadata.tools);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedTools([]);
|
||||
};
|
||||
|
||||
const handleToolToggle = (tool: string) => {
|
||||
setSelectedTools((prev) =>
|
||||
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool],
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
if (selectedTools.length === mcp?.metadata.tools?.length) {
|
||||
handleDeselectAll();
|
||||
} else {
|
||||
handleSelectAll();
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64String = reader.result as string;
|
||||
setMCP({
|
||||
mcp_id: mcp?.mcp_id ?? '',
|
||||
agent_id: agent_id ?? '',
|
||||
metadata: {
|
||||
...mcp?.metadata,
|
||||
icon: base64String,
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Icon Picker */}
|
||||
<div className="mb-4">
|
||||
<MCPIcon icon={mcp?.metadata.icon} onIconChange={handleIconChange} />
|
||||
</div>
|
||||
{/* name, description, url */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="name">{localize('com_ui_name')}</Label>
|
||||
<input
|
||||
id="name"
|
||||
{...register('name', { required: true })}
|
||||
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
|
||||
placeholder={localize('com_agents_mcp_name_placeholder')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="description">
|
||||
{localize('com_ui_description')}
|
||||
<span className="ml-1 text-xs text-text-secondary-alt">
|
||||
{localize('com_ui_optional')}
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
id="description"
|
||||
{...register('description')}
|
||||
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
|
||||
placeholder={localize('com_agents_mcp_description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="url">{localize('com_ui_mcp_url')}</Label>
|
||||
<input
|
||||
id="url"
|
||||
{...register('url', {
|
||||
required: true,
|
||||
})}
|
||||
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
|
||||
placeholder={'https://mcp.example.com'}
|
||||
/>
|
||||
{errors.url && (
|
||||
<span className="text-xs text-red-500">
|
||||
{errors.url.type === 'required'
|
||||
? localize('com_ui_field_required')
|
||||
: errors.url.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MCPAuth />
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<Controller
|
||||
name="trust"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<Label htmlFor="trust" className="flex flex-col">
|
||||
{localize('com_ui_trust_app')}
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_agents_mcp_trust_subtext')}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.trust && (
|
||||
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
onClick={saveMCP}
|
||||
disabled={isLoading}
|
||||
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
|
||||
type="button"
|
||||
>
|
||||
{(() => {
|
||||
if (isLoading) {
|
||||
return <Spinner className="icon-md" />;
|
||||
}
|
||||
return mcp?.mcp_id ? localize('com_ui_update') : localize('com_ui_create');
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTools && mcp?.metadata.tools && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-token-text-primary block font-medium">
|
||||
{localize('com_ui_available_tools')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
type="button"
|
||||
className="btn btn-neutral border-token-border-light relative h-8 rounded-full px-4 font-medium"
|
||||
>
|
||||
{selectedTools.length === mcp.metadata.tools.length
|
||||
? localize('com_ui_deselect_all')
|
||||
: localize('com_ui_select_all')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mcp.metadata.tools.map((tool) => (
|
||||
<label
|
||||
key={tool}
|
||||
htmlFor={tool}
|
||||
className="border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={tool}
|
||||
checked={selectedTools.includes(tool)}
|
||||
onCheckedChange={() => handleToolToggle(tool)}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-token-text-primary">
|
||||
{tool
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
client/src/components/SidePanel/Agents/MCPPanel.tsx
Normal file
172
client/src/components/SidePanel/Agents/MCPPanel.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { defaultMCPFormValues } from '~/common/mcp';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import type { MCPForm } from '~/common';
|
||||
import MCPInput from './MCPInput';
|
||||
import { Panel } from '~/common';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
} from 'librechat-data-provider';
|
||||
// TODO: Add MCP delete (for now mocked for ui)
|
||||
// import { useDeleteAgentMCP } from '~/data-provider';
|
||||
|
||||
function useDeleteAgentMCP({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}) {
|
||||
return {
|
||||
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
|
||||
try {
|
||||
console.log('Mock delete MCP:', { mcp_id, agent_id });
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function MCPPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
|
||||
const deleteAgentMCP = useDeleteAgentMCP({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_delete_mcp_success'),
|
||||
status: 'success',
|
||||
});
|
||||
setActivePanel(Panel.builder);
|
||||
setMcp(undefined);
|
||||
},
|
||||
onError(error) {
|
||||
showToast({
|
||||
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const methods = useForm<MCPForm>({
|
||||
defaultValues: defaultMCPFormValues,
|
||||
});
|
||||
|
||||
const { reset } = methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (mcp) {
|
||||
const formData = {
|
||||
icon: mcp.metadata.icon ?? '',
|
||||
name: mcp.metadata.name ?? '',
|
||||
description: mcp.metadata.description ?? '',
|
||||
url: mcp.metadata.url ?? '',
|
||||
tools: mcp.metadata.tools ?? [],
|
||||
trust: mcp.metadata.trust ?? false,
|
||||
};
|
||||
|
||||
if (mcp.metadata.auth) {
|
||||
Object.assign(formData, {
|
||||
type: mcp.metadata.auth.type || AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
api_key: mcp.metadata.api_key ?? '',
|
||||
authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
|
||||
oauth_client_id: mcp.metadata.oauth_client_id ?? '',
|
||||
oauth_client_secret: mcp.metadata.oauth_client_secret ?? '',
|
||||
authorization_url: mcp.metadata.auth.authorization_url ?? '',
|
||||
client_url: mcp.metadata.auth.client_url ?? '',
|
||||
scope: mcp.metadata.auth.scope ?? '',
|
||||
token_exchange_method:
|
||||
mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
|
||||
});
|
||||
}
|
||||
|
||||
reset(formData);
|
||||
}
|
||||
}, [mcp, reset]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className="h-full grow overflow-hidden">
|
||||
<div className="h-full overflow-auto px-2 pb-12 text-sm">
|
||||
<div className="relative flex flex-col items-center px-16 py-6 text-center">
|
||||
<div className="absolute left-0 top-6">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => {
|
||||
setActivePanel(Panel.builder);
|
||||
setMcp(undefined);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<ChevronLeft />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!!mcp && (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<div className="absolute right-0 top-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!agent_id || !mcp.mcp_id}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
|
||||
>
|
||||
<TrashIcon className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_mcp')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_mcp_confirm')}
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => {
|
||||
if (!agent_id) {
|
||||
return showToast({
|
||||
message: localize('com_agents_no_agent_id_error'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
deleteAgentMCP.mutate({
|
||||
mcp_id: mcp.mcp_id,
|
||||
agent_id,
|
||||
});
|
||||
},
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
)}
|
||||
|
||||
<div className="text-xl font-medium">
|
||||
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
|
||||
</div>
|
||||
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
57
client/src/components/SidePanel/Agents/MCPSection.tsx
Normal file
57
client/src/components/SidePanel/Agents/MCPSection.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import MCP from '~/components/SidePanel/Builder/MCP';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function MCPSection() {
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
const { mcps = [], agent_id, setMcp, setActivePanel } = useAgentPanelContext();
|
||||
|
||||
const handleAddMCP = useCallback(() => {
|
||||
if (!agent_id) {
|
||||
showToast({
|
||||
message: localize('com_agents_mcps_disabled'),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActivePanel(Panel.mcp);
|
||||
}, [agent_id, setActivePanel, showToast, localize]);
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="text-token-text-primary mb-2 block font-medium">
|
||||
{localize('com_ui_mcp_servers')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{mcps
|
||||
.filter((mcp) => mcp.agent_id === agent_id)
|
||||
.map((mcp, i) => (
|
||||
<MCP
|
||||
key={i}
|
||||
mcp={mcp}
|
||||
onClick={() => {
|
||||
setMcp(mcp);
|
||||
setActivePanel(Panel.mcp);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMCP}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{localize('com_ui_add_mcp')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +1,28 @@
|
|||
import keyBy from 'lodash/keyBy';
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft, RotateCcw } from 'lucide-react';
|
||||
import { useFormContext, useWatch, Controller } from 'react-hook-form';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import {
|
||||
alternateName,
|
||||
getSettingsKeys,
|
||||
LocalStorageKeys,
|
||||
SettingDefinition,
|
||||
agentParamSettings,
|
||||
} from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
import keyBy from 'lodash/keyBy';
|
||||
|
||||
export default function ModelPanel({
|
||||
setActivePanel,
|
||||
providers,
|
||||
setActivePanel,
|
||||
models: modelsData,
|
||||
}: AgentModelPanelProps) {
|
||||
}: Pick<AgentModelPanelProps, 'models' | 'providers' | 'setActivePanel'>) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const { control, setValue } = useFormContext<AgentForm>();
|
||||
|
|
@ -50,6 +51,8 @@ export default function ModelPanel({
|
|||
const newModels = modelsData[provider] ?? [];
|
||||
setValue('model', newModels[0] ?? '');
|
||||
}
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_MODEL, _model);
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_PROVIDER, provider);
|
||||
}
|
||||
|
||||
if (provider && !_model) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import type { Agent, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AgentPanelProps } from '~/common';
|
||||
import { Panel } from '~/common';
|
||||
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import { isActiveVersion } from './isActiveVersion';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { useLocalize, useToast } from '~/hooks';
|
||||
import VersionContent from './VersionContent';
|
||||
import { isActiveVersion } from './isActiveVersion';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export type VersionRecord = Record<string, any>;
|
||||
|
||||
|
|
@ -39,15 +39,13 @@ export interface AgentWithVersions extends Agent {
|
|||
versions?: Array<VersionRecord>;
|
||||
}
|
||||
|
||||
export type VersionPanelProps = {
|
||||
agentsConfig: TAgentsEndpoint | null;
|
||||
setActivePanel: AgentPanelProps['setActivePanel'];
|
||||
selectedAgentId?: string;
|
||||
};
|
||||
|
||||
export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) {
|
||||
export default function VersionPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToast();
|
||||
const { agent_id, setActivePanel } = useAgentPanelContext();
|
||||
|
||||
const selectedAgentId = agent_id ?? '';
|
||||
|
||||
const {
|
||||
data: agent,
|
||||
isLoading,
|
||||
|
|
|
|||
|
|
@ -55,13 +55,18 @@ jest.mock('~/hooks', () => ({
|
|||
useToast: jest.fn(() => ({ showToast: jest.fn() })),
|
||||
}));
|
||||
|
||||
// Mock the AgentPanelContext
|
||||
jest.mock('~/Providers/AgentPanelContext', () => ({
|
||||
...jest.requireActual('~/Providers/AgentPanelContext'),
|
||||
useAgentPanelContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('VersionPanel', () => {
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const defaultProps = {
|
||||
agentsConfig: null,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
selectedAgentId: 'agent-123',
|
||||
};
|
||||
const mockUseAgentPanelContext = jest.requireMock(
|
||||
'~/Providers/AgentPanelContext',
|
||||
).useAgentPanelContext;
|
||||
|
||||
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -72,10 +77,17 @@ describe('VersionPanel', () => {
|
|||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
// Set up the default context mock
|
||||
mockUseAgentPanelContext.mockReturnValue({
|
||||
setActivePanel: mockSetActivePanel,
|
||||
agent_id: 'agent-123',
|
||||
activePanel: Panel.version,
|
||||
});
|
||||
});
|
||||
|
||||
test('renders panel UI and handles navigation', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
render(<VersionPanel />);
|
||||
expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-content')).toBeInTheDocument();
|
||||
|
||||
|
|
@ -84,7 +96,7 @@ describe('VersionPanel', () => {
|
|||
});
|
||||
|
||||
test('VersionContent receives correct props', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selectedAgentId: 'agent-123',
|
||||
|
|
@ -101,19 +113,31 @@ describe('VersionPanel', () => {
|
|||
});
|
||||
|
||||
test('handles data state variations', () => {
|
||||
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
|
||||
// Test with empty agent_id
|
||||
mockUseAgentPanelContext.mockReturnValueOnce({
|
||||
setActivePanel: mockSetActivePanel,
|
||||
agent_id: '',
|
||||
activePanel: Panel.version,
|
||||
});
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selectedAgentId: '' }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Test with null data
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
mockUseAgentPanelContext.mockReturnValueOnce({
|
||||
setActivePanel: mockSetActivePanel,
|
||||
agent_id: 'agent-123',
|
||||
activePanel: Panel.version,
|
||||
});
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
|
|
@ -125,13 +149,14 @@ describe('VersionPanel', () => {
|
|||
expect.anything(),
|
||||
);
|
||||
|
||||
// 3. versions is undefined
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: { ...mockAgentData, versions: undefined },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({ versions: [] }),
|
||||
|
|
@ -139,18 +164,20 @@ describe('VersionPanel', () => {
|
|||
expect.anything(),
|
||||
);
|
||||
|
||||
// 4. loading state
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isLoading: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// 5. error state
|
||||
const testError = new Error('Test error');
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
|
|
@ -158,7 +185,7 @@ describe('VersionPanel', () => {
|
|||
error: testError,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: testError }),
|
||||
expect.anything(),
|
||||
|
|
@ -173,7 +200,7 @@ describe('VersionPanel', () => {
|
|||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
render(<VersionPanel />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -80,13 +80,13 @@ const BookmarkTable = () => {
|
|||
<TableHeader>
|
||||
<TableRow className="border-b border-border-light">
|
||||
<TableHead className="w-[70%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div className="px-4">{localize('com_ui_bookmarks_title')}</div>
|
||||
<div>{localize('com_ui_bookmarks_title')}</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div className="px-4">{localize('com_ui_bookmarks_count')}</div>
|
||||
<div>{localize('com_ui_bookmarks_count')}</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[40%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div className="px-4">{localize('com_assistants_actions')}</div>
|
||||
<div>{localize('com_assistants_actions')}</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, posit
|
|||
mutation.mutate(
|
||||
{ ...row, position: item.index },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_update_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_update_error'),
|
||||
|
|
@ -44,7 +50,9 @@ const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, posit
|
|||
accept: 'bookmark',
|
||||
drop: handleDrop,
|
||||
hover(item: DragItem) {
|
||||
if (!ref.current || item.index === position) {return;}
|
||||
if (!ref.current || item.index === position) {
|
||||
return;
|
||||
}
|
||||
moveRow(item.index, position);
|
||||
item.index = position;
|
||||
},
|
||||
|
|
|
|||
60
client/src/components/SidePanel/Builder/MCP.tsx
Normal file
60
client/src/components/SidePanel/Builder/MCP.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useState } from 'react';
|
||||
import type { MCP } from 'librechat-data-provider';
|
||||
import GearIcon from '~/components/svg/GearIcon';
|
||||
import MCPIcon from '~/components/svg/MCPIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type MCPProps = {
|
||||
mcp: MCP;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function MCP({ mcp, onClick }: MCPProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className="group flex w-full rounded-lg border border-border-medium text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-text-primary"
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
aria-label={`MCP for ${mcp.metadata.name}`}
|
||||
>
|
||||
<div className="flex h-9 items-center gap-2 px-3">
|
||||
{mcp.metadata.icon ? (
|
||||
<img
|
||||
src={mcp.metadata.icon}
|
||||
alt={`${mcp.metadata.name} icon`}
|
||||
className="h-6 w-6 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-surface-secondary">
|
||||
<MCPIcon />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="grow overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{mcp.metadata.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-auto h-9 w-9 min-w-9 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-text-primary group-focus:flex',
|
||||
isHovering ? 'flex' : 'hidden',
|
||||
)}
|
||||
aria-label="Settings"
|
||||
>
|
||||
<GearIcon className="icon-sm" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
client/src/components/SidePanel/Builder/MCPAuth.tsx
Normal file
55
client/src/components/SidePanel/Builder/MCPAuth.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
|
||||
import {
|
||||
AuthorizationTypeEnum,
|
||||
TokenExchangeMethodEnum,
|
||||
AuthTypeEnum,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
export default function MCPAuth() {
|
||||
// Create a separate form for auth
|
||||
const authMethods = useForm({
|
||||
defaultValues: {
|
||||
/* General */
|
||||
type: AuthTypeEnum.None,
|
||||
saved_auth_fields: false,
|
||||
/* API key */
|
||||
api_key: '',
|
||||
authorization_type: AuthorizationTypeEnum.Basic,
|
||||
custom_auth_header: '',
|
||||
/* OAuth */
|
||||
oauth_client_id: '',
|
||||
oauth_client_secret: '',
|
||||
authorization_url: '',
|
||||
client_url: '',
|
||||
scope: '',
|
||||
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
|
||||
},
|
||||
});
|
||||
|
||||
const { watch, setValue } = authMethods;
|
||||
const type = watch('type');
|
||||
|
||||
// Sync form state when auth type changes
|
||||
useEffect(() => {
|
||||
if (type === 'none') {
|
||||
// Reset auth fields when type is none
|
||||
setValue('api_key', '');
|
||||
setValue('authorization_type', AuthorizationTypeEnum.Basic);
|
||||
setValue('custom_auth_header', '');
|
||||
setValue('oauth_client_id', '');
|
||||
setValue('oauth_client_secret', '');
|
||||
setValue('authorization_url', '');
|
||||
setValue('client_url', '');
|
||||
setValue('scope', '');
|
||||
setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost);
|
||||
}
|
||||
}, [type, setValue]);
|
||||
|
||||
return (
|
||||
<FormProvider {...authMethods}>
|
||||
<ActionsAuth />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||
import { Button, Input, Label } from '~/components/ui';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ServerConfigWithVars {
|
||||
serverName: string;
|
||||
config: {
|
||||
customUserVars: Record<string, { title: string; description: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function MCPPanel() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const mcpServerDefinitions = useMemo(() => {
|
||||
if (!startupConfig?.mcpServers) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(startupConfig.mcpServers)
|
||||
.filter(
|
||||
([, serverConfig]) =>
|
||||
serverConfig.customUserVars && Object.keys(serverConfig.customUserVars).length > 0,
|
||||
)
|
||||
.map(([serverName, config]) => ({
|
||||
serverName,
|
||||
iconPath: null,
|
||||
config: {
|
||||
...config,
|
||||
customUserVars: config.customUserVars ?? {},
|
||||
},
|
||||
}));
|
||||
}, [startupConfig?.mcpServers]);
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating MCP custom user variables:', error);
|
||||
showToast({
|
||||
message: localize('com_nav_mcp_vars_update_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSaveServerVars = useCallback(
|
||||
(serverName: string, updatedValues: Record<string, string>) => {
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'install', // 'install' action is used to set/update credentials/variables
|
||||
auth: updatedValues,
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
},
|
||||
[updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const handleRevokeServerVars = useCallback(
|
||||
(serverName: string) => {
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'uninstall', // 'uninstall' action clears the variables
|
||||
auth: {}, // Empty auth for uninstall
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
},
|
||||
[updateUserPluginsMutation],
|
||||
);
|
||||
|
||||
const handleServerClickToEdit = (serverName: string) => {
|
||||
setSelectedServerNameForEditing(serverName);
|
||||
};
|
||||
|
||||
const handleGoBackToList = () => {
|
||||
setSelectedServerNameForEditing(null);
|
||||
};
|
||||
|
||||
if (startupConfigLoading) {
|
||||
return <MCPPanelSkeleton />;
|
||||
}
|
||||
|
||||
if (mcpServerDefinitions.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{localize('com_sidepanel_mcp_no_servers_with_vars')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedServerNameForEditing) {
|
||||
// Editing View
|
||||
const serverBeingEdited = mcpServerDefinitions.find(
|
||||
(s) => s.serverName === selectedServerNameForEditing,
|
||||
);
|
||||
|
||||
if (!serverBeingEdited) {
|
||||
// Fallback to list view if server not found
|
||||
setSelectedServerNameForEditing(null);
|
||||
return (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{localize('com_ui_error')}: {localize('com_ui_mcp_server_not_found')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoBackToList}
|
||||
className="mb-3 flex items-center px-3 py-2 text-sm"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
{localize('com_ui_back')}
|
||||
</Button>
|
||||
<h3 className="mb-3 text-lg font-medium">
|
||||
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
|
||||
</h3>
|
||||
<MCPVariableEditor
|
||||
server={serverBeingEdited}
|
||||
onSave={handleSaveServerVars}
|
||||
onRevoke={handleRevokeServerVars}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Server List View
|
||||
return (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||
<div className="space-y-2">
|
||||
{mcpServerDefinitions.map((server) => (
|
||||
<Button
|
||||
key={server.serverName}
|
||||
variant="outline"
|
||||
className="w-full justify-start dark:hover:bg-gray-700"
|
||||
onClick={() => handleServerClickToEdit(server.serverName)}
|
||||
>
|
||||
{server.serverName}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Inner component for the form - remains the same
|
||||
interface MCPVariableEditorProps {
|
||||
server: ServerConfigWithVars;
|
||||
onSave: (serverName: string, updatedValues: Record<string, string>) => void;
|
||||
onRevoke: (serverName: string) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariableEditorProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: {}, // Initialize empty, will be reset by useEffect
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Always initialize with empty strings based on the schema
|
||||
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
reset(initialFormValues);
|
||||
}, [reset, server.config.customUserVars]);
|
||||
|
||||
const onFormSubmit = (data: Record<string, string>) => {
|
||||
onSave(server.serverName, data);
|
||||
};
|
||||
|
||||
const handleRevokeClick = () => {
|
||||
onRevoke(server.serverName);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-4 mt-2 space-y-4">
|
||||
{Object.entries(server.config.customUserVars).map(([key, details]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={`${server.serverName}-${key}`} className="text-sm font-medium">
|
||||
{details.title}
|
||||
</Label>
|
||||
<Controller
|
||||
name={key}
|
||||
control={control}
|
||||
defaultValue={''}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`${server.serverName}-${key}`}
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder={localize('com_sidepanel_mcp_enter_value', { '0': details.title })}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{details.description && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||
/>
|
||||
)}
|
||||
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{Object.keys(server.config.customUserVars).length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRevokeClick}
|
||||
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-green-500 text-white hover:bg-green-600"
|
||||
disabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { Skeleton } from '~/components/ui';
|
||||
|
||||
export default function MCPPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-2">
|
||||
{[1, 2].map((serverIdx) => (
|
||||
<div key={serverIdx} className="space-y-4">
|
||||
<Skeleton className="h-6 w-1/3 rounded-lg" /> {/* Server Name */}
|
||||
{[1, 2].map((varIdx) => (
|
||||
<div key={varIdx} className="space-y-2">
|
||||
<Skeleton className="h-5 w-1/4 rounded-lg" /> {/* Variable Title */}
|
||||
<Skeleton className="h-8 w-full rounded-lg" /> {/* Input Field */}
|
||||
<Skeleton className="h-4 w-2/3 rounded-lg" /> {/* Description */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
client/src/components/SidePanel/Memories/AdminSettings.tsx
Normal file
212
client/src/components/SidePanel/Memories/AdminSettings.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
|
||||
import { useUpdateMemoryPermissionsMutation } from '~/data-provider';
|
||||
import { Button, Switch, DropdownPopup } from '~/components/ui';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
memoryPerm: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({ control, memoryPerm, label }) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{label}
|
||||
<Controller
|
||||
name={memoryPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { mutate, isLoading } = useUpdateMemoryPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
|
||||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]?.permissions) {
|
||||
return roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]) {
|
||||
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData = [
|
||||
{
|
||||
memoryPerm: Permissions.USE,
|
||||
label: localize('com_ui_memories_allow_use'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_memories_allow_create'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.UPDATE,
|
||||
label: localize('com_ui_memories_allow_update'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.READ,
|
||||
label: localize('com_ui_memories_allow_read'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.OPT_OUT,
|
||||
label: localize('com_ui_memories_allow_opt_out'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_memories',
|
||||
)}`}</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="memory-role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ memoryPerm, label }) => (
|
||||
<div key={memoryPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
memoryPerm={memoryPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && memoryPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettings;
|
||||
147
client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx
Normal file
147
client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import React, { useState } from 'react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogTemplate, Button, Label, Input } from '~/components/ui';
|
||||
import { useCreateMemoryMutation } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
interface MemoryCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export default function MemoryCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
triggerRef,
|
||||
}: MemoryCreateDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const { mutate: createMemory, isLoading } = useCreateMemoryMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_memory_created'),
|
||||
status: 'success',
|
||||
});
|
||||
onOpenChange(false);
|
||||
setKey('');
|
||||
setValue('');
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
let errorMessage = localize('com_ui_error');
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as any;
|
||||
if (axiosError.response?.data?.error) {
|
||||
errorMessage = axiosError.response.data.error;
|
||||
|
||||
// Check for duplicate key error
|
||||
if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) {
|
||||
errorMessage = localize('com_ui_memory_key_exists');
|
||||
}
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: errorMessage,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasCreateAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!key.trim() || !value.trim()) {
|
||||
showToast({
|
||||
message: localize('com_ui_field_required'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createMemory({
|
||||
key: key.trim(),
|
||||
value: value.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey && hasCreateAccess) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={localize('com_ui_create_memory')}
|
||||
showCloseButton={false}
|
||||
className="w-11/12 md:max-w-lg"
|
||||
main={
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-key" className="text-sm font-medium">
|
||||
{localize('com_ui_key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="memory-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_key')}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium">
|
||||
{localize('com_ui_value')}
|
||||
</Label>
|
||||
<textarea
|
||||
id="memory-value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_value')}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !key.trim() || !value.trim()}
|
||||
className="text-white"
|
||||
>
|
||||
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
179
client/src/components/SidePanel/Memories/MemoryEditDialog.tsx
Normal file
179
client/src/components/SidePanel/Memories/MemoryEditDialog.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogTemplate, Button, Label, Input } from '~/components/ui';
|
||||
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
interface MemoryEditDialogProps {
|
||||
memory: TUserMemory | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
export default function MemoryEditDialog({
|
||||
memory,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
triggerRef,
|
||||
}: MemoryEditDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: memData } = useMemoriesQuery();
|
||||
|
||||
const hasUpdateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.UPDATE,
|
||||
});
|
||||
|
||||
const { mutate: updateMemory, isLoading } = useUpdateMemoryMutation({
|
||||
onMutate: () => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
triggerRef?.current?.focus();
|
||||
}, 0);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_saved'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [originalKey, setOriginalKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (memory) {
|
||||
setKey(memory.key);
|
||||
setValue(memory.value);
|
||||
setOriginalKey(memory.key);
|
||||
}
|
||||
}, [memory]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasUpdateAccess || !memory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!key.trim() || !value.trim()) {
|
||||
showToast({
|
||||
message: localize('com_ui_field_required'),
|
||||
status: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateMemory({
|
||||
key: key.trim(),
|
||||
value: value.trim(),
|
||||
...(originalKey !== key.trim() && { originalKey }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey && hasUpdateAccess) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={hasUpdateAccess ? localize('com_ui_edit_memory') : localize('com_ui_view_memory')}
|
||||
showCloseButton={false}
|
||||
className="w-11/12 md:max-w-lg"
|
||||
main={
|
||||
<div className="space-y-4">
|
||||
{memory && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary">
|
||||
<div>
|
||||
{localize('com_ui_date')}:{' '}
|
||||
{new Date(memory.updated_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
{/* Token Information */}
|
||||
{memory.tokenCount !== undefined && (
|
||||
<div>
|
||||
{memory.tokenCount.toLocaleString()}
|
||||
{memData?.tokenLimit && ` / ${memData.tokenLimit.toLocaleString()}`}{' '}
|
||||
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Overall Memory Usage */}
|
||||
{memData?.tokenLimit && memData?.usagePercentage !== null && (
|
||||
<div className="text-xs text-text-secondary">
|
||||
{localize('com_ui_usage')}: {memData.usagePercentage}%{' '}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-key" className="text-sm font-medium">
|
||||
{localize('com_ui_key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="memory-key"
|
||||
value={key}
|
||||
onChange={(e) => hasUpdateAccess && setKey(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_key')}
|
||||
className="w-full"
|
||||
disabled={!hasUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium">
|
||||
{localize('com_ui_value')}
|
||||
</Label>
|
||||
<textarea
|
||||
id="memory-value"
|
||||
value={value}
|
||||
onChange={(e) => hasUpdateAccess && setValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_value')}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={3}
|
||||
disabled={!hasUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
hasUpdateAccess ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !key.trim() || !value.trim()}
|
||||
className="text-white"
|
||||
>
|
||||
{isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
423
client/src/components/SidePanel/Memories/MemoryViewer.tsx
Normal file
423
client/src/components/SidePanel/Memories/MemoryViewer.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
/* Memories */
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import {
|
||||
Spinner,
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
Table,
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Switch,
|
||||
TableRow,
|
||||
OGDialog,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TooltipAnchor,
|
||||
OGDialogTrigger,
|
||||
} from '~/components';
|
||||
import {
|
||||
useGetUserQuery,
|
||||
useMemoriesQuery,
|
||||
useDeleteMemoryMutation,
|
||||
useUpdateMemoryPreferencesMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import MemoryCreateDialog from './MemoryCreateDialog';
|
||||
import MemoryEditDialog from './MemoryEditDialog';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function MemoryViewer() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { data: userData } = useGetUserQuery();
|
||||
const { data: memData, isLoading } = useMemoriesQuery();
|
||||
const { mutate: deleteMemory } = useDeleteMemoryMutation();
|
||||
const { showToast } = useToastContext();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const pageSize = 10;
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
|
||||
|
||||
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_preferences_updated'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_error_updating_preferences'),
|
||||
status: 'error',
|
||||
});
|
||||
setReferenceSavedMemories((prev) => !prev);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userData?.personalization?.memories !== undefined) {
|
||||
setReferenceSavedMemories(userData.personalization.memories);
|
||||
}
|
||||
}, [userData?.personalization?.memories]);
|
||||
|
||||
const handleMemoryToggle = (checked: boolean) => {
|
||||
setReferenceSavedMemories(checked);
|
||||
updateMemoryPreferencesMutation.mutate({ memories: checked });
|
||||
};
|
||||
|
||||
const hasReadAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.READ,
|
||||
});
|
||||
|
||||
const hasUpdateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.UPDATE,
|
||||
});
|
||||
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const hasOptOutAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.OPT_OUT,
|
||||
});
|
||||
|
||||
const memories: TUserMemory[] = useMemo(() => memData?.memories ?? [], [memData]);
|
||||
|
||||
const filteredMemories = useMemo(() => {
|
||||
return matchSorter(memories, searchQuery, {
|
||||
keys: ['key', 'value'],
|
||||
});
|
||||
}, [memories, searchQuery]);
|
||||
|
||||
const currentRows = useMemo(() => {
|
||||
return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
|
||||
}, [filteredMemories, pageIndex]);
|
||||
|
||||
const getProgressBarColor = (percentage: number): string => {
|
||||
if (percentage > 90) {
|
||||
return 'stroke-red-500';
|
||||
}
|
||||
if (percentage > 75) {
|
||||
return 'stroke-yellow-500';
|
||||
}
|
||||
return 'stroke-green-500';
|
||||
};
|
||||
|
||||
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Only show edit button if user has UPDATE permission
|
||||
if (!hasUpdateAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MemoryEditDialog
|
||||
open={open}
|
||||
memory={memory}
|
||||
onOpenChange={setOpen}
|
||||
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
|
||||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit_memory')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={localize('com_ui_bookmarks_edit')}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</MemoryEditDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!hasUpdateAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeletingKey(memory.key);
|
||||
deleteMemory(memory.key, {
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_deleted'),
|
||||
status: 'success',
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_error'),
|
||||
status: 'error',
|
||||
}),
|
||||
onSettled: () => setDeletingKey(null),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete_memory')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={localize('com_ui_delete')}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{deletingKey === memory.key ? (
|
||||
<Spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<TrashIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_memory')}
|
||||
className="w-11/12 max-w-lg"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} "{memory.key}"?
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasReadAccess) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_read_access')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div role="region" aria-label={localize('com_ui_memories')} className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder={localize('com_ui_memories_filter')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label={localize('com_ui_memories_filter')}
|
||||
/>
|
||||
</div>
|
||||
{/* Memory Usage and Toggle Display */}
|
||||
{(memData?.tokenLimit || hasOptOutAccess) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-lg',
|
||||
memData?.tokenLimit != null && hasOptOutAccess ? 'justify-between' : 'justify-end',
|
||||
)}
|
||||
>
|
||||
{/* Usage Display */}
|
||||
{memData?.tokenLimit && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative size-10">
|
||||
<svg className="size-10 -rotate-90 transform">
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
className="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 16}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 16 * (1 - (memData.usagePercentage ?? 0) / 100)}`}
|
||||
className={`transition-all ${getProgressBarColor(memData.usagePercentage ?? 0)}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-medium">{memData.usagePercentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">{localize('com_ui_usage')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory Toggle */}
|
||||
{hasOptOutAccess && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>{localize('com_ui_use_memory')}</span>
|
||||
<Switch
|
||||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
aria-label={localize('com_ui_reference_saved_memories')}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Create Memory Button */}
|
||||
{hasCreateAccess && (
|
||||
<div className="flex w-full justify-end">
|
||||
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full bg-transparent">
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{localize('com_ui_create_memory')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</MemoryCreateDialog>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
|
||||
<Table className="w-full table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-border-light hover:bg-surface-secondary">
|
||||
<TableHead
|
||||
className={`${
|
||||
hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'
|
||||
} bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary`}
|
||||
>
|
||||
<div>{localize('com_ui_memory')}</div>
|
||||
</TableHead>
|
||||
{hasUpdateAccess && (
|
||||
<TableHead className="w-[25%] bg-surface-secondary py-3 text-center text-sm font-medium text-text-secondary">
|
||||
<div>{localize('com_assistants_actions')}</div>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentRows.length ? (
|
||||
currentRows.map((memory: TUserMemory, idx: number) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
className="border-b border-border-light hover:bg-surface-secondary"
|
||||
>
|
||||
<TableCell className={`${hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'} px-4 py-4`}>
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary"
|
||||
title={memory.value}
|
||||
>
|
||||
{memory.value}
|
||||
</div>
|
||||
</TableCell>
|
||||
{hasUpdateAccess && (
|
||||
<TableCell className="w-[25%] px-4 py-4">
|
||||
<div className="flex justify-center gap-2">
|
||||
<EditMemoryButton memory={memory} />
|
||||
<DeleteMemoryButton memory={memory} />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={hasUpdateAccess ? 2 : 1}
|
||||
className="h-24 text-center text-sm text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_no_data')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{filteredMemories.length > pageSize && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div className="text-sm" aria-live="polite">
|
||||
{`${pageIndex + 1} / ${Math.ceil(filteredMemories.length / pageSize)}`}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPageIndex((prev) =>
|
||||
(prev + 1) * pageSize < filteredMemories.length ? prev + 1 : prev,
|
||||
)
|
||||
}
|
||||
disabled={(pageIndex + 1) * pageSize >= filteredMemories.length}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Settings */}
|
||||
{user?.role === SystemRoles.ADMIN && (
|
||||
<div className="mt-4">
|
||||
<AdminSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
client/src/components/SidePanel/Memories/index.ts
Normal file
2
client/src/components/SidePanel/Memories/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as MemoryViewer } from './MemoryViewer';
|
||||
export { default as MemoryEditDialog } from './MemoryEditDialog';
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { TPlugin } from 'librechat-data-provider';
|
||||
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
|
||||
import { AgentToolType } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type ToolItemProps = {
|
||||
tool: TPlugin;
|
||||
tool: AgentToolType;
|
||||
onAddTool: () => void;
|
||||
onRemoveTool: () => void;
|
||||
isInstalled?: boolean;
|
||||
|
|
@ -19,15 +19,19 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
|||
}
|
||||
};
|
||||
|
||||
const name = tool.metadata?.name || tool.tool_id;
|
||||
const description = tool.metadata?.description || '';
|
||||
const icon = tool.metadata?.icon;
|
||||
|
||||
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">
|
||||
{tool.icon != null && tool.icon ? (
|
||||
{icon ? (
|
||||
<img
|
||||
src={tool.icon}
|
||||
alt={localize('com_ui_logo', { 0: tool.name })}
|
||||
src={icon}
|
||||
alt={localize('com_ui_logo', { 0: name })}
|
||||
className="h-full w-full rounded-[5px] bg-white"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -40,12 +44,12 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
|||
</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">
|
||||
{tool.name}
|
||||
{name}
|
||||
</div>
|
||||
{!isInstalled ? (
|
||||
<button
|
||||
className="btn btn-primary relative"
|
||||
aria-label={`${localize('com_ui_add')} ${tool.name}`}
|
||||
aria-label={`${localize('com_ui_add')} ${name}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
|
|
@ -57,7 +61,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
|||
<button
|
||||
className="btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200"
|
||||
onClick={handleClick}
|
||||
aria-label={`${localize('com_nav_tool_remove')} ${tool.name}`}
|
||||
aria-label={`${localize('com_nav_tool_remove')} ${name}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{localize('com_nav_tool_remove')}
|
||||
|
|
@ -67,7 +71,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{tool.description}</div>
|
||||
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { Constants, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type {
|
||||
AssistantsEndpoint,
|
||||
EModelEndpoint,
|
||||
TPluginAction,
|
||||
AgentToolType,
|
||||
TError,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TPluginStoreDialogProps } from '~/common/types';
|
||||
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
|
||||
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
|
||||
import { useAvailableToolsQuery } from '~/data-provider';
|
||||
import ToolItem from './ToolItem';
|
||||
|
|
@ -20,14 +22,13 @@ function ToolSelectDialog({
|
|||
isOpen,
|
||||
endpoint,
|
||||
setIsOpen,
|
||||
toolsFormKey,
|
||||
}: TPluginStoreDialogProps & {
|
||||
toolsFormKey: string;
|
||||
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { getValues, setValue } = useFormContext();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { data: tools } = useAvailableToolsQuery(endpoint);
|
||||
const { groupedTools } = useAgentPanelContext();
|
||||
const isAgentTools = isAgentsEndpoint(endpoint);
|
||||
|
||||
const {
|
||||
|
|
@ -66,11 +67,23 @@ function ToolSelectDialog({
|
|||
}, 5000);
|
||||
};
|
||||
|
||||
const toolsFormKey = 'tools';
|
||||
const handleInstall = (pluginAction: TPluginAction) => {
|
||||
const addFunction = () => {
|
||||
const fns = getValues(toolsFormKey).slice();
|
||||
fns.push(pluginAction.pluginKey);
|
||||
setValue(toolsFormKey, fns);
|
||||
const installedToolIds: string[] = getValues(toolsFormKey) || [];
|
||||
// Add the parent
|
||||
installedToolIds.push(pluginAction.pluginKey);
|
||||
|
||||
// If this tool is a group, add subtools too
|
||||
const groupObj = groupedTools[pluginAction.pluginKey];
|
||||
if (groupObj?.tools && groupObj.tools.length > 0) {
|
||||
for (const sub of groupObj.tools) {
|
||||
if (!installedToolIds.includes(sub.tool_id)) {
|
||||
installedToolIds.push(sub.tool_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case
|
||||
};
|
||||
|
||||
if (!pluginAction.auth) {
|
||||
|
|
@ -87,17 +100,21 @@ function ToolSelectDialog({
|
|||
setShowPluginAuthForm(false);
|
||||
};
|
||||
|
||||
const onRemoveTool = (tool: string) => {
|
||||
setShowPluginAuthForm(false);
|
||||
const onRemoveTool = (toolId: string) => {
|
||||
const groupObj = groupedTools[toolId];
|
||||
const toolIdsToRemove = [toolId];
|
||||
if (groupObj?.tools && groupObj.tools.length > 0) {
|
||||
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
|
||||
}
|
||||
// Remove these from the formTools
|
||||
updateUserPlugins.mutate(
|
||||
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
|
||||
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
handleInstallError(error as TError);
|
||||
},
|
||||
onError: (error: unknown) => handleInstallError(error as TError),
|
||||
onSuccess: () => {
|
||||
const fns = getValues(toolsFormKey).filter((fn: string) => fn !== tool);
|
||||
setValue(toolsFormKey, fns);
|
||||
const remainingToolIds =
|
||||
getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
|
||||
setValue(toolsFormKey, remainingToolIds);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -108,22 +125,45 @@ function ToolSelectDialog({
|
|||
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
|
||||
setSelectedPlugin(getAvailablePluginFromKey);
|
||||
|
||||
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
||||
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter);
|
||||
|
||||
if (authConfig && authConfig.length > 0 && !authenticated) {
|
||||
setShowPluginAuthForm(true);
|
||||
if (isMCPTool) {
|
||||
// MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect),
|
||||
// so we directly proceed to install without showing the auth form.
|
||||
handleInstall({ pluginKey, action: 'install', auth: {} });
|
||||
} else {
|
||||
handleInstall({ pluginKey, action: 'install', auth: null });
|
||||
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
||||
if (authConfig && authConfig.length > 0 && !authenticated) {
|
||||
setShowPluginAuthForm(true);
|
||||
} else {
|
||||
handleInstall({
|
||||
pluginKey,
|
||||
action: 'install',
|
||||
auth: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTools = tools?.filter((tool) =>
|
||||
tool.name.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
const filteredTools = Object.values(groupedTools || {}).filter(
|
||||
(tool: AgentToolType & { tools?: AgentToolType[] }) => {
|
||||
// Check if the parent tool matches
|
||||
if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
// Check if any child tools match
|
||||
if (tool.tools) {
|
||||
return tool.tools.some((childTool) =>
|
||||
childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredTools) {
|
||||
setMaxPage(Math.ceil(filteredTools.length / itemsPerPage));
|
||||
setMaxPage(Math.ceil(Object.keys(filteredTools || {}).length / itemsPerPage));
|
||||
if (searchChanged) {
|
||||
setCurrentPage(1);
|
||||
setSearchChanged(false);
|
||||
|
|
@ -155,7 +195,7 @@ function ToolSelectDialog({
|
|||
{/* Full-screen container to center the panel */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
|
||||
className="relative max-h-[90vh] w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
|
||||
style={{ minHeight: '610px' }}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
|
||||
|
|
@ -228,9 +268,9 @@ function ToolSelectDialog({
|
|||
<ToolItem
|
||||
key={index}
|
||||
tool={tool}
|
||||
isInstalled={getValues(toolsFormKey).includes(tool.pluginKey)}
|
||||
onAddTool={() => onAddTool(tool.pluginKey)}
|
||||
onRemoveTool={() => onRemoveTool(tool.pluginKey)}
|
||||
isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false}
|
||||
onAddTool={() => onAddTool(tool.tool_id)}
|
||||
onRemoveTool={() => onRemoveTool(tool.tool_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { VisuallyHidden } from '@ariakit/react';
|
||||
import { Globe, Newspaper, Image, ChevronDown } from 'lucide-react';
|
||||
import { X, Globe, Newspaper, Image, ChevronDown } from 'lucide-react';
|
||||
import type { ValidSource, ImageResult } from 'librechat-data-provider';
|
||||
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
|
||||
import { useSearchContext } from '~/Providers';
|
||||
|
|
@ -9,6 +9,7 @@ import { AnimatedTabs } from '~/components/ui';
|
|||
import { useLocalize } from '~/hooks';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
|
|
@ -213,25 +214,12 @@ function SourcesGroup({ sources, limit = 3 }: { sources: ValidSource[]; limit?:
|
|||
<OGDialogTitle className="text-base font-medium">
|
||||
{localize('com_sources_title')}
|
||||
</OGDialogTitle>
|
||||
<button
|
||||
<OGDialogClose
|
||||
className="rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary"
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<X className="h-4 w-4" />
|
||||
</OGDialogClose>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
|
|||
15
client/src/components/svg/MCPIcon.tsx
Normal file
15
client/src/components/svg/MCPIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export default function MCPIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M11.016 2.099a3.998 3.998 0 0 1 5.58.072l.073.074a3.991 3.991 0 0 1 1.058 3.318 3.994 3.994 0 0 1 3.32 1.06l.073.071.048.047.071.075a3.998 3.998 0 0 1 0 5.506l-.071.074-8.183 8.182-.034.042a.267.267 0 0 0 .034.335l1.68 1.68a.8.8 0 0 1-1.131 1.13l-1.68-1.679a1.866 1.866 0 0 1-.034-2.604l8.26-8.261a2.4 2.4 0 0 0-.044-3.349l-.047-.047-.044-.043a2.4 2.4 0 0 0-3.349.043l-6.832 6.832-.03.029a.8.8 0 0 1-1.1-1.16l6.876-6.875a2.4 2.4 0 0 0-.044-3.35l-.179-.161a2.399 2.399 0 0 0-3.169.119l-.045.043-9.047 9.047-.03.028a.8.8 0 0 1-1.1-1.16l9.046-9.046.074-.072Z" />
|
||||
<path d="M13.234 4.404a.8.8 0 0 1 1.1 1.16l-6.69 6.691a2.399 2.399 0 1 0 3.393 3.393l6.691-6.692a.8.8 0 0 1 1.131 1.131l-6.691 6.692a4 4 0 0 1-5.581.07l-.073-.07a3.998 3.998 0 0 1 0-5.655l6.69-6.691.03-.029Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
client/src/components/svg/PersonalizationIcon.tsx
Normal file
19
client/src/components/svg/PersonalizationIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default function PersonalizationIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`icon-sm ${className}`}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 4C10.3431 4 9 5.34315 9 7C9 8.65685 10.3431 10 12 10C13.6569 10 15 8.65685 15 7C15 5.34315 13.6569 4 12 4ZM7 7C7 4.23858 9.23858 2 12 2C14.7614 2 17 4.23858 17 7C17 9.76142 14.7614 12 12 12C9.23858 12 7 9.76142 7 7ZM19.0277 15.6255C18.6859 15.5646 18.1941 15.6534 17.682 16.1829C17.4936 16.3777 17.2342 16.4877 16.9632 16.4877C16.6922 16.4877 16.4328 16.3777 16.2444 16.1829C15.7322 15.6534 15.2405 15.5646 14.8987 15.6255C14.5381 15.6897 14.2179 15.9384 14.0623 16.3275C13.8048 16.9713 13.9014 18.662 16.9632 20.4617C20.0249 18.662 20.1216 16.9713 19.864 16.3275C19.7084 15.9384 19.3882 15.6897 19.0277 15.6255ZM21.721 15.5847C22.5748 17.7191 21.2654 20.429 17.437 22.4892C17.1412 22.6484 16.7852 22.6484 16.4893 22.4892C12.6609 20.4291 11.3516 17.7191 12.2053 15.5847C12.6117 14.5689 13.4917 13.8446 14.5481 13.6565C15.3567 13.5125 16.2032 13.6915 16.9632 14.1924C17.7232 13.6915 18.5697 13.5125 19.3783 13.6565C20.4347 13.8446 21.3147 14.5689 21.721 15.5847ZM9.92597 14.2049C10.1345 14.7163 9.889 15.2999 9.3776 15.5084C7.06131 16.453 5.5 18.5813 5.5 20.9999C5.5 21.5522 5.05228 21.9999 4.5 21.9999C3.94772 21.9999 3.5 21.5522 3.5 20.9999C3.5 17.6777 5.641 14.8723 8.6224 13.6565C9.1338 13.448 9.71743 13.6935 9.92597 14.2049Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
client/src/components/svg/SquirclePlusIcon.tsx
Normal file
19
client/src/components/svg/SquirclePlusIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default function SquirclePlusIcon() {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-3xl"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,3 +62,4 @@ export { default as ThumbUpIcon } from './ThumbUpIcon';
|
|||
export { default as ThumbDownIcon } from './ThumbDownIcon';
|
||||
export { default as StarIcon } from './StarIcon';
|
||||
export { default as XAIcon } from './XAIcon';
|
||||
export { default as PersonalizationIcon } from './PersonalizationIcon';
|
||||
|
|
|
|||
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Input, Label, OGDialog, Button } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
fieldsSchema: Record<string, ConfigFieldDetail>;
|
||||
initialValues: Record<string, string>;
|
||||
onSave: (updatedValues: Record<string, string>) => void;
|
||||
isSubmitting?: boolean;
|
||||
onRevoke?: () => void;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export default function MCPConfigDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
fieldsSchema,
|
||||
initialValues,
|
||||
onSave,
|
||||
isSubmitting = false,
|
||||
onRevoke,
|
||||
serverName,
|
||||
}: MCPConfigDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, _ },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset(initialValues);
|
||||
}
|
||||
}, [isOpen, initialValues, reset]);
|
||||
|
||||
const onFormSubmit = (data: Record<string, string>) => {
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
const handleRevoke = () => {
|
||||
if (onRevoke) {
|
||||
onRevoke();
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName });
|
||||
const dialogDescription = localize('com_ui_mcp_dialog_desc');
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogTemplate
|
||||
className="sm:max-w-lg"
|
||||
title={dialogTitle}
|
||||
description={dialogDescription}
|
||||
headerClassName="px-6 pt-6 pb-4"
|
||||
main={
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
|
||||
{Object.entries(fieldsSchema).map(([key, details]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-sm font-medium">
|
||||
{details.title}
|
||||
</Label>
|
||||
<Controller
|
||||
name={key}
|
||||
control={control}
|
||||
defaultValue={initialValues[key] || ''}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={key}
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{details.description && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||
/>
|
||||
)}
|
||||
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onFormSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
onRevoke && (
|
||||
<Button
|
||||
onClick={handleRevoke}
|
||||
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
footerClassName="flex justify-end gap-2 px-6 pb-6 pt-2"
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ interface MultiSelectProps<T extends string> {
|
|||
selectItemsClassName?: string;
|
||||
selectedValues: T[];
|
||||
setSelectedValues: (values: T[]) => void;
|
||||
renderItemContent?: (
|
||||
value: T,
|
||||
defaultContent: React.ReactNode,
|
||||
isSelected: boolean,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||
|
|
@ -54,9 +59,9 @@ export default function MultiSelect<T extends string>({
|
|||
selectItemsClassName,
|
||||
selectedValues = [],
|
||||
setSelectedValues,
|
||||
renderItemContent,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
// const [selectedValues, setSelectedValues] = React.useState<T[]>(defaultSelectedValues);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
|
|
@ -105,23 +110,33 @@ export default function MultiSelect<T extends string>({
|
|||
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>
|
||||
))}
|
||||
{items.map((value) => {
|
||||
const defaultContent = (
|
||||
<>
|
||||
<SelectItemCheck className="text-primary" />
|
||||
<span className="truncate">{value}</span>
|
||||
</>
|
||||
);
|
||||
const isCurrentItemSelected = selectedValues.includes(value);
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
>
|
||||
{renderItemContent
|
||||
? renderItemContent(value, defaultContent, isCurrentItemSelected)
|
||||
: defaultContent}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
OGDialogDescription,
|
||||
} from './OriginalDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { Spinner } from '../svg';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
|
|
@ -53,7 +54,6 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
|
|||
showCancelButton = true,
|
||||
} = props;
|
||||
const { selectHandler, selectClasses, selectText, isLoading } = selection || {};
|
||||
const Cancel = localize('com_ui_cancel');
|
||||
|
||||
const defaultSelect =
|
||||
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
|
||||
|
|
@ -83,12 +83,12 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
|
|||
) : null}
|
||||
</div>
|
||||
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
|
||||
{buttons != null ? buttons : null}
|
||||
{showCancelButton && (
|
||||
<OGDialogClose className="btn btn-neutral border-token-border-light relative justify-center rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0 max-sm:order-last max-sm:w-full sm:order-first">
|
||||
{Cancel}
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline">{localize('com_ui_cancel')}</Button>
|
||||
</OGDialogClose>
|
||||
)}
|
||||
{buttons != null ? buttons : null}
|
||||
{selection ? (
|
||||
<OGDialogClose
|
||||
onClick={selectHandler}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { X } from 'lucide-react';
|
|||
import { cn } from '~/utils';
|
||||
|
||||
interface OGDialogProps extends DialogPrimitive.DialogProps {
|
||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement>;
|
||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const TableFooter = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
@ -43,7 +43,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border-light transition-colors',
|
||||
'border-b border-border-light transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -59,7 +59,7 @@ const TableHead = React.forwardRef<
|
|||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -83,7 +83,7 @@ const TableCaption = React.forwardRef<
|
|||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
|
|
|
|||
|
|
@ -224,18 +224,20 @@ export const useUpdateAgentAction = (
|
|||
});
|
||||
|
||||
queryClient.setQueryData<t.Action[]>([QueryKeys.actions], (prev) => {
|
||||
return prev
|
||||
?.map((action) => {
|
||||
if (!prev) {
|
||||
return [updateAgentActionResponse[1]];
|
||||
}
|
||||
|
||||
if (variables.action_id) {
|
||||
return prev.map((action) => {
|
||||
if (action.action_id === variables.action_id) {
|
||||
return updateAgentActionResponse[1];
|
||||
}
|
||||
return action;
|
||||
})
|
||||
.concat(
|
||||
variables.action_id != null && variables.action_id
|
||||
? []
|
||||
: [updateAgentActionResponse[1]],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return [...prev, updateAgentActionResponse[1]];
|
||||
});
|
||||
|
||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);
|
||||
|
|
|
|||
2
client/src/data-provider/Memories/index.ts
Normal file
2
client/src/data-provider/Memories/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/* Memories */
|
||||
export * from './queries';
|
||||
116
client/src/data-provider/Memories/queries.ts
Normal file
116
client/src/data-provider/Memories/queries.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/* Memories */
|
||||
import { QueryKeys, MutationKeys, dataService } from 'librechat-data-provider';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import type {
|
||||
UseQueryOptions,
|
||||
UseMutationOptions,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/react-query';
|
||||
import type { TUserMemory, MemoriesResponse } from 'librechat-data-provider';
|
||||
|
||||
export const useMemoriesQuery = (
|
||||
config?: UseQueryOptions<MemoriesResponse>,
|
||||
): QueryObserverResult<MemoriesResponse> => {
|
||||
return useQuery<MemoriesResponse>([QueryKeys.memories], () => dataService.getMemories(), {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteMemoryMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((key: string) => dataService.deleteMemory(key), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([QueryKeys.memories]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type UpdateMemoryParams = { key: string; value: string; originalKey?: string };
|
||||
export const useUpdateMemoryMutation = (
|
||||
options?: UseMutationOptions<TUserMemory, Error, UpdateMemoryParams>,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({ key, value, originalKey }: UpdateMemoryParams) =>
|
||||
dataService.updateMemory(key, value, originalKey),
|
||||
{
|
||||
...options,
|
||||
onSuccess: (...params) => {
|
||||
queryClient.invalidateQueries([QueryKeys.memories]);
|
||||
options?.onSuccess?.(...params);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export type UpdateMemoryPreferencesParams = { memories: boolean };
|
||||
export type UpdateMemoryPreferencesResponse = {
|
||||
updated: boolean;
|
||||
preferences: { memories: boolean };
|
||||
};
|
||||
|
||||
export const useUpdateMemoryPreferencesMutation = (
|
||||
options?: UseMutationOptions<
|
||||
UpdateMemoryPreferencesResponse,
|
||||
Error,
|
||||
UpdateMemoryPreferencesParams
|
||||
>,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<UpdateMemoryPreferencesResponse, Error, UpdateMemoryPreferencesParams>(
|
||||
[MutationKeys.updateMemoryPreferences],
|
||||
(preferences: UpdateMemoryPreferencesParams) =>
|
||||
dataService.updateMemoryPreferences(preferences),
|
||||
{
|
||||
...options,
|
||||
onSuccess: (...params) => {
|
||||
queryClient.invalidateQueries([QueryKeys.user]);
|
||||
options?.onSuccess?.(...params);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export type CreateMemoryParams = { key: string; value: string };
|
||||
export type CreateMemoryResponse = { created: boolean; memory: TUserMemory };
|
||||
|
||||
export const useCreateMemoryMutation = (
|
||||
options?: UseMutationOptions<CreateMemoryResponse, Error, CreateMemoryParams>,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<CreateMemoryResponse, Error, CreateMemoryParams>(
|
||||
({ key, value }: CreateMemoryParams) => dataService.createMemory({ key, value }),
|
||||
{
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.setQueryData<MemoriesResponse>([QueryKeys.memories], (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
const newMemories = [...oldData.memories, data.memory];
|
||||
const totalTokens = newMemories.reduce(
|
||||
(sum, memory) => sum + (memory.tokenCount || 0),
|
||||
0,
|
||||
);
|
||||
const tokenLimit = oldData.tokenLimit;
|
||||
let usagePercentage = oldData.usagePercentage;
|
||||
|
||||
if (tokenLimit && tokenLimit > 0) {
|
||||
usagePercentage = Math.min(100, Math.round((totalTokens / tokenLimit) * 100));
|
||||
}
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
memories: newMemories,
|
||||
totalTokens,
|
||||
usagePercentage,
|
||||
};
|
||||
});
|
||||
|
||||
options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
19
client/src/data-provider/__tests__/memories.test.ts
Normal file
19
client/src/data-provider/__tests__/memories.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { dataService as _dataService } from 'librechat-data-provider';
|
||||
import axios from 'axios';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('getMemories', () => {
|
||||
it('should fetch memories from /api/memories', async () => {
|
||||
const mockData = [{ key: 'foo', value: 'bar', updated_at: '2024-05-01T00:00:00Z' }];
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce({ data: mockData } as any);
|
||||
|
||||
const result = await (_dataService as any).getMemories();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('/api/memories', expect.any(Object));
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,8 @@ export * from './Auth';
|
|||
export * from './Agents';
|
||||
export * from './Endpoints';
|
||||
export * from './Files';
|
||||
/* Memories */
|
||||
export * from './Memories';
|
||||
export * from './Messages';
|
||||
export * from './Misc';
|
||||
export * from './Tools';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
QueryKeys,
|
||||
dataService,
|
||||
promptPermissionsSchema,
|
||||
memoryPermissionsSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
UseQueryOptions,
|
||||
UseMutationResult,
|
||||
QueryObserverResult,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import { QueryKeys, dataService, promptPermissionsSchema } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
|
||||
export const useGetRole = (
|
||||
|
|
@ -91,3 +96,39 @@ export const useUpdateAgentPermissionsMutation = (
|
|||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdateMemoryPermissionsMutation = (
|
||||
options?: t.UpdateMemoryPermOptions,
|
||||
): UseMutationResult<
|
||||
t.UpdatePermResponse,
|
||||
t.TError | undefined,
|
||||
t.UpdateMemoryPermVars,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onMutate, onSuccess, onError } = options ?? {};
|
||||
return useMutation(
|
||||
(variables) => {
|
||||
memoryPermissionsSchema.partial().parse(variables.updates);
|
||||
return dataService.updateMemoryPermissions(variables);
|
||||
},
|
||||
{
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries([QueryKeys.roles, variables.roleName]);
|
||||
if (onSuccess) {
|
||||
onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (...args) => {
|
||||
const error = args[0];
|
||||
if (error != null) {
|
||||
console.error('Failed to update memory permissions:', error);
|
||||
}
|
||||
if (onError) {
|
||||
onError(...args);
|
||||
}
|
||||
},
|
||||
onMutate,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { v4 } from 'uuid';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Constants,
|
||||
|
|
@ -51,10 +52,10 @@ export default function useChatFunctions({
|
|||
getMessages,
|
||||
setMessages,
|
||||
isSubmitting,
|
||||
conversation,
|
||||
latestMessage,
|
||||
setSubmission,
|
||||
setLatestMessage,
|
||||
conversation: immutableConversation,
|
||||
}: {
|
||||
index?: number;
|
||||
isSubmitting: boolean;
|
||||
|
|
@ -77,8 +78,8 @@ export default function useChatFunctions({
|
|||
const isTemporary = useRecoilValue(store.isTemporary);
|
||||
const codeArtifacts = useRecoilValue(store.codeArtifacts);
|
||||
const includeShadcnui = useRecoilValue(store.includeShadcnui);
|
||||
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
|
||||
const customPromptMode = useRecoilValue(store.customPromptMode);
|
||||
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
|
||||
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
|
||||
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
|
||||
|
||||
|
|
@ -108,6 +109,8 @@ export default function useChatFunctions({
|
|||
return;
|
||||
}
|
||||
|
||||
const conversation = cloneDeep(immutableConversation);
|
||||
|
||||
const endpoint = conversation?.endpoint;
|
||||
if (endpoint === null) {
|
||||
console.error('No endpoint available');
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as useAppStartup } from './useAppStartup';
|
||||
export { default as useClearStates } from './useClearStates';
|
||||
export { default as useSpeechSettingsInit } from './useSpeechSettingsInit';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
|
|||
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
|
||||
import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
|
||||
import useSpeechSettingsInit from './useSpeechSettingsInit';
|
||||
import store from '~/store';
|
||||
|
||||
const pluginStore: TPlugin = {
|
||||
|
|
@ -31,6 +32,8 @@ export default function useAppStartup({
|
|||
select: selectPlugins,
|
||||
});
|
||||
|
||||
useSpeechSettingsInit(!!user);
|
||||
|
||||
/** Set the app title */
|
||||
useEffect(() => {
|
||||
const appTitle = startupConfig?.appTitle ?? '';
|
||||
|
|
|
|||
50
client/src/hooks/Config/useSpeechSettingsInit.ts
Normal file
50
client/src/hooks/Config/useSpeechSettingsInit.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useGetCustomConfigSpeechQuery } from 'librechat-data-provider/react-query';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* Initializes speech-related Recoil values from the server-side custom
|
||||
* configuration on first load (only when the user is authenticated)
|
||||
*/
|
||||
export default function useSpeechSettingsInit(isAuthenticated: boolean) {
|
||||
const { data } = useGetCustomConfigSpeechQuery({ enabled: isAuthenticated });
|
||||
|
||||
const setters = useRef({
|
||||
conversationMode: useSetRecoilState(store.conversationMode),
|
||||
advancedMode: useSetRecoilState(store.advancedMode),
|
||||
speechToText: useSetRecoilState(store.speechToText),
|
||||
textToSpeech: useSetRecoilState(store.textToSpeech),
|
||||
cacheTTS: useSetRecoilState(store.cacheTTS),
|
||||
engineSTT: useSetRecoilState(store.engineSTT),
|
||||
languageSTT: useSetRecoilState(store.languageSTT),
|
||||
autoTranscribeAudio: useSetRecoilState(store.autoTranscribeAudio),
|
||||
decibelValue: useSetRecoilState(store.decibelValue),
|
||||
autoSendText: useSetRecoilState(store.autoSendText),
|
||||
engineTTS: useSetRecoilState(store.engineTTS),
|
||||
voice: useSetRecoilState(store.voice),
|
||||
cloudBrowserVoices: useSetRecoilState(store.cloudBrowserVoices),
|
||||
languageTTS: useSetRecoilState(store.languageTTS),
|
||||
automaticPlayback: useSetRecoilState(store.automaticPlayback),
|
||||
playbackRate: useSetRecoilState(store.playbackRate),
|
||||
}).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !data || data.message === 'not_found') return;
|
||||
|
||||
logger.log('Initializing speech settings from config:', data);
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key === 'sttExternal' || key === 'ttsExternal') return;
|
||||
|
||||
if (localStorage.getItem(key) !== null) return;
|
||||
|
||||
const setter = setters[key as keyof typeof setters];
|
||||
if (setter) {
|
||||
logger.log(`Setting default speech setting: ${key} = ${value}`);
|
||||
setter(value as any);
|
||||
}
|
||||
});
|
||||
}, [isAuthenticated, data, setters]);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { TSetOption } from '~/common';
|
||||
import { defaultDebouncedDelay } from '~/common';
|
||||
|
|
@ -29,10 +29,10 @@ function useDebouncedInput<T = unknown>({
|
|||
|
||||
/** A debounced function to call the passed setOption with the optionKey and new value.
|
||||
*
|
||||
Note: We use useCallback to ensure our debounced function is stable across renders. */
|
||||
const setDebouncedOption = useCallback(
|
||||
debounce(setOption && optionKey ? setOption(optionKey) : setter, delay),
|
||||
[],
|
||||
Note: We use useMemo to ensure our debounced function is stable across renders and properly typed. */
|
||||
const setDebouncedOption = useMemo(
|
||||
() => debounce(setOption && optionKey ? setOption(optionKey) : setter || (() => {}), delay),
|
||||
[setOption, optionKey, setter, delay],
|
||||
);
|
||||
|
||||
/** An onChange handler that updates the local state and the debounced option */
|
||||
|
|
@ -42,8 +42,9 @@ function useDebouncedInput<T = unknown>({
|
|||
typeof e !== 'object'
|
||||
? e
|
||||
: ((e as React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>).target
|
||||
.value as unknown as T);
|
||||
if (numeric === true) {
|
||||
.value as unknown as T);
|
||||
// Handle numeric conversion only if value is not undefined and not empty string
|
||||
if (numeric === true && newValue !== undefined && newValue !== '') {
|
||||
newValue = Number(newValue) as unknown as T;
|
||||
}
|
||||
setValue(newValue);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,25 @@
|
|||
import { v4 } from 'uuid';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||
import {
|
||||
QueryKeys,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
defaultAssistantsVersion,
|
||||
fileConfig as defaultFileConfig,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
mergeFileConfig,
|
||||
QueryKeys,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useUploadFileMutation, useGetFileConfig } from '~/data-provider';
|
||||
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
||||
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { logger, validateFiles } from '~/utils';
|
||||
import { processFileForUpload } from '~/utils/heicConverter';
|
||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
type UseFileHandling = {
|
||||
|
|
@ -262,41 +263,110 @@ const useFileHandling = (params?: UseFileHandling) => {
|
|||
for (const originalFile of fileList) {
|
||||
const file_id = v4();
|
||||
try {
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
const extendedFile: ExtendedFile = {
|
||||
// Create initial preview with original file
|
||||
const initialPreview = URL.createObjectURL(originalFile);
|
||||
|
||||
// Create initial ExtendedFile to show immediately
|
||||
const initialExtendedFile: ExtendedFile = {
|
||||
file_id,
|
||||
file: originalFile,
|
||||
type: originalFile.type,
|
||||
preview,
|
||||
progress: 0.2,
|
||||
preview: initialPreview,
|
||||
progress: 0.1, // Show as processing
|
||||
size: originalFile.size,
|
||||
};
|
||||
|
||||
if (_toolResource != null && _toolResource !== '') {
|
||||
extendedFile.tool_resource = _toolResource;
|
||||
initialExtendedFile.tool_resource = _toolResource;
|
||||
}
|
||||
|
||||
const isImage = originalFile.type.split('/')[0] === 'image';
|
||||
const tool_resource =
|
||||
extendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
||||
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
|
||||
/** Note: this needs to be removed when we can support files to providers */
|
||||
setError('com_error_files_unsupported_capability');
|
||||
continue;
|
||||
// Add file immediately to show in UI
|
||||
addFile(initialExtendedFile);
|
||||
|
||||
// Check if HEIC conversion is needed and show toast
|
||||
const isHEIC =
|
||||
originalFile.type === 'image/heic' ||
|
||||
originalFile.type === 'image/heif' ||
|
||||
originalFile.name.toLowerCase().match(/\.(heic|heif)$/);
|
||||
|
||||
if (isHEIC) {
|
||||
showToast({
|
||||
message: localize('com_info_heic_converting'),
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
addFile(extendedFile);
|
||||
// Process file for HEIC conversion if needed
|
||||
const processedFile = await processFileForUpload(
|
||||
originalFile,
|
||||
0.9,
|
||||
(conversionProgress) => {
|
||||
// Update progress during HEIC conversion (0.1 to 0.5 range for conversion)
|
||||
const adjustedProgress = 0.1 + conversionProgress * 0.4;
|
||||
replaceFile({
|
||||
...initialExtendedFile,
|
||||
progress: adjustedProgress,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (isImage) {
|
||||
loadImage(extendedFile, preview);
|
||||
continue;
|
||||
// If file was converted, update with new file and preview
|
||||
if (processedFile !== originalFile) {
|
||||
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
||||
const newPreview = URL.createObjectURL(processedFile);
|
||||
|
||||
const updatedExtendedFile: ExtendedFile = {
|
||||
...initialExtendedFile,
|
||||
file: processedFile,
|
||||
type: processedFile.type,
|
||||
preview: newPreview,
|
||||
progress: 0.5, // Conversion complete, ready for upload
|
||||
size: processedFile.size,
|
||||
};
|
||||
|
||||
replaceFile(updatedExtendedFile);
|
||||
|
||||
const isImage = processedFile.type.split('/')[0] === 'image';
|
||||
if (isImage) {
|
||||
loadImage(updatedExtendedFile, newPreview);
|
||||
continue;
|
||||
}
|
||||
|
||||
await startUpload(updatedExtendedFile);
|
||||
} else {
|
||||
// File wasn't converted, proceed with original
|
||||
const isImage = originalFile.type.split('/')[0] === 'image';
|
||||
const tool_resource =
|
||||
initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
||||
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
|
||||
/** Note: this needs to be removed when we can support files to providers */
|
||||
setError('com_error_files_unsupported_capability');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update progress to show ready for upload
|
||||
const readyExtendedFile = {
|
||||
...initialExtendedFile,
|
||||
progress: 0.2,
|
||||
};
|
||||
replaceFile(readyExtendedFile);
|
||||
|
||||
if (isImage) {
|
||||
loadImage(readyExtendedFile, initialPreview);
|
||||
continue;
|
||||
}
|
||||
|
||||
await startUpload(readyExtendedFile);
|
||||
}
|
||||
|
||||
await startUpload(extendedFile);
|
||||
} catch (error) {
|
||||
deleteFileById(file_id);
|
||||
console.log('file handling error', error);
|
||||
setError('com_error_files_process');
|
||||
if (error instanceof Error && error.message.includes('HEIC')) {
|
||||
setError('com_error_heic_conversion');
|
||||
} else {
|
||||
setError('com_error_files_process');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { MessageSquareQuote, ArrowRightToLine, Settings2, Bookmark } from 'lucide-react';
|
||||
import { MessageSquareQuote, ArrowRightToLine, Settings2, Database, Bookmark } from 'lucide-react';
|
||||
import {
|
||||
isAssistantsEndpoint,
|
||||
isAgentsEndpoint,
|
||||
|
|
@ -12,11 +12,15 @@ import type { TInterfaceConfig, TEndpointsConfig } from 'librechat-data-provider
|
|||
import type { NavLink } from '~/common';
|
||||
import AgentPanelSwitch from '~/components/SidePanel/Agents/AgentPanelSwitch';
|
||||
import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel';
|
||||
import MemoryViewer from '~/components/SidePanel/Memories/MemoryViewer';
|
||||
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
|
||||
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||
import { Blocks, AttachmentIcon } from '~/components/svg';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPIcon from '~/components/ui/MCPIcon';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
|
||||
export default function useSideNavLinks({
|
||||
|
|
@ -42,6 +46,14 @@ export default function useSideNavLinks({
|
|||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasAccessToMemories = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasAccessToReadMemories = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.READ,
|
||||
});
|
||||
const hasAccessToAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.USE,
|
||||
|
|
@ -50,6 +62,7 @@ export default function useSideNavLinks({
|
|||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const Links = useMemo(() => {
|
||||
const links: NavLink[] = [];
|
||||
|
|
@ -97,6 +110,16 @@ export default function useSideNavLinks({
|
|||
});
|
||||
}
|
||||
|
||||
if (hasAccessToMemories && hasAccessToReadMemories) {
|
||||
links.push({
|
||||
title: 'com_ui_memories',
|
||||
label: '',
|
||||
icon: Database,
|
||||
id: 'memories',
|
||||
Component: MemoryViewer,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
interfaceConfig.parameters === true &&
|
||||
isParamEndpoint(endpoint ?? '', endpointType ?? '') === true &&
|
||||
|
|
@ -130,6 +153,21 @@ export default function useSideNavLinks({
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
startupConfig?.mcpServers &&
|
||||
Object.values(startupConfig.mcpServers).some(
|
||||
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
|
||||
)
|
||||
) {
|
||||
links.push({
|
||||
title: 'com_nav_setting_mcp',
|
||||
label: '',
|
||||
icon: MCPIcon,
|
||||
id: 'mcp-settings',
|
||||
Component: MCPPanel,
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
title: 'com_sidepanel_hide_panel',
|
||||
label: '',
|
||||
|
|
@ -147,9 +185,12 @@ export default function useSideNavLinks({
|
|||
endpoint,
|
||||
hasAccessToAgents,
|
||||
hasAccessToPrompts,
|
||||
hasAccessToMemories,
|
||||
hasAccessToReadMemories,
|
||||
hasAccessToBookmarks,
|
||||
hasAccessToCreateAgents,
|
||||
hidePanel,
|
||||
startupConfig,
|
||||
]);
|
||||
|
||||
return Links;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useSetRecoilState } from 'recoil';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import type { TAttachment, EventSubmission } from 'librechat-data-provider';
|
||||
import { QueryKeys, Tools } from 'librechat-data-provider';
|
||||
import type { TAttachment, EventSubmission, MemoriesResponse } from 'librechat-data-provider';
|
||||
import { handleMemoryArtifact } from '~/utils/memory';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useAttachmentHandler(queryClient?: QueryClient) {
|
||||
|
|
@ -16,6 +17,18 @@ export default function useAttachmentHandler(queryClient?: QueryClient) {
|
|||
});
|
||||
}
|
||||
|
||||
if (queryClient && data.type === Tools.memory && data[Tools.memory]) {
|
||||
const memoryArtifact = data[Tools.memory];
|
||||
|
||||
queryClient.setQueryData([QueryKeys.memories], (oldData: MemoriesResponse | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
return handleMemoryArtifact({ memoryArtifact, currentData: oldData }) || oldData;
|
||||
});
|
||||
}
|
||||
|
||||
setAttachmentsMap((prevMap) => {
|
||||
const messageAttachments =
|
||||
(prevMap as Record<string, TAttachment[] | undefined>)[messageId] || [];
|
||||
|
|
|
|||
16
client/src/hooks/usePersonalizationAccess.ts
Normal file
16
client/src/hooks/usePersonalizationAccess.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import useHasAccess from './Roles/useHasAccess';
|
||||
|
||||
export default function usePersonalizationAccess() {
|
||||
const hasMemoryOptOut = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.OPT_OUT,
|
||||
});
|
||||
|
||||
const hasAnyPersonalizationFeature = hasMemoryOptOut;
|
||||
|
||||
return {
|
||||
hasMemoryOptOut,
|
||||
hasAnyPersonalizationFeature,
|
||||
};
|
||||
}
|
||||
|
|
@ -17,9 +17,16 @@
|
|||
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
|
||||
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
|
||||
"com_agents_instructions_placeholder": "The system instructions that the agent uses",
|
||||
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
|
||||
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
|
||||
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
|
||||
"com_agents_mcp_name_placeholder": "Custom Tool",
|
||||
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
|
||||
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
|
||||
"com_agents_missing_provider_model": "Please select a provider and model before creating an agent.",
|
||||
"com_agents_name_placeholder": "Optional: The name of the agent",
|
||||
"com_agents_no_access": "You don't have access to edit this agent.",
|
||||
"com_agents_no_agent_id_error": "No agent ID found. Please ensure the agent is created first.",
|
||||
"com_agents_not_available": "Agent Not Available",
|
||||
"com_agents_search_info": "When enabled, allows your agent to search the web for up-to-date information. Requires a valid API key.",
|
||||
"com_agents_search_name": "Search agents by name",
|
||||
|
|
@ -275,6 +282,7 @@
|
|||
"com_error_files_upload": "An error occurred while uploading the file.",
|
||||
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
|
||||
"com_error_files_validation": "An error occurred while validating the file.",
|
||||
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
|
||||
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
|
||||
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
|
||||
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
|
||||
|
|
@ -287,6 +295,7 @@
|
|||
"com_files_table": "something needs to go here. was empty",
|
||||
"com_generated_files": "Generated files:",
|
||||
"com_hide_examples": "Hide Examples",
|
||||
"com_info_heic_converting": "Converting HEIC image to JPEG...",
|
||||
"com_nav_2fa": "Two-Factor Authentication (2FA)",
|
||||
"com_nav_account_settings": "Account Settings",
|
||||
"com_nav_always_make_prod": "Always make new versions production",
|
||||
|
|
@ -372,6 +381,7 @@
|
|||
"com_nav_font_size_xs": "Extra Small",
|
||||
"com_nav_help_faq": "Help & FAQ",
|
||||
"com_nav_hide_panel": "Hide right-most side panel",
|
||||
"com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)",
|
||||
"com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat",
|
||||
"com_nav_info_code_artifacts_agent": "Enables the use of code artifacts for this agent. By default, additional instructions specific to the use of artifacts are added, unless \"Custom Prompt Mode\" is enabled.",
|
||||
"com_nav_info_custom_prompt_mode": "When enabled, the default artifacts system prompt will not be included. All artifact-generating instructions must be provided manually in this mode.",
|
||||
|
|
@ -419,6 +429,8 @@
|
|||
"com_nav_log_out": "Log out",
|
||||
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
|
||||
"com_nav_maximize_chat_space": "Maximize chat space",
|
||||
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
|
||||
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
|
||||
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
||||
"com_nav_my_files": "My Files",
|
||||
"com_nav_not_supported": "Not Supported",
|
||||
|
|
@ -443,6 +455,8 @@
|
|||
"com_nav_setting_chat": "Chat",
|
||||
"com_nav_setting_data": "Data controls",
|
||||
"com_nav_setting_general": "General",
|
||||
"com_nav_setting_mcp": "MCP Settings",
|
||||
"com_nav_setting_personalization": "Personalization",
|
||||
"com_nav_setting_speech": "Speech",
|
||||
"com_nav_settings": "Settings",
|
||||
"com_nav_shared_links": "Shared links",
|
||||
|
|
@ -475,6 +489,9 @@
|
|||
"com_sidepanel_conversation_tags": "Bookmarks",
|
||||
"com_sidepanel_hide_panel": "Hide Panel",
|
||||
"com_sidepanel_manage_files": "Manage Files",
|
||||
"com_sidepanel_mcp_enter_value": "Enter value for {{0}}",
|
||||
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
|
||||
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
|
||||
"com_sidepanel_parameters": "Parameters",
|
||||
"com_sources_image_alt": "Search result image",
|
||||
"com_sources_more_sources": "+{{count}} sources",
|
||||
|
|
@ -495,6 +512,8 @@
|
|||
"com_ui_accept": "I accept",
|
||||
"com_ui_action_button": "Action Button",
|
||||
"com_ui_add": "Add",
|
||||
"com_ui_add_mcp": "Add MCP",
|
||||
"com_ui_add_mcp_server": "Add MCP Server",
|
||||
"com_ui_add_model_preset": "Add a model or preset for an additional response",
|
||||
"com_ui_add_multi_conversation": "Add multi-conversation",
|
||||
"com_ui_adding_details": "Adding details",
|
||||
|
|
@ -563,8 +582,10 @@
|
|||
"com_ui_auth_url": "Authorization URL",
|
||||
"com_ui_authentication": "Authentication",
|
||||
"com_ui_authentication_type": "Authentication Type",
|
||||
"com_ui_available_tools": "Available Tools",
|
||||
"com_ui_avatar": "Avatar",
|
||||
"com_ui_azure": "Azure",
|
||||
"com_ui_back": "Back",
|
||||
"com_ui_back_to_chat": "Back to Chat",
|
||||
"com_ui_back_to_prompts": "Back to Prompts",
|
||||
"com_ui_backup_codes": "Backup Codes",
|
||||
|
|
@ -604,11 +625,13 @@
|
|||
"com_ui_client_secret": "Client Secret",
|
||||
"com_ui_close": "Close",
|
||||
"com_ui_close_menu": "Close Menu",
|
||||
"com_ui_close_window": "Close Window",
|
||||
"com_ui_code": "Code",
|
||||
"com_ui_collapse_chat": "Collapse Chat",
|
||||
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
|
||||
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
|
||||
"com_ui_complete_setup": "Complete Setup",
|
||||
"com_ui_configure_mcp_variables_for": "Configure Variables for {{0}}",
|
||||
"com_ui_confirm_action": "Confirm Action",
|
||||
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
|
||||
"com_ui_confirm_change": "Confirm Change",
|
||||
|
|
@ -623,8 +646,10 @@
|
|||
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
||||
"com_ui_create": "Create",
|
||||
"com_ui_create_link": "Create link",
|
||||
"com_ui_create_memory": "Create Memory",
|
||||
"com_ui_create_prompt": "Create Prompt",
|
||||
"com_ui_creating_image": "Creating image. May take a moment",
|
||||
"com_ui_current": "Current",
|
||||
"com_ui_currently_production": "Currently in production",
|
||||
"com_ui_custom": "Custom",
|
||||
"com_ui_custom_header_name": "Custom Header Name",
|
||||
|
|
@ -657,13 +682,20 @@
|
|||
"com_ui_delete_confirm": "This will delete",
|
||||
"com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.",
|
||||
"com_ui_delete_conversation": "Delete chat?",
|
||||
"com_ui_delete_mcp": "Delete MCP",
|
||||
"com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?",
|
||||
"com_ui_delete_mcp_error": "Failed to delete MCP server",
|
||||
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
||||
"com_ui_delete_memory": "Delete Memory",
|
||||
"com_ui_delete_prompt": "Delete Prompt?",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_delete_tool": "Delete Tool",
|
||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||
"com_ui_deleted": "Deleted",
|
||||
"com_ui_descending": "Desc",
|
||||
"com_ui_description": "Description",
|
||||
"com_ui_description_placeholder": "Optional: Enter a description to display for the prompt",
|
||||
"com_ui_deselect_all": "Deselect All",
|
||||
"com_ui_disabling": "Disabling...",
|
||||
"com_ui_download": "Download",
|
||||
"com_ui_download_artifact": "Download Artifact",
|
||||
|
|
@ -679,15 +711,20 @@
|
|||
"com_ui_duplication_success": "Successfully duplicated conversation",
|
||||
"com_ui_edit": "Edit",
|
||||
"com_ui_edit_editing_image": "Editing image",
|
||||
"com_ui_edit_mcp_server": "Edit MCP Server",
|
||||
"com_ui_edit_memory": "Edit Memory",
|
||||
"com_ui_empty_category": "-",
|
||||
"com_ui_endpoint": "Endpoint",
|
||||
"com_ui_endpoint_menu": "LLM Endpoint Menu",
|
||||
"com_ui_enter": "Enter",
|
||||
"com_ui_enter_api_key": "Enter API Key",
|
||||
"com_ui_enter_key": "Enter key",
|
||||
"com_ui_enter_openapi_schema": "Enter your OpenAPI schema here",
|
||||
"com_ui_enter_value": "Enter value",
|
||||
"com_ui_error": "Error",
|
||||
"com_ui_error_connection": "Error connecting to server, try refreshing the page.",
|
||||
"com_ui_error_save_admin_settings": "There was an error saving your admin settings.",
|
||||
"com_ui_error_updating_preferences": "Error updating preferences",
|
||||
"com_ui_examples": "Examples",
|
||||
"com_ui_expand_chat": "Expand Chat",
|
||||
"com_ui_export_convo_modal": "Export Conversation Modal",
|
||||
|
|
@ -708,6 +745,7 @@
|
|||
"com_ui_feedback_tag_other": "Other issue",
|
||||
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
|
||||
"com_ui_field_required": "This field is required",
|
||||
"com_ui_file_size": "File Size",
|
||||
"com_ui_files": "Files",
|
||||
"com_ui_filter_prompts": "Filter Prompts",
|
||||
"com_ui_filter_prompts_name": "Filter prompts by name",
|
||||
|
|
@ -741,6 +779,7 @@
|
|||
"com_ui_generate_backup": "Generate Backup Codes",
|
||||
"com_ui_generate_qrcode": "Generate QR Code",
|
||||
"com_ui_generating": "Generating...",
|
||||
"com_ui_generation_settings": "Generation Settings",
|
||||
"com_ui_getting_started": "Getting Started",
|
||||
"com_ui_global_group": "something needs to go here. was empty",
|
||||
"com_ui_go_back": "Go back",
|
||||
|
|
@ -749,10 +788,13 @@
|
|||
"com_ui_good_evening": "Good evening",
|
||||
"com_ui_good_morning": "Good morning",
|
||||
"com_ui_happy_birthday": "It's my 1st birthday!",
|
||||
"com_ui_hide_image_details": "Hide Image Details",
|
||||
"com_ui_hide_qr": "Hide QR Code",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_icon": "Icon",
|
||||
"com_ui_idea": "Ideas",
|
||||
"com_ui_image_created": "Image created",
|
||||
"com_ui_image_details": "Image Details",
|
||||
"com_ui_image_edited": "Image edited",
|
||||
"com_ui_image_gen": "Image Gen",
|
||||
"com_ui_import": "Import",
|
||||
|
|
@ -764,6 +806,7 @@
|
|||
"com_ui_include_shadcnui_agent": "Include shadcn/ui instructions",
|
||||
"com_ui_input": "Input",
|
||||
"com_ui_instructions": "Instructions",
|
||||
"com_ui_key": "Key",
|
||||
"com_ui_late_night": "Happy late night",
|
||||
"com_ui_latest_footer": "Every AI for Everyone.",
|
||||
"com_ui_latest_production_version": "Latest production version",
|
||||
|
|
@ -776,7 +819,25 @@
|
|||
"com_ui_logo": "{{0}} Logo",
|
||||
"com_ui_manage": "Manage",
|
||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||
"com_ui_mcp_dialog_desc": "Please enter the necessary information below.",
|
||||
"com_ui_mcp_enter_var": "Enter value for {{0}}",
|
||||
"com_ui_mcp_server_not_found": "Server not found.",
|
||||
"com_ui_mcp_servers": "MCP Servers",
|
||||
"com_ui_mcp_url": "MCP Server URL",
|
||||
"com_ui_memories": "Memories",
|
||||
"com_ui_memories_allow_create": "Allow creating Memories",
|
||||
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
|
||||
"com_ui_memories_allow_read": "Allow reading Memories",
|
||||
"com_ui_memories_allow_update": "Allow updating Memories",
|
||||
"com_ui_memories_allow_use": "Allow using Memories",
|
||||
"com_ui_memories_filter": "Filter memories...",
|
||||
"com_ui_memory": "Memory",
|
||||
"com_ui_memory_created": "Memory created successfully",
|
||||
"com_ui_memory_deleted": "Memory deleted",
|
||||
"com_ui_memory_deleted_items": "Deleted Memories",
|
||||
"com_ui_memory_key_exists": "A memory with this key already exists. Please use a different key.",
|
||||
"com_ui_memory_updated": "Updated saved memory",
|
||||
"com_ui_memory_updated_items": "Updated Memories",
|
||||
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
|
||||
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
|
||||
"com_ui_misc": "Misc.",
|
||||
|
|
@ -795,17 +856,30 @@
|
|||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes to update",
|
||||
"com_ui_no_data": "something needs to go here. was empty",
|
||||
"com_ui_no_personalization_available": "No personalization options are currently available",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
||||
"com_ui_none": "None",
|
||||
"com_ui_not_used": "Not Used",
|
||||
"com_ui_nothing_found": "Nothing found",
|
||||
"com_ui_oauth": "OAuth",
|
||||
"com_ui_oauth_connected_to": "Connected to",
|
||||
"com_ui_oauth_error_callback_failed": "Authentication callback failed. Please try again.",
|
||||
"com_ui_oauth_error_generic": "Authentication failed. Please try again.",
|
||||
"com_ui_oauth_error_invalid_state": "Invalid state parameter. Please try again.",
|
||||
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
|
||||
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
|
||||
"com_ui_oauth_error_title": "Authentication Failed",
|
||||
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
|
||||
"com_ui_oauth_success_title": "Authentication Successful",
|
||||
"com_ui_of": "of",
|
||||
"com_ui_off": "Off",
|
||||
"com_ui_on": "On",
|
||||
"com_ui_openai": "OpenAI",
|
||||
"com_ui_optional": "(optional)",
|
||||
"com_ui_page": "Page",
|
||||
"com_ui_preferences_updated": "Preferences updated successfully",
|
||||
"com_ui_prev": "Prev",
|
||||
"com_ui_preview": "Preview",
|
||||
"com_ui_privacy_policy": "Privacy policy",
|
||||
|
|
@ -824,8 +898,11 @@
|
|||
"com_ui_prompts_allow_use": "Allow using Prompts",
|
||||
"com_ui_prompts_settings": "Prompt Settings",
|
||||
"com_ui_provider": "Provider",
|
||||
"com_ui_quality": "Quality",
|
||||
"com_ui_read_aloud": "Read aloud",
|
||||
"com_ui_redirecting_to_provider": "Redirecting to {{0}}, please wait...",
|
||||
"com_ui_reference_saved_memories": "Reference saved memories",
|
||||
"com_ui_reference_saved_memories_description": "Allow the assistant to reference and use your saved memories when responding",
|
||||
"com_ui_refresh_link": "Refresh link",
|
||||
"com_ui_regenerate": "Regenerate",
|
||||
"com_ui_regenerate_backup": "Regenerate Backup Codes",
|
||||
|
|
@ -852,11 +929,14 @@
|
|||
"com_ui_save_badge_changes": "Save badge changes?",
|
||||
"com_ui_save_submit": "Save & Submit",
|
||||
"com_ui_saved": "Saved!",
|
||||
"com_ui_saving": "Saving...",
|
||||
"com_ui_schema": "Schema",
|
||||
"com_ui_scope": "Scope",
|
||||
"com_ui_search": "Search",
|
||||
"com_ui_seconds": "seconds",
|
||||
"com_ui_secret_key": "Secret Key",
|
||||
"com_ui_select": "Select",
|
||||
"com_ui_select_all": "Select All",
|
||||
"com_ui_select_file": "Select a file",
|
||||
"com_ui_select_model": "Select a model",
|
||||
"com_ui_select_provider": "Select a provider",
|
||||
|
|
@ -882,16 +962,11 @@
|
|||
"com_ui_shop": "Shopping",
|
||||
"com_ui_show": "Show",
|
||||
"com_ui_show_all": "Show All",
|
||||
"com_ui_show_image_details": "Show Image Details",
|
||||
"com_ui_show_qr": "Show QR Code",
|
||||
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
|
||||
"com_ui_simple": "Simple",
|
||||
"com_ui_size": "Size",
|
||||
"com_ui_quality": "Quality",
|
||||
"com_ui_generation_settings": "Generation Settings",
|
||||
"com_ui_image_details": "Image Details",
|
||||
"com_ui_show_image_details": "Show Image Details",
|
||||
"com_ui_hide_image_details": "Hide Image Details",
|
||||
"com_ui_file_size": "File Size",
|
||||
"com_ui_special_var_current_date": "Current Date",
|
||||
"com_ui_special_var_current_datetime": "Current Date & Time",
|
||||
"com_ui_special_var_current_user": "Current User",
|
||||
|
|
@ -909,15 +984,23 @@
|
|||
"com_ui_terms_of_service": "Terms of service",
|
||||
"com_ui_thinking": "Thinking...",
|
||||
"com_ui_thoughts": "Thoughts",
|
||||
"com_ui_token": "token",
|
||||
"com_ui_token_exchange_method": "Token Exchange Method",
|
||||
"com_ui_token_url": "Token URL",
|
||||
"com_ui_tokens": "tokens",
|
||||
"com_ui_tool_collection_prefix": "A collection of tools from",
|
||||
"com_ui_tool_info": "Tool Information",
|
||||
"com_ui_tool_more_info": "More information about this tool",
|
||||
"com_ui_tools": "Tools",
|
||||
"com_ui_travel": "Travel",
|
||||
"com_ui_trust_app": "I trust this application",
|
||||
"com_ui_unarchive": "Unarchive",
|
||||
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
||||
"com_ui_unknown": "Unknown",
|
||||
"com_ui_untitled": "Untitled",
|
||||
"com_ui_update": "Update",
|
||||
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
|
||||
"com_ui_update_mcp_success": "Successfully created or updated MCP",
|
||||
"com_ui_upload": "Upload",
|
||||
"com_ui_upload_code_files": "Upload for Code Interpreter",
|
||||
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
|
||||
|
|
@ -932,16 +1015,20 @@
|
|||
"com_ui_upload_ocr_text": "Upload as Text",
|
||||
"com_ui_upload_success": "Successfully uploaded file",
|
||||
"com_ui_upload_type": "Select Upload Type",
|
||||
"com_ui_usage": "Usage",
|
||||
"com_ui_use_2fa_code": "Use 2FA Code Instead",
|
||||
"com_ui_use_backup_code": "Use Backup Code Instead",
|
||||
"com_ui_use_memory": "Use memory",
|
||||
"com_ui_use_micrphone": "Use microphone",
|
||||
"com_ui_use_prompt": "Use prompt",
|
||||
"com_ui_used": "Used",
|
||||
"com_ui_value": "Value",
|
||||
"com_ui_variables": "Variables",
|
||||
"com_ui_variables_info": "Use double braces in your text to create variables, e.g. `{{example variable}}`, to later fill when using the prompt.",
|
||||
"com_ui_verify": "Verify",
|
||||
"com_ui_version_var": "Version {{0}}",
|
||||
"com_ui_versions": "Versions",
|
||||
"com_ui_view_memory": "View Memory",
|
||||
"com_ui_view_source": "View source chat",
|
||||
"com_ui_web_search": "Web Search",
|
||||
"com_ui_web_search_api_subtitle": "Search the web for up-to-date information",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
|
||||
import {
|
||||
Login,
|
||||
Registration,
|
||||
RequestPasswordReset,
|
||||
ResetPassword,
|
||||
VerifyEmail,
|
||||
Registration,
|
||||
ResetPassword,
|
||||
ApiErrorWatcher,
|
||||
TwoFactorScreen,
|
||||
RequestPasswordReset,
|
||||
} from '~/components/Auth';
|
||||
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
|
||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||
import RouteErrorBoundary from './RouteErrorBoundary';
|
||||
import StartupLayout from './Layouts/Startup';
|
||||
|
|
@ -31,6 +32,20 @@ export const router = createBrowserRouter([
|
|||
element: <ShareRoute />,
|
||||
errorElement: <RouteErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: 'oauth',
|
||||
errorElement: <RouteErrorBoundary />,
|
||||
children: [
|
||||
{
|
||||
path: 'success',
|
||||
element: <OAuthSuccess />,
|
||||
},
|
||||
{
|
||||
path: 'error',
|
||||
element: <OAuthError />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <StartupLayout />,
|
||||
|
|
|
|||
|
|
@ -2720,6 +2720,20 @@ html {
|
|||
.shimmer {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(33, 33, 33) 25%,
|
||||
rgba(129, 130, 134, 0.18) 50%,
|
||||
rgb(33, 33, 33) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 4s linear infinite;
|
||||
}
|
||||
|
||||
.dark .shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.8) 25%,
|
||||
|
|
@ -2733,20 +2747,6 @@ html {
|
|||
animation: shimmer 4s linear infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255) 25%,
|
||||
rgba(129, 130, 134, 0.18) 50%,
|
||||
rgb(255, 255, 255) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 4s linear infinite;
|
||||
}
|
||||
|
||||
.custom-style-2 {
|
||||
padding: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
alternateName,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
LocalStorageKeys,
|
||||
defaultAgentFormValues,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Agent, TFile } from 'librechat-data-provider';
|
||||
import type { DropdownValueSetter, TAgentOption, ExtendedFile } from '~/common';
|
||||
|
|
@ -42,6 +44,16 @@ export const createProviderOption = (provider: string) => ({
|
|||
value: provider,
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets default agent form values with localStorage values for model and provider.
|
||||
* This is used to initialize agent forms with the last used model and provider.
|
||||
**/
|
||||
export const getDefaultAgentFormValues = () => ({
|
||||
...defaultAgentFormValues,
|
||||
model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '',
|
||||
provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''),
|
||||
});
|
||||
|
||||
export const processAgentOption = ({
|
||||
agent: _agent,
|
||||
fileMap,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue