🎉 feat: Code Interpreter API and Agents Release (#4860)

* feat: Code Interpreter API & File Search Agent Uploads

chore: add back code files

wip: first pass, abstract key dialog

refactor: influence checkbox on key changes

refactor: update localization keys for 'execute code' to 'run code'

wip: run code button

refactor: add throwError parameter to loadAuthValues and getUserPluginAuthValue functions

feat: first pass, API tool calling

fix: handle missing toolId in callTool function and return 404 for non-existent tools

feat: show code outputs

fix: improve error handling in callTool function and log errors

fix: handle potential null value for filepath in attachment destructuring

fix: normalize language before rendering and prevent null return

fix: add loading indicator in RunCode component while executing code

feat: add support for conditional code execution in Markdown components

feat: attachments

refactor: remove bash

fix: pass abort signal to graph/run

refactor: debounce and rate limit tool call

refactor: increase debounce delay for execute function

feat: set code output attachments

feat: image attachments

refactor: apply message context

refactor: pass `partIndex`

feat: toolCall schema/model/methods

feat: block indexing

feat: get tool calls

chore: imports

chore: typing

chore: condense type imports

feat: get tool calls

fix: block indexing

chore: typing

refactor: update tool calls mapping to support multiple results

fix: add unique key to nav link for rendering

wip: first pass, tool call results

refactor: update query cache from successful tool call mutation

style: improve result switcher styling

chore: note on using \`.toObject()\`

feat: add agent_id field to conversation schema

chore: typing

refactor: rename agentMap to agentsMap for consistency

feat: Agent Name as chat input placeholder

chore: bump agents

📦 chore: update @langchain dependencies to latest versions to match agents package

📦 chore: update @librechat/agents dependency to version 1.8.0

fix: Aborting agent stream removes sender; fix(bedrock): completion removes preset name label

refactor: remove direct file parameter to use req.file, add `processAgentFileUpload` for image uploads

feat: upload menu

feat: prime message_file resources

feat: implement conversation access validation in chat route

refactor: remove file parameter from processFileUpload and use req.file instead

feat: add savedMessageIds set to track saved message IDs in BaseClient, to prevent unnecessary double-write to db

feat: prevent duplicate message saves by checking savedMessageIds in AgentController

refactor: skip legacy RAG API handling for agents

feat: add files field to convoSchema

refactor: update request type annotations from Express.Request to ServerRequest in file processing functions

feat: track conversation files

fix: resendFiles, addPreviousAttachments handling

feat: add ID validation for session_id and file_id in download route

feat: entity_id for code file uploads/downloads

fix: code file edge cases

feat: delete related tool calls

feat: add stream rate handling for LLM configuration

feat: enhance system content with attached file information

fix: improve error logging in resource priming function

* WIP: PoC, sequential agents

WIP: PoC Sequential Agents, first pass content data + bump agents package

fix: package-lock

WIP: PoC, o1 support, refactor bufferString

feat: convertJsonSchemaToZod

fix: form issues and schema defining erroneous model

fix: max length issue on agent form instructions, limit conversation messages to sequential agents

feat: add abort signal support to createRun function and AgentClient

feat: PoC, hide prior sequential agent steps

fix: update parameter naming from config to metadata in event handlers for clarity, add model to usage data

refactor: use only last contentData, track model for usage data

chore: bump agents package

fix: content parts issue

refactor: filter contentParts to include tool calls and relevant indices

feat: show function calls

refactor: filter context messages to exclude tool calls when no tools are available to the agent

fix: ensure tool call content is not undefined in formatMessages

feat: add agent_id field to conversationPreset schema

feat: hide sequential agents

feat: increase upload toast duration to 10 seconds

* refactor: tool context handling & update Code API Key Dialog

feat: toolContextMap

chore: skipSpecs -> useSpecs

ci: fix handleTools tests

feat: API Key Dialog

* feat: Agent Permissions Admin Controls

feat: replace label with button for prompt permission toggle

feat: update agent permissions

feat: enable experimental agents and streamline capability configuration

feat: implement access control for agents and enhance endpoint menu items

feat: add welcome message for agent selection in localization

feat: add agents permission to access control and update version to 0.7.57

* fix: update types in useAssistantListMap and useMentions hooks for better null handling

* feat: mention agents

* fix: agent tool resource race conditions when deleting agent tool resource files

* feat: add error handling for code execution with user feedback

* refactor: rename AdminControls to AdminSettings for clarity

* style: add gap to button in AdminSettings for improved layout

* refactor: separate agent query hooks and check access to enable fetching

* fix: remove unused provider from agent initialization options, creates issue with custom endpoints

* refactor: remove redundant/deprecated modelOptions from AgentClient processes

* chore: update @librechat/agents to version 1.8.5 in package.json and package-lock.json

* fix: minor styling issues + agent panel uniformity

* fix: agent edge cases when set endpoint is no longer defined

* refactor: remove unused cleanup function call from AppService

* fix: update link in ApiKeyDialog to point to pricing page

* fix: improve type handling and layout calculations in SidePanel component

* fix: add missing localization string for agent selection in SidePanel

* chore: form styling and localizations for upload filesearch/code interpreter

* fix: model selection placeholder logic in AgentConfig component

* style: agent capabilities

* fix: add localization for provider selection and improve dropdown styling in ModelPanel

* refactor: use gpt-4o-mini > gpt-3.5-turbo

* fix: agents configuration for loadDefaultInterface and update related tests

* feat: DALLE Agents support
This commit is contained in:
Danny Avila 2024-12-04 15:48:13 -05:00 committed by GitHub
parent affcebd48c
commit 1a815f5e19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
189 changed files with 5056 additions and 1815 deletions

View file

@ -5,7 +5,6 @@ import { useChatContext, useAddedChatContext } from '~/Providers';
import { TooltipAnchor } from '~/components';
import { mainTextareaId } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
function AddMultiConvo() {
const { conversation } = useChatContext();

View file

@ -0,0 +1,100 @@
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon } from 'lucide-react';
import { EToolResources } from 'librechat-data-provider';
import { FileUpload, TooltipAnchor, DropdownPopup } from '~/components/ui';
import { AttachmentIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface AttachFileProps {
isRTL: boolean;
disabled?: boolean | null;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
setToolResource?: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const AttachFile = ({ isRTL, disabled, setToolResource, handleFileChange }: AttachFileProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const handleUploadClick = (isImage?: boolean) => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.accept = isImage === true ? 'image/*' : '';
inputRef.current.click();
inputRef.current.accept = '';
};
const dropdownItems = [
{
label: localize('com_ui_upload_image_input'),
onClick: () => {
setToolResource?.(undefined);
handleUploadClick(true);
},
icon: <ImageUpIcon className="icon-md" />,
},
{
label: localize('com_ui_upload_file_search'),
onClick: () => {
setToolResource?.(EToolResources.file_search);
handleUploadClick();
},
icon: <FileSearch className="icon-md" />,
},
{
label: localize('com_ui_upload_code_files'),
onClick: () => {
setToolResource?.(EToolResources.execute_code);
handleUploadClick();
},
icon: <TerminalSquareIcon className="icon-md" />,
},
];
const menuTrigger = (
<TooltipAnchor
render={
<Ariakit.MenuButton
disabled={isUploadDisabled}
id="attach-file-menu-button"
aria-label="Attach File Options"
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-1 md:left-2',
)}
>
<div className="flex w-full items-center justify-center gap-2">
<AttachmentIcon />
</div>
</Ariakit.MenuButton>
}
id="attach-file-menu-button"
description={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
/>
);
return (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
<div className="relative">
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</div>
</FileUpload>
);
};
export default React.memo(AttachFile);

View file

@ -1,12 +1,14 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import AttachFile from './AttachFile';
@ -20,23 +22,46 @@ function FileFormWrapper({
disableInputs: boolean;
children?: React.ReactNode;
}) {
const { handleFileChange, abortUpload } = useFileHandling();
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { handleFileChange, abortUpload, setToolResource } = useFileHandling();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const isRTL = chatDirection === 'rtl';
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
const renderAttachFile = () => {
if (isAgents) {
return (
<AttachFileMenu
isRTL={isRTL}
disabled={disableInputs}
setToolResource={setToolResource}
handleFileChange={handleFileChange}
/>
);
}
if (endpointSupportsFiles && !isUploadDisabled) {
return (
<AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
);
}
return null;
};
return (
<>
<FileRow
@ -50,9 +75,7 @@ function FileFormWrapper({
)}
/>
{children}
{endpointSupportsFiles && !isUploadDisabled && (
<AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
)}
{renderAttachFile()}
</>
);
}

View file

@ -26,8 +26,15 @@ export default function Mention({
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const { options, presets, modelSpecs, modelsConfig, endpointsConfig, assistantListMap } =
useMentions({ assistantMap: assistantMap || {}, includeAssistants });
const {
options,
presets,
modelSpecs,
agentsList,
modelsConfig,
endpointsConfig,
assistantListMap,
} = useMentions({ assistantMap: assistantMap || {}, includeAssistants });
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
@ -62,18 +69,23 @@ export default function Mention({
}
};
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) {
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.agents) {
setSearchValue('');
setInputOptions(assistantListMap[EModelEndpoint.assistants]);
setInputOptions(agentsList ?? []);
setActiveIndex(0);
inputRef.current?.focus();
} else if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) {
setSearchValue('');
setInputOptions(assistantListMap[EModelEndpoint.assistants] ?? []);
setActiveIndex(0);
inputRef.current?.focus();
} else if (mention.type === 'endpoint' && mention.value === EModelEndpoint.azureAssistants) {
setSearchValue('');
setInputOptions(assistantListMap[EModelEndpoint.azureAssistants]);
setInputOptions(assistantListMap[EModelEndpoint.azureAssistants] ?? []);
setActiveIndex(0);
inputRef.current?.focus();
} else if (mention.type === 'endpoint') {
const models = (modelsConfig?.[mention.value ?? ''] ?? []).map((model) => ({
const models = (modelsConfig?.[mention.value || ''] ?? []).map((model) => ({
value: mention.value,
label: model,
type: 'model',

View file

@ -1,47 +1,57 @@
import type { FC } from 'react';
import { Close } from '@radix-ui/react-popover';
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
import {
EModelEndpoint,
alternateName,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import MenuSeparator from '../UI/MenuSeparator';
import { getEndpointField } from '~/utils';
import { useHasAccess } from '~/hooks';
import MenuItem from './MenuItem';
const EndpointItems: FC<{
endpoints: EModelEndpoint[];
endpoints: Array<EModelEndpoint | undefined>;
selected: EModelEndpoint | '';
}> = ({ endpoints, selected }) => {
}> = ({ endpoints = [], selected }) => {
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const { data: endpointsConfig } = useGetEndpointsQuery();
return (
<>
{endpoints &&
endpoints.map((endpoint, i) => {
if (!endpoint) {
return null;
} else if (!endpointsConfig?.[endpoint]) {
return null;
}
const userProvidesKey: boolean | null | undefined = getEndpointField(
endpointsConfig,
endpoint,
'userProvide',
);
return (
<Close asChild key={`endpoint-${endpoint}`}>
<div key={`endpoint-${endpoint}`}>
<MenuItem
key={`endpoint-item-${endpoint}`}
title={alternateName[endpoint] || endpoint}
value={endpoint}
selected={selected === endpoint}
data-testid={`endpoint-item-${endpoint}`}
userProvidesKey={!!userProvidesKey}
// description="With DALL·E, browsing and analysis"
/>
{i !== endpoints.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
{endpoints.map((endpoint, i) => {
if (!endpoint) {
return null;
} else if (!endpointsConfig?.[endpoint]) {
return null;
}
if (endpoint === EModelEndpoint.agents && !hasAccessToAgents) {
return null;
}
const userProvidesKey: boolean | null | undefined =
getEndpointField(endpointsConfig, endpoint, 'userProvide') ?? false;
return (
<Close asChild key={`endpoint-${endpoint}`}>
<div key={`endpoint-${endpoint}`}>
<MenuItem
key={`endpoint-item-${endpoint}`}
title={alternateName[endpoint] || endpoint}
value={endpoint}
selected={selected === endpoint}
data-testid={`endpoint-item-${endpoint}`}
userProvidesKey={!!userProvidesKey}
// description="With DALL·E, browsing and analysis"
/>
{i !== endpoints.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
</>
);
};

View file

@ -4,12 +4,14 @@ import { ContentTypes } from 'librechat-data-provider';
import type { TMessageContentParts, TAttachment, Agents } from 'librechat-data-provider';
import EditTextPart from './Parts/EditTextPart';
import { mapAttachments } from '~/utils/map';
import { MessageContext } from '~/Providers';
import store from '~/store';
import Part from './Part';
type ContentPartsProps = {
content: Array<TMessageContentParts | undefined> | undefined;
messageId: string;
conversationId?: string | null;
attachments?: TAttachment[];
isCreatedByUser: boolean;
isLast: boolean;
@ -27,6 +29,7 @@ const ContentParts = memo(
({
content,
messageId,
conversationId,
attachments,
isCreatedByUser,
isLast,
@ -79,15 +82,23 @@ const ContentParts = memo(
const attachments = attachmentMap[toolCallId];
return (
<Part
part={part}
isSubmitting={isSubmitting}
attachments={attachments}
key={`display-${messageId}-${idx}`}
showCursor={idx === content.length - 1 && isLast}
messageId={messageId}
isCreatedByUser={isCreatedByUser}
/>
<MessageContext.Provider
key={`provider-${messageId}-${idx}`}
value={{
messageId,
conversationId,
partIndex: idx,
}}
>
<Part
part={part}
attachments={attachments}
isSubmitting={isSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
showCursor={idx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
</>

View file

@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useRef, useEffect } from 'react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
@ -10,10 +10,10 @@ import remarkDirective from 'remark-directive';
import type { Pluggable } from 'unified';
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
import { useToastContext, CodeBlockProvider, useCodeBlockContext } from '~/Providers';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import { useFileDownload } from '~/data-provider';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import store from '~/store';
type TCodeProps = {
@ -25,6 +25,32 @@ type TCodeProps = {
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
const isMath = lang === 'math';
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
const { getNextIndex, resetCounter } = useCodeBlockContext();
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
useEffect(() => {
resetCounter();
}, [children, resetCounter]);
if (isMath) {
return children;
} else if (isSingleLine) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
{children}
</code>
);
} else {
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} blockIndex={blockIndex} />;
}
});
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1];
if (lang === 'math') {
return children;
@ -35,7 +61,7 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
</code>
);
} else {
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} />;
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
}
});
@ -45,7 +71,11 @@ export const a: React.ElementType = memo(
const { showToast } = useToastContext();
const localize = useLocalize();
const { file_id, filename, filepath } = useMemo(() => {
const {
file_id = '',
filename = '',
filepath,
} = useMemo(() => {
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
const match = href.match(pattern);
if (match && match[0]) {
@ -164,25 +194,27 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
return (
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={remarkPlugins}
/* @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code,
a,
p,
artifact: Artifact,
} as {
[nodeType: string]: React.ElementType;
<CodeBlockProvider>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={remarkPlugins}
/* @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code,
a,
p,
artifact: Artifact,
} as {
[nodeType: string]: React.ElementType;
}
}
}
>
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
</ReactMarkdown>
>
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
</ReactMarkdown>
</CodeBlockProvider>
);
});

View file

@ -6,40 +6,51 @@ import supersub from 'remark-supersub';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified';
import { code, codeNoExecution, a, p } from './Markdown';
import { CodeBlockProvider } from '~/Providers';
import { langSubset } from '~/utils';
import { code, a, p } from './Markdown';
const MarkdownLite = memo(({ content = '' }: { content?: string }) => {
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
return (
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
const MarkdownLite = memo(
({ content = '', codeExecution = true }: { content?: string; codeExecution?: boolean }) => {
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
code,
a,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
>
{content}
</ReactMarkdown>
);
});
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
return (
<CodeBlockProvider>
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
// linkTarget="_new"
components={
{
code: codeExecution ? code : codeNoExecution,
a,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
>
{content}
</ReactMarkdown>
</CodeBlockProvider>
);
},
);
export default MarkdownLite;

View file

@ -21,143 +21,130 @@ type PartProps = {
part?: TMessageContentParts;
isSubmitting: boolean;
showCursor: boolean;
messageId: string;
isCreatedByUser: boolean;
attachments?: TAttachment[];
};
const Part = memo(
({ part, isSubmitting, attachments, showCursor, messageId, isCreatedByUser }: PartProps) => {
attachments && console.log(attachments);
if (!part) {
const Part = memo(({ part, isSubmitting, attachments, showCursor, isCreatedByUser }: PartProps) => {
if (!part) {
return null;
}
if (part.type === ContentTypes.ERROR) {
return <ErrorMessage text={part[ContentTypes.TEXT].value} className="my-2" />;
} else if (part.type === ContentTypes.TEXT) {
const text = typeof part.text === 'string' ? part.text : part.text.value;
if (typeof text !== 'string') {
return null;
}
if (part.tool_call_ids != null && !text) {
return null;
}
return (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
} else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL];
if (!toolCall) {
return null;
}
if (part.type === ContentTypes.ERROR) {
return <ErrorMessage text={part[ContentTypes.TEXT].value} className="my-2" />;
} else if (part.type === ContentTypes.TEXT) {
const text = typeof part.text === 'string' ? part.text : part.text.value;
if (typeof text !== 'string') {
return null;
}
if (part.tool_call_ids != null && !text) {
return null;
}
const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isToolCall && toolCall.name === Tools.execute_code) {
return (
<Container>
<Text
text={text}
isCreatedByUser={isCreatedByUser}
messageId={messageId}
showCursor={showCursor}
/>
</Container>
<ExecuteCode
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
/>
);
} else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL];
if (!toolCall) {
} else if (isToolCall) {
return (
<ToolCall
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return (
<CodeAnalyze
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
isSubmitting={isSubmitting}
/>
);
} else if (
toolCall.type === ToolCallTypes.RETRIEVAL ||
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
ToolCallTypes.FUNCTION in toolCall &&
imageGenTools.has(toolCall.function.name)
) {
return (
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
if (isImageVisionTool(toolCall)) {
if (isSubmitting && showCursor) {
return (
<Container>
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
}
return null;
}
const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isToolCall && toolCall.name === Tools.execute_code) {
return (
<ExecuteCode
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
/>
);
} else if (isToolCall) {
return (
<ToolCall
args={toolCall.args ?? ''}
name={toolCall.name ?? ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return (
<CodeAnalyze
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
isSubmitting={isSubmitting}
/>
);
} else if (
toolCall.type === ToolCallTypes.RETRIEVAL ||
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
ToolCallTypes.FUNCTION in toolCall &&
imageGenTools.has(toolCall.function.name)
) {
return (
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
if (isImageVisionTool(toolCall)) {
if (isSubmitting && showCursor) {
return (
<Container>
<Text
text={''}
isCreatedByUser={isCreatedByUser}
messageId={messageId}
showCursor={showCursor}
/>
</Container>
);
}
return null;
}
return (
<ToolCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
args={toolCall.function.arguments as string}
name={toolCall.function.name}
output={toolCall.function.output}
/>
);
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
return (
<Image
imagePath={imageFile.filepath}
height={height}
width={width}
altText={imageFile.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: height + 'px',
width: width + 'px',
}}
<ToolCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
args={toolCall.function.arguments as string}
name={toolCall.function.name}
output={toolCall.function.output}
/>
);
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
return (
<Image
imagePath={imageFile.filepath}
height={height}
width={width}
altText={imageFile.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: height + 'px',
width: width + 'px',
}}
/>
);
}
return null;
},
);
return null;
});
export default Part;

View file

@ -0,0 +1,19 @@
import { imageExtRegex } from 'librechat-data-provider';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import Image from '~/components/Chat/Messages/Content/Image';
export default function Attachment({ attachment }: { attachment?: TAttachment }) {
if (!attachment) {
return null;
}
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null;
if (isImage) {
return (
<Image altText={attachment.filename} imagePath={filepath} height={height} width={width} />
);
}
return null;
}

View file

@ -1,12 +1,11 @@
import React, { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CodeInProgress } from './CodeProgress';
import { imageExtRegex } from 'librechat-data-provider';
import type { TFile, TAttachment, TAttachmentMetadata } from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import Image from '~/components/Chat/Messages/Content/Image';
import { CodeInProgress } from './CodeProgress';
import Attachment from './Attachment';
import LogContent from './LogContent';
import { useProgress } from '~/hooks';
import store from '~/store';
@ -86,7 +85,10 @@ export default function ExecuteCode({
</div>
{showCode && (
<div className="code-analyze-block mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
<MarkdownLite content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''} />
<MarkdownLite
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
codeExecution={false}
/>
{output.length > 0 && (
<div className="bg-gray-700 p-4 text-xs">
<div
@ -103,25 +105,9 @@ export default function ExecuteCode({
)}
</div>
)}
{attachments?.map((attachment, index) => {
const { width, height, filepath } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename) &&
width != null &&
height != null &&
filepath != null;
if (isImage) {
return (
<Image
key={index}
altText={attachment.filename}
imagePath={filepath}
height={height}
width={width}
/>
);
}
})}
{attachments?.map((attachment, index) => (
<Attachment attachment={attachment} key={index} />
))}
</>
);
}

View file

@ -1,17 +1,26 @@
import { isAfter } from 'date-fns';
import React, { useMemo } from 'react';
import { imageExtRegex } from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
import type { TFile, TAttachment, TAttachmentMetadata } from 'librechat-data-provider';
import Image from '~/components/Chat/Messages/Content/Image';
import { useLocalize } from '~/hooks';
import LogLink from './LogLink';
interface LogContentProps {
output?: string;
renderImages?: boolean;
attachments?: TAttachment[];
}
const LogContent: React.FC<LogContentProps> = ({ output = '', attachments }) => {
type ImageAttachment = TFile &
TAttachmentMetadata & {
height: number;
width: number;
};
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
const localize = useLocalize();
const processedContent = useMemo(() => {
if (!output) {
return '';
@ -21,8 +30,29 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', attachments }) =>
return parts[0].trim();
}, [output]);
const nonImageAttachments =
attachments?.filter((file) => !imageExtRegex.test(file.filename)) || [];
const { imageAttachments, nonImageAttachments } = useMemo(() => {
const imageAtts: ImageAttachment[] = [];
const nonImageAtts: TAttachment[] = [];
attachments?.forEach((attachment) => {
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename) &&
width != null &&
height != null &&
filepath != null;
if (isImage) {
imageAtts.push(attachment as ImageAttachment);
} else {
nonImageAtts.push(attachment);
}
});
return {
imageAttachments: renderImages === true ? imageAtts : null,
nonImageAttachments: nonImageAtts,
};
}, [attachments, renderImages]);
const renderAttachment = (file: TAttachment) => {
const now = new Date();
@ -59,6 +89,18 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', attachments }) =>
))}
</div>
)}
{imageAttachments?.map((attachment, index) => {
const { width, height, filepath } = attachment;
return (
<Image
key={index}
altText={attachment.filename}
imagePath={filepath}
height={height}
width={width}
/>
);
})}
</>
);
};

View file

@ -2,15 +2,14 @@ import { memo, useMemo, ReactElement } from 'react';
import { useRecoilValue } from 'recoil';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import Markdown from '~/components/Chat/Messages/Content/Markdown';
import { useChatContext } from '~/Providers';
import { useChatContext, useMessageContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
type TextPartProps = {
text: string;
isCreatedByUser: boolean;
messageId: string;
showCursor: boolean;
isCreatedByUser: boolean;
};
type ContentType =
@ -18,7 +17,8 @@ type ContentType =
| ReactElement<React.ComponentProps<typeof MarkdownLite>>
| ReactElement;
const TextPart = memo(({ text, isCreatedByUser, messageId, showCursor }: TextPartProps) => {
const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => {
const { messageId } = useMessageContext();
const { isSubmitting, latestMessage } = useChatContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);

View file

@ -1,9 +1,11 @@
import { useMemo } from 'react';
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
import * as Popover from '@radix-ui/react-popover';
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
import type { TAttachment } from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import ProgressCircle from './ProgressCircle';
import InProgressCall from './InProgressCall';
import Attachment from './Parts/Attachment';
import CancelledIcon from './CancelledIcon';
import ProgressText from './ProgressText';
import FinishedIcon from './FinishedIcon';
@ -18,12 +20,14 @@ export default function ToolCall({
name,
args: _args = '',
output,
attachments,
}: {
initialProgress: number;
isSubmitting: boolean;
name: string;
args: string | Record<string, unknown>;
output?: string | null;
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const progress = useProgress(initialProgress);
@ -106,6 +110,9 @@ export default function ToolCall({
/>
)}
</div>
{attachments?.map((attachment, index) => (
<Attachment attachment={attachment} key={index} />
))}
</Popover.Root>
);
}

View file

@ -33,7 +33,7 @@ export default function ToolPopover({
<div tabIndex={-1}>
<div className="bg-token-surface-primary max-w-sm rounded-md p-2 shadow-[0_0_24px_0_rgba(0,0,0,0.05),inset_0_0.5px_0_0_rgba(0,0,0,0.05),0_2px_8px_0_rgba(0,0,0,0.05)]">
<div className="mb-2 text-sm font-medium dark:text-gray-100">
{domain
{domain != null && domain
? localize('com_assistants_domain_info', domain)
: localize('com_assistants_function_use', function_name)}
</div>
@ -42,7 +42,7 @@ export default function ToolPopover({
<code className="!whitespace-pre-wrap ">{formatText(input)}</code>
</div>
</div>
{output && (
{output != null && output && (
<>
<div className="mb-2 mt-2 text-sm font-medium dark:text-gray-100">
{localize('com_ui_result')}

View file

@ -82,11 +82,12 @@ export default function Message(props: TMessageProps) {
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts
content={message.content as Array<TMessageContentParts | undefined>}
messageId={message.messageId}
isCreatedByUser={message.isCreatedByUser}
isLast={isLast}
isSubmitting={isSubmitting}
messageId={message.messageId}
isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId}
content={message.content as Array<TMessageContentParts | undefined>}
/>
</div>
</div>

View file

@ -9,6 +9,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { MessageContext } from '~/Providers';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
@ -59,9 +60,10 @@ const MessageRender = memo(
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const { isCreatedByUser, error, unfinished } = msg ?? {};
const hasNoChildren = !(msg?.children?.length ?? 0);
const isLast = useMemo(
() => !msg?.children?.length && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[msg?.children, msg?.depth, latestMessage?.depth],
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[hasNoChildren, msg?.depth, latestMessage?.depth],
);
if (!msg) {
@ -122,24 +124,31 @@ const MessageRender = memo(
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
{msg.plugin && <Plugin plugin={msg.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(error ?? false)}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
<MessageContext.Provider
value={{
messageId: msg.messageId,
conversationId: conversation?.conversationId,
}}
>
{msg.plugin && <Plugin plugin={msg.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(error ?? false)}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</MessageContext.Provider>
</div>
</div>
{!msg.children?.length && (isSubmittingFamily === true || isSubmitting) ? (
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">

View file

@ -28,7 +28,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
createPresetMutation.mutate(_preset, {
onSuccess: () => {
showToast({
message: `${toastTitle} ${localize('com_endpoint_preset_saved')}`,
message: `${toastTitle} ${localize('com_ui_saved')}`,
});
onOpenChange(false); // Close the dialog on success
},

View file

@ -1,81 +1,133 @@
import copy from 'copy-to-clipboard';
import { InfoIcon } from 'lucide-react';
import React, { useRef, useState, RefObject } from 'react';
import { Tools } from 'librechat-data-provider';
import React, { useRef, useState, useMemo, useEffect } from 'react';
import type { CodeBarProps } from '~/common';
import LogContent from '~/components/Chat/Messages/Content/Parts/LogContent';
import ResultSwitcher from '~/components/Messages/Content/ResultSwitcher';
import { useToolCallsMapContext, useMessageContext } from '~/Providers';
import RunCode from '~/components/Messages/Content/RunCode';
import Clipboard from '~/components/svg/Clipboard';
import CheckMark from '~/components/svg/CheckMark';
import useLocalize from '~/hooks/useLocalize';
import cn from '~/utils/cn';
type CodeBarProps = {
lang: string;
codeRef: RefObject<HTMLElement>;
plugin?: boolean;
error?: boolean;
};
type CodeBlockProps = Pick<CodeBarProps, 'lang' | 'plugin' | 'error'> & {
type CodeBlockProps = Pick<
CodeBarProps,
'lang' | 'plugin' | 'error' | 'allowExecution' | 'blockIndex'
> & {
codeChildren: React.ReactNode;
classProp?: string;
};
const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plugin = null }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
) : (
<button
type="button"
className={cn(
'ml-auto flex gap-2',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
const CodeBar: React.FC<CodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
) : (
<div className="flex items-center justify-center gap-4">
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
<button
type="button"
className={cn(
'ml-auto flex gap-2',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
setTimeout(() => {
setIsCopied(false);
}, 3000);
}
}}
>
{isCopied ? (
<>
<CheckMark className="h-[18px] w-[18px]" />
{error === true ? '' : localize('com_ui_copied')}
</>
) : (
<>
<Clipboard />
{error === true ? '' : localize('com_ui_copy_code')}
</>
)}
</button>
)}
</div>
);
});
setTimeout(() => {
setIsCopied(false);
}, 3000);
}
}}
>
{isCopied ? (
<>
<CheckMark className="h-[18px] w-[18px]" />
{error === true ? '' : localize('com_ui_copied')}
</>
) : (
<>
<Clipboard />
{error === true ? '' : localize('com_ui_copy_code')}
</>
)}
</button>
</div>
)}
</div>
);
},
);
const CodeBlock: React.FC<CodeBlockProps> = ({
lang,
blockIndex,
codeChildren,
classProp = '',
allowExecution = true,
plugin = null,
error,
}) => {
const codeRef = useRef<HTMLElement>(null);
const toolCallsMap = useToolCallsMapContext();
const { messageId, partIndex } = useMessageContext();
const key = allowExecution
? `${messageId}_${partIndex ?? 0}_${blockIndex ?? 0}_${Tools.execute_code}`
: '';
const [currentIndex, setCurrentIndex] = useState(0);
const fetchedToolCalls = toolCallsMap?.[key];
const [toolCalls, setToolCalls] = useState(toolCallsMap?.[key] ?? null);
useEffect(() => {
if (fetchedToolCalls) {
setToolCalls(fetchedToolCalls);
setCurrentIndex(fetchedToolCalls.length - 1);
}
}, [fetchedToolCalls]);
const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]);
const next = () => {
if (!toolCalls) {
return;
}
if (currentIndex < toolCalls.length - 1) {
setCurrentIndex(currentIndex + 1);
}
};
const previous = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const isNonCode = !!(plugin === true || error === true);
const language = isNonCode ? 'json' : lang;
return (
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
<CodeBar lang={lang} codeRef={codeRef} plugin={plugin === true} error={error} />
<CodeBar
lang={lang}
error={error}
codeRef={codeRef}
blockIndex={blockIndex}
plugin={plugin === true}
allowExecution={allowExecution}
/>
<div className={cn(classProp, 'overflow-y-auto p-4')}>
<code
ref={codeRef}
@ -86,6 +138,34 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
{codeChildren}
</code>
</div>
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
<>
<div className="bg-gray-700 p-4 text-xs">
<div
className="prose flex flex-col-reverse text-white"
style={{
color: 'white',
}}
>
<pre className="shrink-0">
<LogContent
output={(currentToolCall?.result as string | undefined) ?? ''}
attachments={currentToolCall?.attachments ?? []}
renderImages={true}
/>
</pre>
</div>
</div>
{toolCalls.length > 1 && (
<ResultSwitcher
currentIndex={currentIndex}
totalCount={toolCalls.length}
onPrevious={previous}
onNext={next}
/>
)}
</>
)}
</div>
);
};

View file

@ -0,0 +1,69 @@
interface ResultSwitcherProps {
currentIndex: number;
totalCount: number;
onPrevious: () => void;
onNext: () => void;
}
const ResultSwitcher: React.FC<ResultSwitcherProps> = ({
currentIndex,
totalCount,
onPrevious,
onNext,
}) => {
if (totalCount <= 1) {
return null;
}
return (
<div className="flex items-center justify-start gap-1 self-center bg-gray-700 pb-2 text-xs">
<button
className="hover-button rounded-md p-1 text-gray-400 hover:bg-gray-700 hover:text-gray-200 disabled:hover:text-gray-400"
type="button"
onClick={onPrevious}
disabled={currentIndex === 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>
</button>
<span className="flex-shrink-0 tabular-nums">
{currentIndex + 1} / {totalCount}
</span>
<button
className="hover-button rounded-md p-1 text-gray-400 hover:bg-gray-700 hover:text-gray-200 disabled:hover:text-gray-400"
type="button"
onClick={onNext}
disabled={currentIndex === totalCount - 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>
</button>
</div>
);
};
export default ResultSwitcher;

View file

@ -0,0 +1,109 @@
import debounce from 'lodash/debounce';
import { Tools, AuthType } from 'librechat-data-provider';
import { TerminalSquareIcon, Loader } from 'lucide-react';
import React, { useMemo, useCallback, useEffect } from 'react';
import type { CodeBarProps } from '~/common';
import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useLocalize, useCodeApiKeyForm } from '~/hooks';
import { useMessageContext } from '~/Providers';
import { cn, normalizeLanguage } from '~/utils';
import { useToastContext } from '~/Providers';
const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const execute = useToolCallMutation(Tools.execute_code, {
onError: () => {
showToast({ message: localize('com_ui_run_code_error'), status: 'error' });
},
});
const { messageId, conversationId, partIndex } = useMessageContext();
const normalizedLang = useMemo(() => normalizeLanguage(lang), [lang]);
const { data } = useVerifyAgentToolAuth({ toolId: Tools.execute_code });
const authType = useMemo(() => data?.message ?? false, [data?.message]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useCodeApiKeyForm({});
const handleExecute = useCallback(async () => {
if (!isAuthenticated) {
setIsDialogOpen(true);
return;
}
const codeString: string = codeRef.current?.textContent ?? '';
if (
typeof codeString !== 'string' ||
codeString.length === 0 ||
typeof normalizedLang !== 'string' ||
normalizedLang.length === 0
) {
return;
}
execute.mutate({
partIndex,
messageId,
blockIndex,
conversationId: conversationId ?? '',
lang: normalizedLang,
code: codeString,
});
}, [
codeRef,
execute,
partIndex,
messageId,
blockIndex,
conversationId,
normalizedLang,
setIsDialogOpen,
isAuthenticated,
]);
const debouncedExecute = useMemo(
() => debounce(handleExecute, 1000, { leading: true }),
[handleExecute],
);
useEffect(() => {
return () => {
debouncedExecute.cancel();
};
}, [debouncedExecute]);
if (typeof normalizedLang !== 'string' || normalizedLang.length === 0) {
return null;
}
return (
<>
<button
type="button"
className={cn('ml-auto flex gap-2')}
onClick={debouncedExecute}
disabled={execute.isLoading}
>
{execute.isLoading ? (
<Loader className="animate-spin" size={18} />
) : (
<TerminalSquareIcon size={18} />
)}
{localize('com_ui_run_code')}
</button>
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
);
});
export default RunCode;

View file

@ -129,16 +129,17 @@ const ContentRender = memo(
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts
content={msg.content as Array<TMessageContentParts | undefined>}
messageId={msg.messageId}
isCreatedByUser={msg.isCreatedByUser}
isLast={isLast}
isSubmitting={isSubmitting}
edit={edit}
isLast={isLast}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
messageId={msg.messageId}
isSubmitting={isSubmitting}
setSiblingIdx={setSiblingIdx}
attachments={msg.attachments}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
</div>

View file

@ -29,17 +29,19 @@ const LabelController: React.FC<LabelControllerProps> = ({
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<label
<button
className="cursor-pointer select-none"
htmlFor={promptPerm}
type="button"
// htmlFor={promptPerm}
onClick={() =>
setValue(promptPerm, !getValues(promptPerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</label>
</button>
<Controller
name={promptPerm}
control={control}
@ -48,7 +50,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field?.value?.toString()}
value={field.value.toString()}
/>
)}
/>
@ -61,7 +63,7 @@ const AdminSettings = () => {
const { showToast } = useToastContext();
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_endpoint_preset_saved') });
showToast({ status: 'success', message: localize('com_ui_saved') });
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });

View file

@ -14,9 +14,9 @@ import {
replaceSpecialVars,
extractVariableInfo,
} from '~/utils';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { TextareaAutosize, InputCombobox } from '~/components/ui';
import { code } from '~/components/Chat/Messages/Content/Markdown';
type FieldType = 'text' | 'select';
@ -143,12 +143,16 @@ export default function VariableForm({
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-gray-100 p-4 text-text-secondary dark:bg-gray-700/50 sm:max-w-full md:max-h-80">
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex, { output: 'mathml' }],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
components={{ code }}
/** @ts-ignore */
components={{ code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1 max-h-[50vh] break-words"
>
{generateHighlightedMarkdown()}

View file

@ -6,7 +6,7 @@ import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import rehypeHighlight from 'rehype-highlight';
import type { TPromptGroup } from 'librechat-data-provider';
import { code } from '~/components/Chat/Messages/Content/Markdown';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { useLocalize, useAuthContext } from '~/hooks';
import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
@ -50,12 +50,20 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
</h2>
<div className="group relative min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600 sm:max-w-full">
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex, { output: 'mathml' }],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
components={{ p: PromptVariableGfm, code }}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1"
>
{mainText}

View file

@ -9,8 +9,8 @@ import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import ReactMarkdown from 'react-markdown';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
import { code } from '~/components/Chat/Messages/Content/Markdown';
import { SaveIcon, CrossIcon } from '~/components/svg';
import { TextareaAutosize } from '~/components/ui';
import { PromptVariableGfm } from './Markdown';
@ -75,7 +75,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
role="button"
className={cn(
'min-h-[8rem] w-full rounded-b-lg border border-border-medium p-4 transition-all duration-150',
{ 'bg-surface-secondary-alt cursor-pointer hover:bg-surface-tertiary': !isEditing },
{ 'cursor-pointer bg-surface-secondary-alt hover:bg-surface-tertiary': !isEditing },
)}
onClick={() => !isEditing && setIsEditing(true)}
onKeyDown={(e) => {
@ -107,9 +107,12 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
/>
) : (
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
components={{ p: PromptVariableGfm, code }}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="markdown prose dark:prose-invert light my-1 w-full break-words text-text-primary"
>
{field.value}

View file

@ -53,6 +53,7 @@ const PromptVariables = ({
) : (
<div className="flex h-7 items-center">
<span className="text-xs text-text-secondary md:text-sm">
{/** @ts-ignore */}
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_variables_info')}
</ReactMarkdown>
@ -68,6 +69,7 @@ const PromptVariables = ({
</span>
{'\u00A0'}
<span className="text-xs text-text-secondary md:text-sm">
{/** @ts-ignore */}
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_special_variables_info')}
</ReactMarkdown>
@ -79,6 +81,7 @@ const PromptVariables = ({
</span>
{'\u00A0'}
<span className="text-xs text-text-secondary md:text-sm">
{/** @ts-ignore */}
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_dropdown_variables_info')}
</ReactMarkdown>

View file

@ -6,6 +6,7 @@ import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { MessageContext } from '~/Providers';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import { cn } from '~/utils';
@ -31,10 +32,10 @@ export default function Message(props: TMessageProps) {
const {
text = '',
children,
messageId = null,
isCreatedByUser = true,
error = false,
messageId = '',
unfinished = false,
isCreatedByUser = true,
} = message;
let messageLabel = '';
@ -64,26 +65,33 @@ export default function Message(props: TMessageProps) {
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex max-w-full flex-grow flex-col gap-0">
{/* Legacy Plugins */}
{message.plugin && <Plugin plugin={message.plugin} />}
{message.content ? (
<SearchContent message={message} />
) : (
<MessageContent
edit={false}
error={error}
isLast={false}
ask={() => ({})}
text={text}
message={message}
isSubmitting={false}
enterEdit={() => ({})}
unfinished={!!unfinished}
isCreatedByUser={isCreatedByUser}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
)}
<MessageContext.Provider
value={{
messageId,
conversationId: conversation?.conversationId,
}}
>
{/* Legacy Plugins */}
{message.plugin && <Plugin plugin={message.plugin} />}
{message.content ? (
<SearchContent message={message} />
) : (
<MessageContent
edit={false}
error={error}
isLast={false}
ask={() => ({})}
text={text || ''}
message={message}
isSubmitting={false}
enterEdit={() => ({})}
unfinished={unfinished}
siblingIdx={siblingIdx ?? 0}
isCreatedByUser={isCreatedByUser}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
)}
</MessageContext.Provider>
</div>
</div>
<SubRow classes="text-xs">

View file

@ -0,0 +1,163 @@
import { useMemo, useEffect } 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 { useUpdateAgentPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { Button, Switch } from '~/components/ui';
import { useToastContext } from '~/Providers';
type FormValues = Record<Permissions, boolean>;
type LabelControllerProps = {
label: string;
agentPerm: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const defaultValues = roleDefaults[SystemRoles.USER];
const LabelController: React.FC<LabelControllerProps> = ({
control,
agentPerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<button
className="cursor-pointer select-none"
type="button"
onClick={() =>
setValue(agentPerm, !getValues(agentPerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</button>
<Controller
name={agentPerm}
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 } = useUpdateAgentPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
},
});
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: useMemo(() => {
if (roles?.[SystemRoles.USER]) {
return roles[SystemRoles.USER][PermissionTypes.AGENTS];
}
return defaultValues[PermissionTypes.AGENTS];
}, [roles]),
});
useEffect(() => {
if (roles?.[SystemRoles.USER]?.[PermissionTypes.AGENTS]) {
reset(roles[SystemRoles.USER][PermissionTypes.AGENTS]);
}
}, [roles, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData = [
{
agentPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_agents_allow_share_global'),
},
{
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
{
agentPerm: Permissions.CREATE,
label: localize('com_ui_agents_allow_create'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: SystemRoles.USER, updates: data });
};
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative my-1 h-9 w-full rounded-lg font-medium"
>
<ShieldEllipsis className="cursor-pointer" />
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-1/4 bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_agents',
)}`}</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ agentPerm, label }) => (
<LabelController
key={agentPerm}
control={control}
agentPerm={agentPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
))}
</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>
</OGDialogContent>
</OGDialog>
);
};
export default AdminSettings;

View file

@ -1,24 +1,32 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import { QueryKeys, AgentCapabilities, EModelEndpoint, SystemRoles } from 'librechat-data-provider';
import {
QueryKeys,
SystemRoles,
Permissions,
EModelEndpoint,
PermissionTypes,
AgentCapabilities,
} from 'librechat-data-provider';
import type { TConfig, TPlugin } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
import { useCreateAgentMutation, useUpdateAgentMutation } from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import { useToastContext, useFileMapContext } from '~/Providers';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { useLocalize, useAuthContext } from '~/hooks';
import { processAgentOption } from '~/utils';
import AdminSettings from './AdminSettings';
import { Spinner } from '~/components/svg';
import DeleteButton from './DeleteButton';
import AgentAvatar from './AgentAvatar';
import FileSearch from './FileSearch';
import ShareAgent from './ShareAgent';
import AgentTool from './AgentTool';
// import CodeForm from './Code/Form';
import CodeForm from './Code/Form';
import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
@ -55,6 +63,11 @@ export default function AgentConfig({
const tools = useWatch({ control, name: 'tools' });
const agent_id = useWatch({ control, name: 'id' });
const hasAccessToShareAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.SHARED_GLOBAL,
});
const toolsEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.tools),
[agentsConfig],
@ -263,7 +276,7 @@ export default function AgentConfig({
/>
</div>
{/* Instructions */}
<div className="mb-6">
<div className="mb-4">
<label className={labelClass} htmlFor="instructions">
{localize('com_ui_instructions')}
</label>
@ -275,7 +288,7 @@ export default function AgentConfig({
<textarea
{...field}
value={field.value ?? ''}
maxLength={32768}
// maxLength={32768}
className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions"
placeholder={localize('com_agents_instructions_placeholder')}
@ -297,7 +310,7 @@ export default function AgentConfig({
/>
</div>
{/* Model and Provider */}
<div className="mb-6">
<div className="mb-4">
<label className={labelClass} htmlFor="provider">
{localize('com_ui_model')} <span className="text-red-500">*</span>
</label>
@ -319,16 +332,23 @@ export default function AgentConfig({
/>
</div>
)}
<span>{model != null ? model : localize('com_ui_select_model')}</span>
<span>{model != null && model ? model : localize('com_ui_select_model')}</span>
</div>
</button>
</div>
{/* Code Execution */}
{/* {codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />} */}
{/* File Search */}
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
{(codeEnabled || fileSearchEnabled) && (
<div className="mb-4 flex w-full flex-col items-start gap-3">
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_capabilities')}
</label>
{/* Code Execution */}
{codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />}
{/* File Search */}
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
</div>
)}
{/* Agent Tools & Actions */}
<div className="mb-6">
<div className="mb-4">
<label className={labelClass}>
{`${toolsEnabled === true ? localize('com_ui_tools') : ''}
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
@ -360,7 +380,7 @@ export default function AgentConfig({
<button
type="button"
onClick={() => setShowToolDialog(true)}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
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">
@ -373,7 +393,7 @@ export default function AgentConfig({
type="button"
disabled={!agent_id}
onClick={handleAddActions}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
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">
@ -384,6 +404,7 @@ export default function AgentConfig({
</div>
</div>
</div>
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
{/* Context Button */}
<div className="flex items-center justify-end gap-2">
<DeleteButton
@ -391,7 +412,8 @@ export default function AgentConfig({
setCurrentAgentId={setCurrentAgentId}
createMutation={create}
/>
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) && (
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
hasAccessToShareAgents && (
<ShareAgent
agent_id={agent_id}
agentName={agent?.name ?? ''}
@ -401,7 +423,7 @@ export default function AgentConfig({
)}
{/* Submit Button */}
<button
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
type="submit"
disabled={create.isLoading || update.isLoading}
aria-busy={create.isLoading || update.isLoading}

View file

@ -126,6 +126,9 @@ export default function AgentPanel({
model: _model,
model_parameters,
provider: _provider,
agent_ids,
end_after_tools,
hide_sequential_outputs,
} = data;
const model = _model ?? '';
@ -143,6 +146,9 @@ export default function AgentPanel({
tools,
provider,
model_parameters,
agent_ids,
end_after_tools,
hide_sequential_outputs,
},
});
return;
@ -163,6 +169,9 @@ export default function AgentPanel({
tools,
provider,
model_parameters,
agent_ids,
end_after_tools,
hide_sequential_outputs,
});
},
[agent_id, create, update, showToast, localize],

View file

@ -58,6 +58,8 @@ export default function AgentSelect({
const capabilities: TAgentCapabilities = {
[AgentCapabilities.execute_code]: false,
[AgentCapabilities.file_search]: false,
[AgentCapabilities.end_after_tools]: false,
[AgentCapabilities.hide_sequential_outputs]: false,
};
const agentTools: string[] = [];

View file

@ -1,38 +1,39 @@
import { useState } from 'react';
import { KeyRoundIcon } from 'lucide-react';
import { AuthType, AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller, useForm, useWatch } from 'react-hook-form';
import { useFormContext, Controller, useWatch } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
Input,
OGDialog,
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
Button,
} from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useAuthCodeTool } from '~/hooks';
import { useLocalize, useCodeApiKeyForm } from '~/hooks';
import { CircleHelpIcon } from '~/components/svg';
import ApiKeyDialog from './ApiKeyDialog';
import { ESide } from '~/common';
type ApiKeyFormData = {
apiKey: string;
authType?: string | AuthType;
};
export default function Action({ authType = '', isToolAuthenticated = false }) {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const {
onSubmit,
isDialogOpen,
setIsDialogOpen,
handleRevokeApiKey,
methods: keyFormMethods,
} = useCodeApiKeyForm({
onSubmit: () => {
setValue(AgentCapabilities.execute_code, true, { shouldDirty: true });
},
onRevoke: () => {
setValue(AgentCapabilities.execute_code, false, { shouldDirty: true });
},
});
const runCodeIsEnabled = useWatch({ control, name: AgentCapabilities.execute_code });
const { installTool, removeTool } = useAuthCodeTool({ isEntityTool: true });
const { reset, register, handleSubmit } = useForm<ApiKeyFormData>();
const isUserProvided = authType === AuthType.USER_PROVIDED;
const handleCheckboxChange = (checked: boolean) => {
@ -45,18 +46,6 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
}
};
const onSubmit = (data: { apiKey: string }) => {
reset();
installTool(data.apiKey);
setIsDialogOpen(false);
};
const handleRevokeApiKey = () => {
reset();
removeTool();
setIsDialogOpen(false);
};
return (
<>
<HoverCard openDelay={50}>
@ -87,7 +76,7 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={AgentCapabilities.execute_code}
>
{localize('com_agents_execute_code')}
{localize('com_ui_run_code')}
</label>
</button>
<div className="ml-2 flex gap-2">
@ -104,48 +93,23 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{/* // TODO: add a Code Interpreter description */}
{localize('com_agents_code_interpreter')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</div>
</HoverCard>
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<OGDialogTemplate
className="w-11/12 sm:w-1/4"
title={localize('com_agents_tool_not_authenticated')}
main={
<form onSubmit={handleSubmit(onSubmit)}>
<Input
type="password"
placeholder="Enter API Key"
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('apiKey', { required: true })}
/>
</form>
}
selection={{
selectHandler: handleSubmit(onSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectText: localize('com_ui_save'),
}}
buttons={
isUserProvided &&
isToolAuthenticated && (
<Button
onClick={handleRevokeApiKey}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{localize('com_ui_revoke')}
</Button>
)
}
showCancelButton={true}
/>
</OGDialog>
<ApiKeyDialog
isOpen={isDialogOpen}
onSubmit={onSubmit}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
register={keyFormMethods.register}
isToolAuthenticated={isToolAuthenticated}
handleSubmit={keyFormMethods.handleSubmit}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
);
}

View file

@ -0,0 +1,106 @@
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
import type { ApiKeyFormData } from '~/common';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { Input, Button, OGDialog } from '~/components/ui';
import { useLocalize } from '~/hooks';
export default function ApiKeyDialog({
isOpen,
onSubmit,
onRevoke,
onOpenChange,
isUserProvided,
isToolAuthenticated,
register,
handleSubmit,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { apiKey: string }) => void;
onRevoke: () => void;
isUserProvided: boolean;
isToolAuthenticated: boolean;
register: UseFormRegister<ApiKeyFormData>;
handleSubmit: UseFormHandleSubmit<ApiKeyFormData>;
}) {
const localize = useLocalize();
const languageIcons = [
'python.svg',
'nodedotjs.svg',
'tsnode.svg',
'rust.svg',
'go.svg',
'c.svg',
'cplusplus.svg',
'php.svg',
'fortran.svg',
];
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogTemplate
className="w-11/12 sm:w-[450px]"
title=""
main={
<>
<div className="mb-4 text-center font-medium">
{localize('com_ui_librechat_code_api_title')}
</div>
<div className="mb-4 text-center text-sm">
{localize('com_ui_librechat_code_api_subtitle')}
</div>
{/* Language Icons Stack */}
<div className="mb-6">
<div className="mx-auto mb-4 flex max-w-[400px] flex-wrap justify-center gap-3">
{languageIcons.map((icon) => (
<div key={icon} className="h-6 w-6">
<img
src={`/assets/${icon}`}
alt=""
className="h-full w-full object-contain opacity-[0.85] dark:invert"
/>
</div>
))}
</div>
<a
href="https://code.librechat.ai/pricing"
target="_blank"
rel="noopener noreferrer"
className="block text-center text-[15px] font-medium text-blue-500 underline decoration-1 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_librechat_code_api_key')}
</a>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Input
type="password"
placeholder={localize('com_ui_enter_api_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('apiKey', { required: true })}
/>
</form>
</>
}
selection={{
selectHandler: handleSubmit(onSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectText: localize('com_ui_save'),
}}
buttons={
isUserProvided &&
isToolAuthenticated && (
<Button
onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{localize('com_ui_revoke')}
</Button>
)
}
showCancelButton={true}
/>
</OGDialog>
);
}

View file

@ -12,6 +12,7 @@ import type { ExtendedFile, AgentForm } from '~/common';
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
const tool_resource = EToolResources.execute_code;
@ -68,8 +69,8 @@ export default function Files({
return (
<div className="mb-2 w-full">
<div className="flex flex-col gap-4">
<div className="text-token-text-tertiary rounded-lg text-xs">
<div className="flex flex-col gap-3">
<div className="rounded-lg text-xs text-text-secondary">
{localize('com_assistants_code_interpreter_files')}
</div>
<FileRow
@ -85,10 +86,10 @@ export default function Files({
<button
type="button"
disabled={!agent_id || codeChecked === false}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
onClick={handleButtonClick}
>
<div className="flex w-full items-center justify-center gap-2">
<div className="flex w-full items-center justify-center gap-1">
<input
multiple={true}
type="file"
@ -98,7 +99,8 @@ export default function Files({
disabled={!agent_id || codeChecked === false}
onChange={handleFileChange}
/>
{localize('com_ui_upload_files')}
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
{localize('com_ui_upload_code_files')}
</div>
</button>
</div>

View file

@ -16,13 +16,18 @@ export default function CodeForm({
const { data } = useVerifyAgentToolAuth({ toolId: Tools.execute_code });
return (
<div className="mb-4">
<div className="mb-1.5 flex items-center">
<span>
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_capabilities')}
</label>
</span>
<div className="w-full">
<div className="mb-1.5 flex items-center gap-2">
<div className="flex flex-row items-center gap-1">
<div className="flex items-center gap-1">
<span className="text-token-text-primary block font-medium">
{localize('com_agents_code_interpreter_title')}
</span>
<span className="text-xs text-text-secondary">
{localize('com_agents_by_librechat')}
</span>
</div>
</div>
</div>
<div className="flex flex-col items-start gap-2">
<Action authType={data?.message} isToolAuthenticated={data?.authenticated} />

View file

@ -67,7 +67,7 @@ export default function FileSearch({
};
return (
<div className="mb-6">
<div className="w-full">
<div className="mb-1.5 flex items-center gap-2">
<span>
<label className="text-token-text-primary block font-medium">
@ -76,12 +76,12 @@ export default function FileSearch({
</span>
</div>
<FileSearchCheckbox />
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<div>
<button
type="button"
disabled={!agent_id || fileSearchChecked === false}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
onClick={handleButtonClick}
>
<div className="flex w-full items-center justify-center gap-1">
@ -95,13 +95,13 @@ export default function FileSearch({
disabled={!agent_id || fileSearchChecked === false}
onChange={handleFileChange}
/>
{localize('com_ui_upload_files')}
{localize('com_ui_upload_file_search')}
</div>
</button>
</div>
{/* Disabled Message */}
{agent_id ? null : (
<div className="text-sm text-text-secondary">
<div className="text-xs text-text-secondary">
{localize('com_agents_file_search_disabled')}
</div>
)}

View file

@ -31,14 +31,17 @@ export default function Parameters({
: (providerOption as StringOption | undefined)?.value;
return value ?? '';
}, [providerOption]);
const models = useMemo(() => (provider ? modelsData[provider] : []), [modelsData, provider]);
const models = useMemo(
() => (provider ? modelsData[provider] ?? [] : []),
[modelsData, provider],
);
useEffect(() => {
const _model = model ?? '';
if (provider && _model) {
const modelExists = models.includes(_model);
if (!modelExists) {
const newModels = modelsData[provider];
const newModels = modelsData[provider] ?? [];
setValue('model', newModels[0] ?? '');
}
}
@ -105,14 +108,16 @@ export default function Parameters({
<SelectDropDown
emptyTitle={true}
value={field.value ?? ''}
title={localize('com_ui_provider')}
placeholder={localize('com_ui_select_provider')}
searchPlaceholder={localize('com_ui_select_search_provider')}
setValue={field.onChange}
availableValues={providers}
showAbove={false}
showLabel={false}
className={cn(
cardStyle,
'flex h-[40px] w-full flex-none items-center justify-center border-none px-4 hover:cursor-pointer',
'flex h-9 w-full flex-none items-center justify-center border-none px-4 hover:cursor-pointer',
(field.value === undefined || field.value === '') &&
'border-2 border-yellow-400',
)}

View file

@ -0,0 +1,74 @@
import { AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
Checkbox,
HoverCard,
// HoverCardContent,
// HoverCardPortal,
// HoverCardTrigger,
} from '~/components/ui';
// import { CircleHelpIcon } from '~/components/svg';
// import { useLocalize } from '~/hooks';
// import { ESide } from '~/common';
export default function HideSequential() {
// const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
return (
<>
<HoverCard openDelay={50}>
<div className="my-2 flex items-center">
<Controller
name={AgentCapabilities.hide_sequential_outputs}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
/>
)}
/>
<button
type="button"
className="flex items-center space-x-2"
onClick={() =>
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
setValue(
AgentCapabilities.hide_sequential_outputs,
!getValues(AgentCapabilities.hide_sequential_outputs),
{
shouldDirty: true,
},
)
}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={AgentCapabilities.hide_sequential_outputs}
>
Hide Sequential Agent Outputs except the last agent&apos;s
</label>
{/* <HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-gray-500" />
</HoverCardTrigger> */}
</button>
{/* <HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{localize('com_agents_ttg_info')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal> */}
</div>
</HoverCard>
</>
);
}

View file

@ -0,0 +1,153 @@
import { Plus, X } from 'lucide-react';
import React, { useRef, useState } from 'react';
import { Transition } from 'react-transition-group';
import { Constants } from 'librechat-data-provider';
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
import { TooltipAnchor } from '~/components/ui';
import HideSequential from './HideSequential';
interface SequentialAgentsProps {
field: {
value: string[];
onChange: (value: string[]) => void;
};
}
const labelClass = 'mb-2 text-token-text-primary block font-medium';
const inputClass = cn(
defaultTextProps,
'flex w-full px-3 py-2 dark:border-gray-800 dark:bg-gray-800 rounded-xl mb-2',
removeFocusOutlines,
);
const maxAgents = 5;
const SequentialAgents: React.FC<SequentialAgentsProps> = ({ field }) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const nodeRef = useRef(null);
const [newAgentId, setNewAgentId] = useState('');
const handleAddAgentId = () => {
if (newAgentId.trim() && field.value.length < maxAgents) {
const newValues = [...field.value, newAgentId];
field.onChange(newValues);
setNewAgentId('');
}
};
const handleDeleteAgentId = (index: number) => {
const newValues = field.value.filter((_, i) => i !== index);
field.onChange(newValues);
};
const defaultStyle = {
transition: 'opacity 200ms ease-in-out',
opacity: 0,
};
const triggerShake = (element: HTMLElement) => {
element.classList.remove('shake');
void element.offsetWidth;
element.classList.add('shake');
setTimeout(() => {
element.classList.remove('shake');
}, 200);
};
const transitionStyles = {
entering: { opacity: 1 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 },
};
const hasReachedMax = field.value.length >= Constants.MAX_CONVO_STARTERS;
return (
<div className="relative">
<label className={labelClass} htmlFor="agent_ids">
Sequential Agents
</label>
<div className="mt-4 space-y-2">
<HideSequential />
{/* Display existing agents first */}
{field.value.map((agentId, index) => (
<div key={index} className="relative">
<input
ref={(el) => (inputRefs.current[index] = el)}
value={agentId}
onChange={(e) => {
const newValue = [...field.value];
newValue[index] = e.target.value;
field.onChange(newValue);
}}
className={`${inputClass} pr-10`}
type="text"
maxLength={64}
/>
<TooltipAnchor
side="top"
description={'Remove agent ID'}
className="absolute right-1 top-1 flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onClick={() => handleDeleteAgentId(index)}
>
<X className="size-4" />
</TooltipAnchor>
</div>
))}
{/* Input for new agent at the bottom */}
<div className="relative">
<input
ref={(el) => (inputRefs.current[field.value.length] = el)}
value={newAgentId}
maxLength={64}
className={`${inputClass} pr-10`}
type="text"
placeholder={hasReachedMax ? 'Max agents reached' : 'Enter agent ID (e.g. agent_1234)'}
onChange={(e) => setNewAgentId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (hasReachedMax) {
triggerShake(e.currentTarget);
} else {
handleAddAgentId();
}
}
}}
/>
<Transition
nodeRef={nodeRef}
in={field.value.length < Constants.MAX_CONVO_STARTERS}
timeout={200}
unmountOnExit
>
{(state: string) => (
<div
ref={nodeRef}
style={{
...defaultStyle,
...transitionStyles[state as keyof typeof transitionStyles],
transition: state === 'entering' ? 'none' : defaultStyle.transition,
}}
className="absolute right-1 top-1"
>
<TooltipAnchor
side="top"
description={hasReachedMax ? 'Max agents reached' : 'Add agent ID'}
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onClick={handleAddAgentId}
disabled={hasReachedMax}
>
<Plus className="size-4" />
</TooltipAnchor>
</div>
)}
</Transition>
</div>
</div>
</div>
);
};
export default SequentialAgents;

View file

@ -76,7 +76,7 @@ export default function CodeFiles({
<button
type="button"
disabled={!assistant_id}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
onClick={handleButtonClick}
>
<div className="flex w-full items-center justify-center gap-2">

View file

@ -32,6 +32,7 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
<TooltipAnchor
description={localize(link.title)}
side="left"
key={`nav-link-${index}`}
render={
<Button
variant="ghost"

View file

@ -6,8 +6,8 @@ import {
useGetStartupConfig,
useUserKeyQuery,
} from 'librechat-data-provider/react-query';
import type { TEndpointsConfig, TInterfaceConfig } from 'librechat-data-provider';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import type { TEndpointsConfig } from 'librechat-data-provider';
import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/components/ui/Resizable';
import { useMediaQuery, useLocalStorage, useLocalize } from '~/hooks';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
@ -65,7 +65,7 @@ const SidePanel = ({
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const { data: startupConfig } = useGetStartupConfig();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
() => (startupConfig?.interface ?? defaultInterface) as Partial<TInterfaceConfig>,
[startupConfig],
);
@ -117,17 +117,17 @@ const SidePanel = ({
});
const calculateLayout = useCallback(() => {
if (!artifacts) {
if (artifacts == null) {
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
return [100 - navSize, navSize];
} else {
const navSize = Math.max(minSize, navCollapsedSize);
const navSize = 0;
const remainingSpace = 100 - navSize;
const newMainSize = Math.floor(remainingSpace / 2);
const artifactsSize = remainingSpace - newMainSize;
return [newMainSize, artifactsSize, navSize];
}
}, [artifacts, defaultLayout, minSize, navCollapsedSize]);
}, [artifacts, defaultLayout]);
const currentLayout = useMemo(() => normalizeLayout(calculateLayout()), [calculateLayout]);
@ -261,7 +261,7 @@ const SidePanel = ({
: 'opacity-100',
)}
>
{interfaceConfig.modelSelect && (
{interfaceConfig.modelSelect === true && (
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-background',

View file

@ -1,5 +1,6 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { cn } from '~/utils';
interface DropdownProps {
trigger: React.ReactNode;
@ -15,11 +16,21 @@ interface DropdownProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
className?: string;
iconClassName?: string;
anchor?: { x: string; y: string };
modal?: boolean;
menuId: string;
}
const DropdownPopup: React.FC<DropdownProps> = ({ trigger, items, isOpen, setIsOpen, menuId }) => {
const DropdownPopup: React.FC<DropdownProps> = ({
trigger,
items,
isOpen,
setIsOpen,
menuId,
modal,
iconClassName,
}) => {
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen });
return (
@ -27,8 +38,9 @@ const DropdownPopup: React.FC<DropdownProps> = ({ trigger, items, isOpen, setIsO
{trigger}
<Ariakit.Menu
id={menuId}
className="z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
className="absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
gutter={8}
modal={modal}
>
{items
.filter((item) => item.show !== false)
@ -49,7 +61,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({ trigger, items, isOpen, setIsO
}}
>
{item.icon != null && (
<span className="mr-2 h-5 w-5" aria-hidden="true">
<span className={cn('mr-2 h-5 w-5', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}

View file

@ -81,13 +81,13 @@ function defaultGetStringKey(node: unknown): string {
* @returns
*/
export function useMultiSearch<OptionsType extends unknown[]>({
availableOptions,
availableOptions = [] as unknown as OptionsType,
placeholder,
getTextKeyOverride,
className,
disabled = false,
}: {
availableOptions: OptionsType;
availableOptions?: OptionsType;
placeholder?: string;
getTextKeyOverride?: (node: OptionsType[0]) => string;
className?: string;

View file

@ -20,7 +20,7 @@ type SelectDropDownProps = {
value: string | null | Option | OptionWithIcon;
setValue: DropdownValueSetter | ((value: string) => void);
tabIndex?: number;
availableValues: string[] | Option[] | OptionWithIcon[];
availableValues?: string[] | Option[] | OptionWithIcon[];
emptyTitle?: boolean;
showAbove?: boolean;
showLabel?: boolean;
@ -89,18 +89,20 @@ function SelectDropDown({
title = localize('com_ui_model');
}
const values = availableValues ?? [];
// Detemine if we should to convert this component into a searchable select. If we have enough elements, a search
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
// reset once the component is unmounted (as per a normal search)
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({
availableOptions: availableValues,
availableOptions: values,
placeholder: searchPlaceholder,
getTextKeyOverride: (option) => getOptionText(option).toUpperCase(),
className: searchClassName,
disabled,
});
const hasSearchRender = searchRender != null;
const options = hasSearchRender ? filteredValues : availableValues;
const options = hasSearchRender ? filteredValues : values;
const renderIcon = showOptionIcon && value != null && (value as OptionWithIcon).icon != null;

View file

@ -7,6 +7,7 @@ interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string;
side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
focusable?: boolean;
role?: string;
}