mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20:15 +01:00
🖥️ feat: Code Interpreter API for Non-Agent Endpoints (#6803)
* fix: Prevent parsing 'undefined' string in useLocalStorage initialization * feat: first pass, code interpreter badge * feat: Integrate API key authentication and default checked value in Code Interpreter Badge * refactor: Rename showMCPServers to showEphemeralBadges and update related components, memoize values in useChatBadges * refactor: Enhance AttachFileChat to support ephemeral agents in file attachment logic * fix: Add baseURL configuration option to legacy function call * refactor: Update dependency array in useDragHelpers to include handleFiles * refactor: Update isEphemeralAgent function to accept optional endpoint parameter * refactor: Update file handling to support ephemeral agents in AttachFileMenu and useDragHelpers * fix: improve compatibility issues with OpenAI usage field handling in createRun function * refactor: usage field compatibility * fix: ensure mcp servers are no longer "selected" if mcp servers are now unavailable
This commit is contained in:
parent
5d668748f9
commit
24c0433dcf
19 changed files with 311 additions and 48 deletions
|
|
@ -10,6 +10,7 @@ import React, {
|
|||
} from 'react';
|
||||
import { useRecoilValue, useRecoilCallback } from 'recoil';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import CodeInterpreter from './CodeInterpreter';
|
||||
import type { BadgeItem } from '~/common';
|
||||
import { useChatBadges } from '~/hooks';
|
||||
import { Badge } from '~/components/ui';
|
||||
|
|
@ -17,7 +18,7 @@ import MCPSelect from './MCPSelect';
|
|||
import store from '~/store';
|
||||
|
||||
interface BadgeRowProps {
|
||||
showMCPServers?: boolean;
|
||||
showEphemeralBadges?: boolean;
|
||||
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
|
||||
onToggle?: (badgeId: string, currentActive: boolean) => void;
|
||||
conversationId?: string | null;
|
||||
|
|
@ -131,7 +132,13 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
|
|||
}
|
||||
};
|
||||
|
||||
function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat }: BadgeRowProps) {
|
||||
function BadgeRow({
|
||||
showEphemeralBadges,
|
||||
conversationId,
|
||||
onChange,
|
||||
onToggle,
|
||||
isInChat,
|
||||
}: BadgeRowProps) {
|
||||
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
|
||||
const [dragState, dispatch] = useReducer(dragReducer, {
|
||||
draggedBadge: null,
|
||||
|
|
@ -146,7 +153,7 @@ function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat
|
|||
const animationFrame = useRef<number | null>(null);
|
||||
const containerRectRef = useRef<DOMRect | null>(null);
|
||||
|
||||
const allBadges = useChatBadges() || [];
|
||||
const allBadges = useChatBadges();
|
||||
const isEditing = useRecoilValue(store.isEditingBadges);
|
||||
|
||||
const badges = useMemo(
|
||||
|
|
@ -345,7 +352,12 @@ function BadgeRow({ showMCPServers, conversationId, onChange, onToggle, isInChat
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{showMCPServers === true && <MCPSelect conversationId={conversationId} />}
|
||||
{showEphemeralBadges === true && (
|
||||
<>
|
||||
<CodeInterpreter conversationId={conversationId} />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
</>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
<div
|
||||
className="ghost-badge h-full"
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
<AttachFileChat disableInputs={disableInputs} />
|
||||
</div>
|
||||
<BadgeRow
|
||||
showMCPServers={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
conversationId={conversation?.conversationId ?? Constants.NEW_CONVO}
|
||||
onChange={setBadges}
|
||||
isInChat={
|
||||
|
|
|
|||
104
client/src/components/Chat/Input/CodeInterpreter.tsx
Normal file
104
client/src/components/Chat/Input/CodeInterpreter.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { TerminalSquareIcon } from 'lucide-react';
|
||||
import {
|
||||
Tools,
|
||||
AuthType,
|
||||
Constants,
|
||||
LocalStorageKeys,
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
} from 'librechat-data-provider';
|
||||
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
|
||||
import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks';
|
||||
import CheckboxButton from '~/components/ui/CheckboxButton';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
const canRunCode = useHasAccess({
|
||||
permissionType: PermissionTypes.RUN_CODE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const isCodeToggleEnabled = useMemo(() => {
|
||||
return ephemeralAgent?.execute_code ?? false;
|
||||
}, [ephemeralAgent?.execute_code]);
|
||||
|
||||
const { data } = useVerifyAgentToolAuth(
|
||||
{ toolId: Tools.execute_code },
|
||||
{
|
||||
retry: 1,
|
||||
},
|
||||
);
|
||||
const authType = useMemo(() => data?.message ?? false, [data?.message]);
|
||||
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
|
||||
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
|
||||
useCodeApiKeyForm({});
|
||||
|
||||
const setValue = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
execute_code: isChecked,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [runCode, setRunCode] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
|
||||
isCodeToggleEnabled,
|
||||
setValue,
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
if (!isAuthenticated) {
|
||||
setIsDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setRunCode(isChecked);
|
||||
},
|
||||
[setRunCode, setIsDialogOpen, isAuthenticated],
|
||||
);
|
||||
|
||||
const debouncedChange = useMemo(
|
||||
() => debounce(handleChange, 50, { leading: true }),
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
if (!canRunCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
defaultChecked={runCode}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_assistants_code_interpreter')}
|
||||
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
|
||||
icon={<TerminalSquareIcon className="icon-md" />}
|
||||
/>
|
||||
<ApiKeyDialog
|
||||
onSubmit={onSubmit}
|
||||
isOpen={isDialogOpen}
|
||||
register={methods.register}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
handleSubmit={methods.handleSubmit}
|
||||
isToolAuthenticated={isAuthenticated}
|
||||
isUserProvided={authType === AuthType.USER_PROVIDED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CodeInterpreter);
|
||||
|
|
@ -1,24 +1,31 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
Constants,
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
isEphemeralAgent,
|
||||
EndpointFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import AttachFile from './AttachFile';
|
||||
import store from '~/store';
|
||||
|
||||
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
|
||||
const key = conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key));
|
||||
const isAgents = useMemo(
|
||||
() => isAgentsEndpoint(_endpoint) || isEphemeralAgent(_endpoint, ephemeralAgent),
|
||||
[_endpoint, ephemeralAgent],
|
||||
);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
|
|||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { handleFileChange } = useFileHandling();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
});
|
||||
|
||||
const capabilities = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery } from '~/data-provider';
|
||||
|
|
@ -10,8 +10,12 @@ import { useLocalize } from '~/hooks';
|
|||
|
||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const hasSetFetched = useRef(false);
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
|
|
@ -29,10 +33,10 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
);
|
||||
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
|
||||
`${LocalStorageKeys.LAST_MCP_}${key}`,
|
||||
ephemeralAgent?.mcp ?? [],
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
);
|
||||
const { data: mcpServers } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
const { data: mcpServers, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data) => {
|
||||
const serverNames = new Set<string>();
|
||||
data.forEach((tool) => {
|
||||
|
|
@ -45,6 +49,20 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetFetched.current) {
|
||||
return;
|
||||
}
|
||||
if (!isFetched) {
|
||||
return;
|
||||
}
|
||||
hasSetFetched.current = true;
|
||||
if ((mcpServers?.length ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
setMCPValues([]);
|
||||
}, [isFetched, setMCPValues, mcpServers?.length]);
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
if (values.length === 0) {
|
||||
|
|
@ -70,8 +88,8 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
|||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
placeholder={localize('com_ui_mcp_servers')}
|
||||
popoverClassName="min-w-[200px]"
|
||||
className="badge-icon h-full min-w-[150px]"
|
||||
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-shadow md:w-full size-9 p-2 md:p-3 bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
|
|
|
|||
62
client/src/components/ui/CheckboxButton.tsx
Normal file
62
client/src/components/ui/CheckboxButton.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Checkbox, useStoreState, useCheckboxStore } from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function CheckboxButton({
|
||||
label,
|
||||
icon,
|
||||
setValue,
|
||||
className,
|
||||
defaultChecked,
|
||||
isCheckedClassName,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
defaultChecked?: boolean;
|
||||
isCheckedClassName?: string;
|
||||
setValue?: (isChecked: boolean) => void;
|
||||
}) {
|
||||
const checkbox = useCheckboxStore();
|
||||
const isChecked = useStoreState(checkbox, (state) => state?.value);
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
if (typeof isChecked !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
setValue?.(!isChecked);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (defaultChecked) {
|
||||
checkbox.setValue(defaultChecked);
|
||||
}
|
||||
}, [defaultChecked, checkbox]);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
store={checkbox}
|
||||
onChange={onChange}
|
||||
defaultChecked={defaultChecked}
|
||||
className={cn(
|
||||
// Base styling from MultiSelect's selectClassName
|
||||
'group relative inline-flex items-center justify-center gap-1.5',
|
||||
'rounded-full border border-border-medium text-sm font-medium',
|
||||
'size-9 p-2 transition-shadow md:w-full md:p-3',
|
||||
'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
|
||||
// Checked state styling
|
||||
isChecked && isCheckedClassName && isCheckedClassName,
|
||||
|
||||
// Additional custom classes
|
||||
className,
|
||||
)}
|
||||
render={<button type="button" aria-label={label} />}
|
||||
>
|
||||
{/* Icon if provided */}
|
||||
{icon && <span className="icon-md text-text-primary">{icon}</span>}
|
||||
|
||||
{/* Show the label on larger screens */}
|
||||
<span className="hidden truncate md:block">{label}</span>
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ export default function MultiSelect<T extends string>({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn('h-full', className)}>
|
||||
<div className={className}>
|
||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
||||
{label && (
|
||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||
|
|
@ -82,9 +82,10 @@ export default function MultiSelect<T extends string>({
|
|||
selectClassName,
|
||||
selectedValues.length > 0 && selectItemsClassName != null && selectItemsClassName,
|
||||
)}
|
||||
onChange={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectIcon && selectIcon}
|
||||
<span className="hidden truncate md:block">
|
||||
<span className="mr-auto hidden truncate md:block">
|
||||
{renderSelectedValues(selectedValues, placeholder)}
|
||||
</span>
|
||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue