mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 08:13:46 +00:00
feat: refine agent builder tools, actions, and MCP sections
This commit is contained in:
parent
e3ec9544ef
commit
178be2d4c7
38 changed files with 1214 additions and 704 deletions
|
|
@ -42,13 +42,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
|
||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
||||
|
||||
const { data: mcpData } = useMCPToolsQuery({
|
||||
const { data: mcpData, isFetching: mcpToolsFetching } = useMCPToolsQuery({
|
||||
enabled:
|
||||
!isEphemeralAgent(agent_id) &&
|
||||
!isLoading &&
|
||||
availableMCPServers != null &&
|
||||
availableMCPServers.length > 0,
|
||||
});
|
||||
/** Tools are still arriving when the query is in flight and nothing is cached
|
||||
* yet (e.g., right after a hard refresh). Lets the MCP dialog show a skeleton. */
|
||||
const mcpToolsLoading = mcpToolsFetching && mcpData == null;
|
||||
|
||||
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
|
||||
const mcpServerNames = useMemo(
|
||||
|
|
@ -148,6 +151,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
agentsConfig,
|
||||
startupConfig,
|
||||
mcpServersMap,
|
||||
mcpToolsLoading,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
setCurrentAgentId,
|
||||
|
|
|
|||
|
|
@ -233,6 +233,9 @@ export type AgentPanelContextType = {
|
|||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
/** Pre-computed MCP server information indexed by server key */
|
||||
mcpServersMap: Map<string, MCPServerInfo>;
|
||||
/** True while the MCP tools list is being fetched and no data has arrived yet,
|
||||
* so consumers can show a skeleton instead of an empty "no tools" state. */
|
||||
mcpToolsLoading: boolean;
|
||||
availableMCPServers: MCPServerDefinition[];
|
||||
availableMCPServersMap: t.MCPServersListResponse | undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Save } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { HoverCard, HoverCardTrigger, SecretInput } from '@librechat/client';
|
||||
import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { HoverCard, HoverCardTrigger, SecretInput, Input, Label, Button } from '@librechat/client';
|
||||
import type { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
|
||||
import type { RegisterOptions } from 'react-hook-form';
|
||||
import PluginTooltip from './PluginTooltip';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
|
|
@ -34,6 +34,38 @@ const AUTH_PLACEHOLDERS: Record<string, string> = {
|
|||
AZURE_AI_SEARCH_INDEX_NAME: 'my-index',
|
||||
};
|
||||
|
||||
/**
|
||||
* Endpoint/URL fields, keyed by primary `authField`. These get a hard validation
|
||||
* rule: a value that isn't a valid http(s) URL blocks submission.
|
||||
*/
|
||||
const URL_FIELDS = new Set<string>(['SD_WEBUI_URL', 'AZURE_AI_SEARCH_SERVICE_ENDPOINT']);
|
||||
|
||||
/**
|
||||
* Known credential prefixes per primary `authField`. A value missing its expected
|
||||
* prefix surfaces a non-blocking hint, never an error — key formats drift over
|
||||
* time, so we guide without trapping a key that just doesn't match.
|
||||
*/
|
||||
const AUTH_PREFIXES: Record<string, string> = {
|
||||
OPENAI_API_KEY: 'sk-',
|
||||
DALLE_API_KEY: 'sk-',
|
||||
DALLE2_API_KEY: 'sk-',
|
||||
DALLE3_API_KEY: 'sk-',
|
||||
IMAGE_GEN_OAI_API_KEY: 'sk-',
|
||||
GEMINI_API_KEY: 'AIza',
|
||||
GOOGLE_KEY: 'AIza',
|
||||
GOOGLE_SEARCH_API_KEY: 'AIza',
|
||||
TAVILY_API_KEY: 'tvly-',
|
||||
};
|
||||
|
||||
const isValidUrl = (value: string): boolean => {
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type TPluginAuthFormProps = {
|
||||
plugin: TPlugin | undefined;
|
||||
onSubmit: (installActionData: TPluginAction) => void;
|
||||
|
|
@ -43,113 +75,120 @@ type TPluginAuthFormProps = {
|
|||
function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm();
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
const localize = useLocalize();
|
||||
const watchedValues = useWatch({ control });
|
||||
const authConfig = plugin?.authConfig ?? [];
|
||||
const allFieldsOptional = authConfig.length > 0 && authConfig.every((c) => c.optional === true);
|
||||
|
||||
const submit = handleSubmit((auth) =>
|
||||
onSubmit({
|
||||
pluginKey: plugin?.pluginKey ?? '',
|
||||
action: 'install',
|
||||
auth,
|
||||
isEntityTool,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full gap-6 sm:grid-cols-2">
|
||||
<form
|
||||
className="col-span-1 flex w-full flex-col items-start justify-start gap-2"
|
||||
method="POST"
|
||||
onSubmit={handleSubmit((auth) =>
|
||||
onSubmit({
|
||||
pluginKey: plugin?.pluginKey ?? '',
|
||||
action: 'install',
|
||||
auth,
|
||||
isEntityTool,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{authConfig.map((config: TPluginAuthConfig, i: number) => {
|
||||
const authField = config.authField.split('||')[0];
|
||||
const isOptional = config.optional === true;
|
||||
const inputClassName =
|
||||
'flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0';
|
||||
const sharedProps = {
|
||||
id: authField,
|
||||
placeholder: AUTH_PLACEHOLDERS[authField],
|
||||
'aria-invalid': !!errors[authField],
|
||||
'aria-describedby': `${authField}-error`,
|
||||
'aria-label': config.label,
|
||||
'aria-required': !isOptional,
|
||||
/* autoFocus is generally disorienting, but here the required field must be navigated to
|
||||
* anyway, and the form emulates a modal opening where users expect focus to shift. */
|
||||
autoFocus: i === 0,
|
||||
className: inputClassName,
|
||||
...register(
|
||||
authField,
|
||||
isOptional
|
||||
? {}
|
||||
: {
|
||||
required: `${config.label} is required.`,
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: `${config.label} must be at least 1 character long`,
|
||||
},
|
||||
},
|
||||
),
|
||||
<form className="flex w-full flex-col" method="POST" onSubmit={submit}>
|
||||
<div className="flex max-h-[40vh] flex-col gap-4 overflow-y-auto pr-1">
|
||||
{authConfig.map((config: TPluginAuthConfig, i: number) => {
|
||||
const authField = config.authField.split('||')[0];
|
||||
const isOptional = config.optional === true;
|
||||
const rules: RegisterOptions = {};
|
||||
if (!isOptional) {
|
||||
rules.required = `${config.label} is required.`;
|
||||
rules.minLength = {
|
||||
value: 1,
|
||||
message: `${config.label} must be at least 1 character long`,
|
||||
};
|
||||
return (
|
||||
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor={authField}
|
||||
className="mb-1 text-left text-sm font-medium text-gray-700/70 dark:text-gray-50/70"
|
||||
>
|
||||
{config.label}
|
||||
</label>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
{config.sensitive === false ? (
|
||||
<input type="text" autoComplete="off" {...sharedProps} />
|
||||
) : (
|
||||
<SecretInput
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
controlsOnHover
|
||||
{...sharedProps}
|
||||
/>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
<PluginTooltip content={config.description} position="right" />
|
||||
</HoverCard>
|
||||
{errors[authField] && (
|
||||
<span role="alert" className="mt-1 text-sm text-red-400">
|
||||
{String(errors?.[authField]?.message ?? '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
disabled={allFieldsOptional ? isSubmitting : !isDirty || !isValid || isSubmitting}
|
||||
type="button"
|
||||
className="btn btn-primary relative"
|
||||
onClick={() => {
|
||||
handleSubmit((auth) =>
|
||||
onSubmit({
|
||||
pluginKey: plugin?.pluginKey ?? '',
|
||||
action: 'install',
|
||||
auth,
|
||||
isEntityTool,
|
||||
}),
|
||||
)();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{localize('com_ui_save')}
|
||||
<Save className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />
|
||||
}
|
||||
if (URL_FIELDS.has(authField)) {
|
||||
rules.validate = (value: string) =>
|
||||
!value || isValidUrl(value) || localize('com_ui_auth_invalid_url');
|
||||
}
|
||||
const hasError = !!errors[authField];
|
||||
const expectedPrefix = AUTH_PREFIXES[authField];
|
||||
const value = String(watchedValues?.[authField] ?? '');
|
||||
const showHint =
|
||||
!hasError && !!expectedPrefix && value.length > 0 && !value.startsWith(expectedPrefix);
|
||||
const describedBy = showHint
|
||||
? `${authField}-error ${authField}-hint`
|
||||
: `${authField}-error`;
|
||||
const reserveMessage = !isOptional || URL_FIELDS.has(authField) || !!expectedPrefix;
|
||||
const sharedProps = {
|
||||
id: authField,
|
||||
placeholder: AUTH_PLACEHOLDERS[authField],
|
||||
'aria-invalid': hasError,
|
||||
'aria-describedby': describedBy,
|
||||
'aria-label': config.label,
|
||||
'aria-required': !isOptional,
|
||||
/* autoFocus is generally disorienting, but here the required field must be navigated to
|
||||
* anyway, and the form emulates a modal opening where users expect focus to shift. */
|
||||
autoFocus: i === 0,
|
||||
className:
|
||||
'hover:border-border-light focus-visible:border-border-light focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
...register(authField, rules),
|
||||
};
|
||||
return (
|
||||
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1.5">
|
||||
<Label htmlFor={authField} className="text-sm font-medium text-text-secondary">
|
||||
{config.label}
|
||||
</Label>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="block w-full">
|
||||
{config.sensitive === false ? (
|
||||
<Input type="text" autoComplete="off" {...sharedProps} />
|
||||
) : (
|
||||
<SecretInput
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
controlsOnHover
|
||||
{...sharedProps}
|
||||
/>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
<PluginTooltip content={config.description} position="right" />
|
||||
</HoverCard>
|
||||
{reserveMessage && (
|
||||
<div className="min-h-4 text-xs leading-4">
|
||||
{hasError && (
|
||||
<span id={`${authField}-error`} role="alert" className="text-red-500">
|
||||
{String(errors?.[authField]?.message ?? '')}
|
||||
</span>
|
||||
)}
|
||||
{showHint && (
|
||||
<span
|
||||
id={`${authField}-hint`}
|
||||
aria-live="polite"
|
||||
className="text-amber-600 dark:text-amber-500"
|
||||
>
|
||||
{localize('com_ui_auth_format_hint', { 0: expectedPrefix })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="submit"
|
||||
disabled={allFieldsOptional ? isSubmitting : !isDirty || !isValid || isSubmitting}
|
||||
onClick={submit}
|
||||
>
|
||||
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,24 +2,22 @@ import { useState, useEffect } from 'react';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
validateAndParseOpenAPISpec,
|
||||
openapiToFunction,
|
||||
AuthTypeEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
Textarea,
|
||||
Skeleton,
|
||||
OGDialog,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
validateAndParseOpenAPISpec,
|
||||
openapiToFunction,
|
||||
AuthTypeEnum,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
Action,
|
||||
FunctionTool,
|
||||
|
|
@ -28,8 +26,8 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { ActionAuthForm } from '~/common';
|
||||
import type { Spec } from './ActionsTable';
|
||||
import { ActionsTable, ActionsTableSkeleton, columns } from './ActionsTable';
|
||||
import ActionCallback from '~/components/SidePanel/Builder/ActionCallback';
|
||||
import { ActionsTable, columns } from './ActionsTable';
|
||||
import { useUpdateAgentAction } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { logger } from '~/utils';
|
||||
|
|
@ -43,20 +41,6 @@ const debouncedValidation = debounce(
|
|||
);
|
||||
|
||||
/** Placeholder rows shaped like the "Available actions" table (Name / Method / Path). */
|
||||
function ActionsTableSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-1" aria-busy="true" aria-live="polite">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b border-border-light pb-2.5 pt-1">
|
||||
<Skeleton className="h-3.5 w-1/3" />
|
||||
<Skeleton className="h-3.5 w-12" />
|
||||
<Skeleton className="h-3.5 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActionsInput({
|
||||
action,
|
||||
agent_id,
|
||||
|
|
@ -97,7 +81,7 @@ export default function ActionsInput({
|
|||
}
|
||||
setInputValue(rawSpec);
|
||||
setIsValidating(true);
|
||||
debouncedValidation(rawSpec, handleResult);
|
||||
handleResult(validateAndParseOpenAPISpec(rawSpec));
|
||||
}, [action?.metadata.raw_spec]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -251,7 +235,7 @@ export default function ActionsInput({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<label
|
||||
htmlFor="schemaInput"
|
||||
|
|
@ -269,40 +253,36 @@ export default function ActionsInput({
|
|||
<Maximize2 className="h-4 w-4" strokeWidth={1.75} aria-hidden={true} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border-medium ring-0 hover:border-border-heavy">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<Textarea
|
||||
id="schemaInput"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
spellCheck="false"
|
||||
placeholder={localize('com_ui_enter_openapi_schema')}
|
||||
className="min-h-[12rem] flex-1 resize-y rounded-none border-0 bg-transparent p-2 font-mono text-xs focus-visible:ring-0"
|
||||
/>
|
||||
<Textarea
|
||||
id="schemaInput"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
spellCheck="false"
|
||||
placeholder={localize('com_ui_enter_openapi_schema')}
|
||||
className="block min-h-[12rem] w-full resize-y rounded-lg border border-border-light bg-transparent p-3 font-mono text-xs leading-relaxed transition-colors focus-visible:border-border-heavy focus-visible:ring-0"
|
||||
/>
|
||||
{validationError && (
|
||||
<div className="mt-1.5 text-xs text-red-500">
|
||||
{validationError.split('\n').map((line: string, i: number) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
{validationError && (
|
||||
<div className="border-t border-border-light p-2 text-red-500">
|
||||
{validationError.split('\n').map((line: string, i: number) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(data || showSkeleton) && (
|
||||
<div className="my-2">
|
||||
<div className="flex items-center">
|
||||
<label className="block text-sm font-medium text-text-primary">
|
||||
{localize('com_assistants_available_actions')}
|
||||
</label>
|
||||
<div className="mt-4 flex min-h-0 flex-1 flex-col">
|
||||
<label className="mb-1 block shrink-0 text-sm font-medium text-text-primary">
|
||||
{localize('com_assistants_available_actions')}
|
||||
</label>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{data ? <ActionsTable columns={columns} data={data} /> : <ActionsTableSkeleton />}
|
||||
</div>
|
||||
{data ? <ActionsTable columns={columns} data={data} /> : <ActionsTableSkeleton />}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative my-1">
|
||||
<div className="mt-4 shrink-0">
|
||||
<ActionCallback action_id={action?.action_id} />
|
||||
</div>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 pt-4">
|
||||
<div className="flex items-center">{footerStart}</div>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -316,38 +296,32 @@ export default function ActionsInput({
|
|||
</div>
|
||||
|
||||
<OGDialog open={isSchemaDialogOpen} onOpenChange={setIsSchemaDialogOpen}>
|
||||
<OGDialogContent
|
||||
className="flex h-[85vh] max-h-[85vh] w-11/12 max-w-5xl flex-col gap-4 p-6"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<OGDialogHeader className="mb-2 pr-14">
|
||||
<OGDialogTitle className="text-left text-2xl font-semibold">
|
||||
<OGDialogContent className="flex h-[85vh] max-h-[85vh] w-11/12 max-w-5xl flex-col gap-3 p-5">
|
||||
<OGDialogHeader className="space-y-0 pr-10">
|
||||
<OGDialogTitle className="text-left text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_schema')}
|
||||
</OGDialogTitle>
|
||||
<OGDialogDescription className="sr-only">
|
||||
{localize('com_ui_enter_openapi_schema')}
|
||||
</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
<Textarea
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
spellCheck="false"
|
||||
placeholder={localize('com_ui_enter_openapi_schema')}
|
||||
aria-label={localize('com_ui_schema')}
|
||||
className="min-h-0 flex-1 resize-none bg-transparent font-mono text-sm leading-relaxed"
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border-medium bg-surface-secondary focus-within:border-border-heavy">
|
||||
<Textarea
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
spellCheck="false"
|
||||
placeholder={localize('com_ui_enter_openapi_schema')}
|
||||
aria-label={localize('com_ui_schema')}
|
||||
className="min-h-0 flex-1 resize-none border-0 bg-transparent p-4 font-mono text-[13px] leading-relaxed focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{validationError && (
|
||||
<div className="max-h-24 overflow-y-auto text-sm text-red-500">
|
||||
<div className="max-h-24 shrink-0 overflow-y-auto text-xs text-red-500">
|
||||
{validationError.split('\n').map((line: string, i: number) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<OGDialogClose asChild>
|
||||
<Button>{localize('com_ui_done')}</Button>
|
||||
</OGDialogClose>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export type Spec = {
|
||||
name: string;
|
||||
|
|
@ -7,48 +10,49 @@ export type Spec = {
|
|||
domain: string;
|
||||
};
|
||||
|
||||
export const fakeData: Spec[] = [
|
||||
{
|
||||
name: 'listPets',
|
||||
method: 'get',
|
||||
path: '/pets',
|
||||
domain: 'petstore.swagger.io',
|
||||
},
|
||||
{
|
||||
name: 'createPets',
|
||||
method: 'post',
|
||||
path: '/pets',
|
||||
domain: 'petstore.swagger.io',
|
||||
},
|
||||
{
|
||||
name: 'showPetById',
|
||||
method: 'get',
|
||||
path: '/pets/{petId}',
|
||||
domain: 'petstore.swagger.io',
|
||||
},
|
||||
];
|
||||
/** Color-codes the HTTP verb the way API docs do, so the method reads at a glance. */
|
||||
const METHOD_STYLES: Record<string, string> = {
|
||||
get: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
post: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
put: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
patch: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
delete: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
};
|
||||
|
||||
function HeaderCell({ labelKey }: { labelKey: TranslationKeys }) {
|
||||
const localize = useLocalize();
|
||||
return <>{localize(labelKey)}</>;
|
||||
}
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-wide',
|
||||
METHOD_STYLES[method.toLowerCase()] ?? 'bg-surface-secondary text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{method}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Spec>[] = [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
header: () => <HeaderCell labelKey="com_ui_name" />,
|
||||
cell: ({ row }) => <span className="font-medium text-text-primary">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
header: 'Method',
|
||||
accessorKey: 'method',
|
||||
header: () => <HeaderCell labelKey="com_ui_method" />,
|
||||
cell: ({ row }) => <MethodBadge method={row.original.method} />,
|
||||
},
|
||||
{
|
||||
header: 'Path',
|
||||
accessorKey: 'path',
|
||||
header: () => <HeaderCell labelKey="com_ui_path" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="break-all font-mono text-xs text-text-secondary">{row.original.path}</span>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// header: '',
|
||||
// accessorKey: 'action',
|
||||
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// cell: ({ row: _row }) => (
|
||||
// <button className="btn relative btn-neutral h-8 rounded-lg border-token-border-light font-medium">
|
||||
// <div className="flex w-full gap-2 items-center justify-center">Test</div>
|
||||
// </button>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { Skeleton } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const SKELETON_ROWS = 3;
|
||||
const HEADER_KEYS = ['com_ui_name', 'com_ui_method', 'com_ui_path'] as const;
|
||||
|
||||
/**
|
||||
* Loading state for the actions table. Mirrors the table's chrome (eyebrow header,
|
||||
* row dividers, a badge-shaped method cell) so the swap to real rows doesn't shift
|
||||
* the layout. The headers are real since they never change.
|
||||
*/
|
||||
export default function ActionsTableSkeleton() {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<table className="w-full table-auto text-sm" aria-busy="true" aria-live="polite">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light">
|
||||
{HEADER_KEYS.map((key) => (
|
||||
<th
|
||||
key={key}
|
||||
className="py-2 pr-3 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary"
|
||||
>
|
||||
{localize(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: SKELETON_ROWS }, (_, row) => (
|
||||
<tr key={row} className="border-b border-border-light last:border-0">
|
||||
<td className="py-2.5 pr-3">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<Skeleton className="h-5 w-12 rounded-md" />
|
||||
</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<Skeleton className="h-4 w-32 rounded" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,15 +14,15 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
});
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full table-auto text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-token-border-light text-token-text-tertiary border-b text-left text-xs"
|
||||
>
|
||||
{headerGroup.headers.map((header, j) => (
|
||||
<th key={j} className="py-1 font-normal text-text-secondary-alt">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="sticky top-0 z-10 border-b border-border-light bg-background py-2 pr-3 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
|
@ -32,10 +32,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, i) => (
|
||||
<tr key={i} className="border-token-border-light border-b">
|
||||
{row.getVisibleCells().map((cell, j) => (
|
||||
<td key={j} className="py-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-border-light transition-colors last:border-0 hover:bg-surface-secondary"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="py-2 pr-3 align-middle">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as ActionsTable } from './Table';
|
||||
export { default as ActionsTableSkeleton } from './Skeleton';
|
||||
export * from './Columns';
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
import { Input } from '@librechat/client';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
|
||||
import type { AgentForm, IconComponentTypes } from '~/common';
|
||||
import type { AgentForm, ExtendedFile, IconComponentTypes } from '~/common';
|
||||
import { useLocalize, useAgentCapabilities } from '~/hooks';
|
||||
import AgentCategorySelector from './AgentCategorySelector';
|
||||
import { validateEmail, getIconKey, cn } from '~/utils';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import AgentCategorySelector from './AgentCategorySelector';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
import ToolsSection from './Tools/ToolsSection';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import Instructions from './Instructions';
|
||||
import ToolsSection from './Tools/ToolsSection';
|
||||
import FileContext from './FileContext';
|
||||
import AgentAvatar from './AgentAvatar';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
const fieldClass = 'h-9';
|
||||
|
||||
export default function AgentConfig() {
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { setActivePanel, endpointsConfig } = useAgentPanelContext();
|
||||
const { setActivePanel, endpointsConfig, agentsConfig } = useAgentPanelContext();
|
||||
const { contextEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
|
||||
const {
|
||||
control,
|
||||
|
|
@ -27,6 +29,7 @@ export default function AgentConfig() {
|
|||
const model = useWatch({ control, name: 'model' });
|
||||
const agent = useWatch({ control, name: 'agent' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
const contextFiles = (agent?.context_files ?? []) as Array<[string, ExtendedFile]>;
|
||||
|
||||
const providerValue = typeof provider === 'string' ? provider : provider?.value;
|
||||
let Icon: IconComponentTypes | null | undefined;
|
||||
|
|
@ -148,6 +151,13 @@ export default function AgentConfig() {
|
|||
{/* TOOLS — unified built-ins / tools / actions / mcp / skills */}
|
||||
<ToolsSection agentId={agent_id} />
|
||||
|
||||
{/* FILE CONTEXT — standalone section, separate from the tool library */}
|
||||
{contextEnabled && (
|
||||
<div className="mb-3">
|
||||
<FileContext agent_id={agent_id} files={contextFiles} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SUPPORT CONTACT */}
|
||||
<div className="mb-3 flex flex-col">
|
||||
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { memo, useMemo, useRef, useState } from 'react';
|
||||
import { Folder } from 'lucide-react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Folder, Plus, Info } from 'lucide-react';
|
||||
import { EModelEndpoint, EToolResources } from 'librechat-data-provider';
|
||||
import { DropdownPopup, SharePointIcon } from '@librechat/client';
|
||||
import { DropdownPopup, SharePointIcon, TooltipAnchor } from '@librechat/client';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
|
||||
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
|
||||
import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks';
|
||||
import DropzoneContent, { dropzoneClassName } from './UploadDropzone';
|
||||
import { SharePointPickerDialog } from '~/components/SharePoint';
|
||||
import FileRow from '~/components/Chat/Input/Files/FileRow';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import SectionHeader from './SectionHeader';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const addButtonClassName = cn(
|
||||
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-text-secondary transition-colors',
|
||||
'hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-text-secondary',
|
||||
);
|
||||
|
||||
function FileContext({
|
||||
agent_id,
|
||||
|
|
@ -96,24 +101,75 @@ function FileContext({
|
|||
},
|
||||
];
|
||||
|
||||
const dropzoneLabel = localize('com_ui_upload_file_context');
|
||||
const dropzoneHint = localize('com_ui_upload_files_hint');
|
||||
const addLabel = localize('com_ui_upload_file_context');
|
||||
const fileCount = files.size;
|
||||
|
||||
const menuTrigger = (
|
||||
<Ariakit.MenuButton disabled={disabledUploadButton} className={dropzoneClassName}>
|
||||
<DropzoneContent label={dropzoneLabel} hint={dropzoneHint} />
|
||||
</Ariakit.MenuButton>
|
||||
const addControl = sharePointEnabled ? (
|
||||
<DropdownPopup
|
||||
gutter={2}
|
||||
menuId="file-context-upload-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
disabled={disabledUploadButton}
|
||||
aria-label={addLabel}
|
||||
className={addButtonClassName}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden="true" />
|
||||
{localize('com_ui_add')}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={dropdownItems}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabledUploadButton}
|
||||
aria-label={addLabel}
|
||||
className={addButtonClassName}
|
||||
onClick={handleLocalFileClick}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden="true" />
|
||||
{localize('com_ui_add')}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{showHeader && (
|
||||
<SectionHeader
|
||||
title={localize('com_agents_file_context_label')}
|
||||
info={localize('com_agents_file_context_description')}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File Context Files */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{showHeader ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_agents_file_context_label')}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={localize('com_agents_file_context_description')}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_agents_file_context_description')}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded text-text-tertiary transition-colors hover:text-text-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{fileCount > 0 && (
|
||||
<span className="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-surface-tertiary px-1.5 text-[10px] font-medium text-text-secondary">
|
||||
{fileCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{addControl}
|
||||
</div>
|
||||
{fileCount > 0 && (
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
|
|
@ -121,45 +177,21 @@ function FileContext({
|
|||
tool_resource={EToolResources.context}
|
||||
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
<div>
|
||||
{sharePointEnabled ? (
|
||||
<DropdownPopup
|
||||
gutter={2}
|
||||
menuId="file-context-upload-menu"
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabledUploadButton}
|
||||
className={dropzoneClassName}
|
||||
onClick={handleLocalFileClick}
|
||||
>
|
||||
<DropzoneContent label={dropzoneLabel} hint={dropzoneHint} />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={disabledUploadButton}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Disabled Message */}
|
||||
{agent_id ? null : (
|
||||
<div className="text-xs text-text-secondary">
|
||||
{localize('com_agents_file_context_disabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!agent_id && (
|
||||
<p className="text-[11px] leading-snug text-text-secondary">
|
||||
{localize('com_agents_file_context_disabled')}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={disabledUploadButton}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<SharePointPickerDialog
|
||||
isOpen={isSharePointDialogOpen}
|
||||
onOpenChange={setIsSharePointDialogOpen}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState, useId } from 'react';
|
||||
import { PlusCircle, Maximize2 } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { PlusCircle, Maximize2 } from 'lucide-react';
|
||||
import { specialVariables } from 'librechat-data-provider';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
Button,
|
||||
DropdownPopup,
|
||||
|
|
@ -10,12 +12,10 @@ import {
|
|||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
} from '@librechat/client';
|
||||
import { specialVariables } from 'librechat-data-provider';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import type { TSpecialVarLabel } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const textareaClass =
|
||||
'lc-field flex w-full rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:border-border-medium focus-visible:ring-2 focus-visible:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
|
@ -84,7 +84,7 @@ export default function Instructions() {
|
|||
}
|
||||
items={variableItems}
|
||||
menuId={menuId}
|
||||
className="z-30"
|
||||
className="pointer-events-auto z-30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -172,7 +172,7 @@ export default function Instructions() {
|
|||
}
|
||||
items={variableItems}
|
||||
menuId={dialogMenuId}
|
||||
className="z-[200]"
|
||||
className="pointer-events-auto z-[200]"
|
||||
/>
|
||||
<OGDialogClose asChild>
|
||||
<Button>{localize('com_ui_done')}</Button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Clock, MoreHorizontal, Code2 } from 'lucide-react';
|
||||
import {
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from '@librechat/client';
|
||||
import { useState } from 'react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { Check, Clock, Code2, Info } from 'lucide-react';
|
||||
import type { AgentToolType } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -25,18 +17,8 @@ interface MCPToolItemProps {
|
|||
onToggleProgrammatic: () => void;
|
||||
}
|
||||
|
||||
function getToolItemStyle(isDeferred: boolean, isProgrammatic: boolean): string {
|
||||
if (isDeferred && isProgrammatic) {
|
||||
return 'border-purple-500/50 bg-purple-500/5 hover:bg-purple-500/10';
|
||||
}
|
||||
if (isDeferred) {
|
||||
return 'border-amber-500/50 bg-amber-500/5 hover:bg-amber-500/10';
|
||||
}
|
||||
if (isProgrammatic) {
|
||||
return 'border-violet-500/50 bg-violet-500/5 hover:bg-violet-500/10';
|
||||
}
|
||||
return 'border-token-border-light hover:bg-token-surface-secondary';
|
||||
}
|
||||
const iconButton =
|
||||
'flex size-6 items-center justify-center rounded-md transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary';
|
||||
|
||||
export default function MCPToolItem({
|
||||
tool,
|
||||
|
|
@ -50,103 +32,116 @@ export default function MCPToolItem({
|
|||
programmaticToolsEnabled,
|
||||
}: MCPToolItemProps) {
|
||||
const localize = useLocalize();
|
||||
const hasOptions = isDeferred || isProgrammatic;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const description = tool.metadata.description?.trim();
|
||||
const detailsId = `mcp-tool-details-${tool.tool_id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/item flex cursor-pointer items-center rounded-lg border p-2',
|
||||
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
||||
getToolItemStyle(isDeferred, isProgrammatic),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
id={tool.tool_id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelect}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const checkbox = e.currentTarget as HTMLButtonElement;
|
||||
checkbox.click();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative mr-2 inline-flex h-4 w-4 shrink-0 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
||||
aria-label={tool.metadata.name}
|
||||
/>
|
||||
<span className="text-token-text-primary min-w-0 flex-1 select-none truncate">
|
||||
{tool.metadata.name}
|
||||
</span>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
{isDeferred && <Clock className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />}
|
||||
{isProgrammatic && <Code2 className="h-3.5 w-3.5 text-violet-500" aria-hidden="true" />}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||
hasOptions
|
||||
? 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'
|
||||
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={localize('com_ui_mcp_tool_options')}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="left"
|
||||
className="w-80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<div className="flex items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-surface-secondary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelect}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={tool.metadata.name}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2.5 rounded-lg p-2 text-left',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded border border-border-medium transition-colors',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-text-primary">
|
||||
{tool.metadata.description || localize('com_ui_mcp_no_description')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{deferredToolsEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isDeferred}
|
||||
onCheckedChange={onToggleDefer}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-amber-500" />
|
||||
<div className="flex flex-col">
|
||||
<span>{localize('com_ui_mcp_defer_loading')}</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_ui_mcp_click_to_defer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
{isSelected && <Check className="size-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||
{tool.metadata.name}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{deferredToolsEnabled && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_mcp_click_to_defer')}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDefer}
|
||||
aria-pressed={isDeferred}
|
||||
aria-label={localize('com_ui_mcp_defer_loading')}
|
||||
className={cn(
|
||||
iconButton,
|
||||
isDeferred ? 'text-amber-500' : 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<Clock className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{programmaticToolsEnabled && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_mcp_click_to_programmatic')}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleProgrammatic}
|
||||
aria-pressed={isProgrammatic}
|
||||
aria-label={localize('com_ui_mcp_programmatic')}
|
||||
className={cn(
|
||||
iconButton,
|
||||
isProgrammatic
|
||||
? 'text-violet-500'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<Code2 className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
aria-label={localize('com_ui_tools_info')}
|
||||
className={cn(
|
||||
iconButton,
|
||||
expanded ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
{programmaticToolsEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isProgrammatic}
|
||||
onCheckedChange={onToggleProgrammatic}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-4 w-4 text-violet-500" />
|
||||
<div className="flex flex-col">
|
||||
<span>{localize('com_ui_mcp_programmatic')}</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{localize('com_ui_mcp_click_to_programmatic')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
>
|
||||
<Info className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Auto-height reveal via grid-template-rows 0fr -> 1fr so the panel — and
|
||||
the auto-sized dialog around it — grow/shrink smoothly instead of jumping. */}
|
||||
<div
|
||||
id={detailsId}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] [transition-duration:var(--resize-dur)] [transition-timing-function:var(--resize-ease)] motion-reduce:transition-none',
|
||||
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-border-light px-3 py-3 transition-opacity duration-200 ease-out motion-reduce:transition-none',
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
>
|
||||
<p className="max-h-44 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-text-secondary">
|
||||
{description || localize('com_ui_mcp_no_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import {
|
|||
import type { ActionAuthForm } from '~/common';
|
||||
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import ActionsInput from '../ActionsInput';
|
||||
import { useDeleteAgentAction } from '~/data-provider';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import ActionsInput from '../ActionsInput';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ActionEditorProps {
|
||||
|
|
@ -100,11 +100,11 @@ export default function ActionEditor({
|
|||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
disabled={isEphemeralAgent(agentId) || !action.action_id}
|
||||
aria-label={localize('com_ui_delete_action')}
|
||||
className="flex-shrink-0 text-red-500"
|
||||
title={localize('com_ui_delete_action')}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -141,26 +141,15 @@ export default function ActionEditor({
|
|||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form className="h-full grow overflow-hidden">
|
||||
<div className="h-full overflow-auto text-sm">
|
||||
<div className="flex min-h-full flex-col pb-3">
|
||||
<div>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_assistants_actions_info')}
|
||||
</p>
|
||||
<ActionsAuth />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<ActionsInput
|
||||
action={action}
|
||||
agent_id={agentId}
|
||||
setAction={setAction}
|
||||
onCreated={onCreated}
|
||||
footerStart={deleteButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form className="flex min-h-0 flex-1 flex-col text-sm">
|
||||
<ActionsAuth />
|
||||
<ActionsInput
|
||||
action={action}
|
||||
agent_id={agentId}
|
||||
setAction={setAction}
|
||||
onCreated={onCreated}
|
||||
footerStart={deleteButton}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,14 @@ export default function ItemDialog({ item, agentId, onClose }: Props) {
|
|||
{item && (
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<ItemDialogHeader item={item} />
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 pt-2">
|
||||
<div
|
||||
className={cn(
|
||||
'px-6 pb-6 pt-2',
|
||||
isAction
|
||||
? 'flex min-h-0 flex-1 flex-col overflow-hidden'
|
||||
: 'flex-1 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
<ItemDialogBody item={item} agentId={agentId} onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import McpSection from '../sections/McpSection';
|
||||
import type { McpItem } from '../../items/types';
|
||||
import McpSection from '../sections/McpSection';
|
||||
|
||||
const mockSetValue = jest.fn();
|
||||
const mockGetValues = jest.fn((): string[] => []);
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({ setValue: mockSetValue, getValues: mockGetValues }),
|
||||
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
|
||||
useWatch: () => mockGetValues(),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
|
|
@ -30,6 +31,10 @@ jest.mock('~/hooks', () => ({
|
|||
isToolProgrammatic: () => false,
|
||||
toggleToolDefer: jest.fn(),
|
||||
toggleToolProgrammatic: jest.fn(),
|
||||
areAllToolsDeferred: () => false,
|
||||
areAllToolsProgrammatic: () => false,
|
||||
toggleDeferAll: jest.fn(),
|
||||
toggleProgrammaticAll: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -148,6 +153,13 @@ describe('McpSection', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('selection state tracks the watched tools field (re-render on toggle)', () => {
|
||||
mockGetValues.mockReturnValue(['mcp:srv:a']);
|
||||
render(<McpSection item={item} />);
|
||||
expect(screen.getByTestId('tool-mcp:srv:a')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByTestId('tool-mcp:srv:b')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test('shows empty hint when the server exposes no tools', () => {
|
||||
const empty: McpItem = {
|
||||
...item,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import type { ActionItem } from '../../items/types';
|
||||
import ActionEditor from '../../ActionEditor';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import ActionEditor from '../../ActionEditor';
|
||||
|
||||
/** Sentinel id the marketplace assigns to its create-action selection (cross-file contract). */
|
||||
const NEW_ACTION_ID = '__new_action__';
|
||||
|
|
@ -22,7 +22,7 @@ export default function ActionSection({ item, agentId, onClose }: Props) {
|
|||
}, [isCreate, item.action, setAction]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ActionEditor
|
||||
agentId={agentId}
|
||||
onClose={onClose}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { Checkbox } from '@librechat/client';
|
||||
import { Clock, Code2 } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { McpItem } from '../../items/types';
|
||||
import MCPToolItem from '../../../MCPToolItem';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
useAgentCapabilities,
|
||||
useGetAgentsConfig,
|
||||
useMCPServerManager,
|
||||
useMCPToolOptions,
|
||||
} from '~/hooks';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import MCPToolItem from '../../../MCPToolItem';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -54,15 +55,23 @@ interface Props {
|
|||
|
||||
export default function McpSection({ item }: Props) {
|
||||
const localize = useLocalize();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { control, getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
||||
const { mcpServersMap } = useAgentPanelContext();
|
||||
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { deferredToolsEnabled, programmaticToolsEnabled } = useAgentCapabilities(
|
||||
agentsConfig?.capabilities,
|
||||
);
|
||||
const { isToolDeferred, isToolProgrammatic, toggleToolDefer, toggleToolProgrammatic } =
|
||||
useMCPToolOptions();
|
||||
const {
|
||||
isToolDeferred,
|
||||
isToolProgrammatic,
|
||||
toggleToolDefer,
|
||||
toggleToolProgrammatic,
|
||||
areAllToolsDeferred,
|
||||
areAllToolsProgrammatic,
|
||||
toggleDeferAll,
|
||||
toggleProgrammaticAll,
|
||||
} = useMCPToolOptions();
|
||||
|
||||
const serverName = item.server.serverName;
|
||||
const serverToken = `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`;
|
||||
|
|
@ -72,10 +81,12 @@ export default function McpSection({ item }: Props) {
|
|||
const tools = liveServer.tools ?? [];
|
||||
const hasTools = tools.length > 0;
|
||||
|
||||
const getSelectedTools = (): string[] => {
|
||||
const formTools = (getValues('tools') ?? []) as string[];
|
||||
return tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
|
||||
};
|
||||
/** Subscribe to the tools field so selection toggles re-render this section.
|
||||
* `getValues` is a non-reactive read and left the checkboxes visually stale. */
|
||||
const formTools = (useWatch({ control, name: 'tools' }) ?? []) as string[];
|
||||
|
||||
const getSelectedTools = (): string[] =>
|
||||
tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
|
||||
|
||||
/** Replace this server's selection. Strips every tool_id AND the server-level
|
||||
* placeholder token, so passing `[]` fully detaches the server. */
|
||||
|
|
@ -101,13 +112,18 @@ export default function McpSection({ item }: Props) {
|
|||
|
||||
const selectedTools = getSelectedTools();
|
||||
const allSelected = hasTools && selectedTools.length === tools.length;
|
||||
const allDeferred = areAllToolsDeferred(tools);
|
||||
const allProgrammatic = areAllToolsProgrammatic(tools);
|
||||
const statusIconProps = getServerStatusIconProps(serverName);
|
||||
const configDialogProps = getConfigDialogProps();
|
||||
const statusDisplay = getStatusDisplay(
|
||||
statusIconProps?.serverStatus?.connectionState,
|
||||
statusIconProps?.isInitializing ?? false,
|
||||
liveServer.isConfigured,
|
||||
);
|
||||
const connectionState = statusIconProps?.serverStatus?.connectionState;
|
||||
const isInitializing = statusIconProps?.isInitializing ?? false;
|
||||
const statusDisplay = getStatusDisplay(connectionState, isInitializing, liveServer.isConfigured);
|
||||
/** A connected server's tools arrive with the (cold-cache) MCP tools fetch, and
|
||||
* the server is also briefly toolless while initializing — show a skeleton in
|
||||
* both cases instead of a misleading "no tools" message. */
|
||||
const toolsLoading =
|
||||
!hasTools && (mcpToolsLoading || isInitializing || connectionState === 'connecting');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
|
@ -134,26 +150,93 @@ export default function McpSection({ item }: Props) {
|
|||
{localize('com_ui_tools_mcp_tools_section')}
|
||||
</span>
|
||||
{hasTools && (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-text-secondary">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => toggleAll(checked === true)}
|
||||
aria-label={
|
||||
allSelected
|
||||
<div className="flex items-center gap-0.5">
|
||||
{deferredToolsEnabled && (
|
||||
<TooltipAnchor
|
||||
description={
|
||||
allDeferred
|
||||
? localize('com_ui_mcp_undefer_all')
|
||||
: localize('com_ui_mcp_defer_all')
|
||||
}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDeferAll(tools)}
|
||||
aria-pressed={allDeferred}
|
||||
aria-label={
|
||||
allDeferred
|
||||
? localize('com_ui_mcp_undefer_all')
|
||||
: localize('com_ui_mcp_defer_all')
|
||||
}
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-md transition-colors',
|
||||
'hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
allDeferred
|
||||
? 'text-amber-600 dark:text-amber-500'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<Clock className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{programmaticToolsEnabled && (
|
||||
<TooltipAnchor
|
||||
description={
|
||||
allProgrammatic
|
||||
? localize('com_ui_mcp_unprogrammatic_all')
|
||||
: localize('com_ui_mcp_programmatic_all')
|
||||
}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleProgrammaticAll(tools)}
|
||||
aria-pressed={allProgrammatic}
|
||||
aria-label={
|
||||
allProgrammatic
|
||||
? localize('com_ui_mcp_unprogrammatic_all')
|
||||
: localize('com_ui_mcp_programmatic_all')
|
||||
}
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-md transition-colors',
|
||||
'hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
allProgrammatic
|
||||
? 'text-violet-600 dark:text-violet-500'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<Code2 className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(deferredToolsEnabled || programmaticToolsEnabled) && (
|
||||
<span className="mx-1 h-4 w-px bg-border-light" aria-hidden="true" />
|
||||
)}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs text-text-secondary">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => toggleAll(checked === true)}
|
||||
aria-label={
|
||||
allSelected
|
||||
? localize('com_ui_tools_mcp_deselect_all')
|
||||
: localize('com_ui_tools_mcp_select_all')
|
||||
}
|
||||
className="size-4 rounded border border-border-medium"
|
||||
/>
|
||||
<span>
|
||||
{allSelected
|
||||
? localize('com_ui_tools_mcp_deselect_all')
|
||||
: localize('com_ui_tools_mcp_select_all')
|
||||
}
|
||||
className="size-4 rounded border border-border-medium"
|
||||
/>
|
||||
<span>
|
||||
{allSelected
|
||||
? localize('com_ui_tools_mcp_deselect_all')
|
||||
: localize('com_ui_tools_mcp_select_all')}
|
||||
</span>
|
||||
</label>
|
||||
: localize('com_ui_tools_mcp_select_all')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasTools ? (
|
||||
{hasTools && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{tools.map((tool) => (
|
||||
<MCPToolItem
|
||||
|
|
@ -170,7 +253,18 @@ export default function McpSection({ item }: Props) {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{!hasTools && toolsLoading && (
|
||||
<div className="flex flex-col gap-1" aria-busy="true" aria-live="polite">
|
||||
{['w-3/5', 'w-1/2', 'w-2/5'].map((width) => (
|
||||
<div key={width} className="flex items-center gap-2.5 rounded-lg px-2 py-2">
|
||||
<Skeleton className="size-4 shrink-0 rounded" />
|
||||
<Skeleton className={cn('h-4 rounded', width)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasTools && !toolsLoading && (
|
||||
<p className="rounded-xl border border-dashed border-border-light p-3 text-center text-xs text-text-tertiary">
|
||||
{localize('com_ui_tools_mcp_no_tools')}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { useState } from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TError, TPluginAction } from 'librechat-data-provider';
|
||||
import type { ToolItem } from '../../items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
import PluginAuthForm from '~/components/Plugins/Store/PluginAuthForm';
|
||||
import { pluginNeedsAuth } from '../../items/auth';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -10,8 +16,48 @@ interface Props {
|
|||
|
||||
export default function ToolSection({ item }: Props) {
|
||||
const localize = useLocalize();
|
||||
const [authDone, setAuthDone] = useState(false);
|
||||
const needsAuth = pluginNeedsAuth(item.plugin) && !authDone;
|
||||
const { showToast } = useToastContext();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
|
||||
const requiresAuth = pluginNeedsAuth(item.plugin);
|
||||
const [savedAuth, setSavedAuth] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const showForm = requiresAuth && (!savedAuth || editing);
|
||||
const showConfigured = requiresAuth && savedAuth && !editing;
|
||||
|
||||
/** Add this tool's pluginKey to the agent's tools so it becomes usable. */
|
||||
const enableTool = () => {
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
if (!current.includes(item.id)) {
|
||||
setValue('tools', [...current, item.id], { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (data: TPluginAction) => {
|
||||
const hasAuth = data.auth != null && Object.keys(data.auth).length > 0;
|
||||
if (!hasAuth) {
|
||||
enableTool();
|
||||
setSavedAuth(true);
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
updateUserPlugins.mutate(data, {
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: (error as TError)?.message || localize('com_nav_plugin_auth_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
enableTool();
|
||||
setSavedAuth(true);
|
||||
setEditing(false);
|
||||
showToast({ message: localize('com_ui_tool_credentials_saved'), status: 'success' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
|
@ -22,9 +68,22 @@ export default function ToolSection({ item }: Props) {
|
|||
{localize('com_ui_tools_no_description')}
|
||||
</p>
|
||||
)}
|
||||
{needsAuth && (
|
||||
<PluginAuthForm plugin={item.plugin} isEntityTool onSubmit={() => setAuthDone(true)} />
|
||||
{showConfigured && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-border-light bg-surface-secondary px-3 py-2.5">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-text-primary">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" aria-hidden="true" />
|
||||
{localize('com_ui_tools_info_configured')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
>
|
||||
{localize('com_ui_edit')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showForm && <PluginAuthForm plugin={item.plugin} isEntityTool onSubmit={handleSubmit} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { LayoutGrid, ListFilter, Wrench, Server, Workflow, Star, User, Plus } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button, DropdownPopup } from '@librechat/client';
|
||||
import * as Ariakit from '@ariakit/react/menu';
|
||||
import { Button, DropdownPopup } from '@librechat/client';
|
||||
import { LayoutGrid, ListFilter, Wrench, Server, Workflow, Star, User, Plus } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentItemKind, ItemFilter } from './items/types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -91,6 +91,7 @@ export default function MarketplaceSidebar({
|
|||
isOpen={createOpen}
|
||||
setIsOpen={setCreateOpen}
|
||||
menuId="marketplace-create-new"
|
||||
className="pointer-events-auto"
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
render={
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { memo, useState } from 'react';
|
||||
import { BadgeCheck, Check, Settings } from 'lucide-react';
|
||||
import type { AgentItem } from './items/types';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentItem } from './items/types';
|
||||
import { hasConfigurableSettings } from './items/configurable';
|
||||
import { getIconForItem } from './items/icons';
|
||||
import { pluginNeedsAuth } from './items/auth';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -33,16 +33,6 @@ const KIND_LABEL_KEYS: Record<AgentItem['kind'], TranslationKeys> = {
|
|||
action: 'com_ui_tools_kind_actions',
|
||||
};
|
||||
|
||||
function hasConfigurableSettings(item: AgentItem): boolean {
|
||||
if (item.kind === 'builtin') {
|
||||
return item.id === 'artifacts' || item.id === 'file_search' || item.id === 'context';
|
||||
}
|
||||
if (item.kind === 'tool') {
|
||||
return pluginNeedsAuth(item.plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface ItemIconProps {
|
||||
item: AgentItem;
|
||||
size: 'sm' | 'md';
|
||||
|
|
@ -141,9 +131,9 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
|
|||
</div>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-text-secondary">{description}</p>
|
||||
<p className="line-clamp-3 text-xs leading-snug text-text-secondary">{description}</p>
|
||||
) : (
|
||||
<p className="line-clamp-2 text-xs italic text-text-tertiary">
|
||||
<p className="line-clamp-3 text-xs italic leading-snug text-text-tertiary">
|
||||
{isNative ? localize('com_ui_tools_native_short') : kindLabel}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -169,8 +159,8 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
|
|||
}}
|
||||
aria-label={localize('com_ui_tools_configure')}
|
||||
className={cn(
|
||||
'absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-lg text-text-tertiary',
|
||||
'opacity-0 transition-opacity duration-150 hover:bg-surface-tertiary hover:text-text-primary',
|
||||
'absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-lg text-text-secondary',
|
||||
'opacity-0 transition duration-150 hover:bg-surface-hover hover:text-text-primary',
|
||||
'group-focus-within:opacity-100 group-hover:opacity-100',
|
||||
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { memo, useState } from 'react';
|
||||
import { Info, X } from 'lucide-react';
|
||||
import type { AgentItem } from './items/types';
|
||||
import { Info, Settings, X } from 'lucide-react';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentItem } from './items/types';
|
||||
import { hasConfigurableSettings } from './items/configurable';
|
||||
import { getIconForItem } from './items/icons';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -53,6 +54,8 @@ function ToolRowImpl({ item, onInfo, onRemove }: Props) {
|
|||
const localize = useLocalize();
|
||||
const suffix = getSuffix(item);
|
||||
const displayName = item.kind === 'builtin' ? localize(item.name as TranslationKeys) : item.name;
|
||||
const configurable = hasConfigurableSettings(item);
|
||||
const DetailIcon = configurable ? Settings : Info;
|
||||
|
||||
return (
|
||||
<div className="group relative flex w-full items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-secondary">
|
||||
|
|
@ -60,7 +63,7 @@ function ToolRowImpl({ item, onInfo, onRemove }: Props) {
|
|||
<RowIcon item={item} />
|
||||
<span className="flex min-w-0 items-center gap-1 truncate text-sm text-text-primary">
|
||||
<span className="truncate font-medium">{displayName}</span>
|
||||
{suffix && <span className="text-text-tertiary">{suffix}</span>}
|
||||
{suffix && <span className="text-text-secondary">{suffix}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{item.status === 'needs_setup' && (
|
||||
|
|
@ -78,14 +81,16 @@ function ToolRowImpl({ item, onInfo, onRemove }: Props) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => onInfo(item)}
|
||||
aria-label={localize('com_ui_tools_info')}
|
||||
aria-label={
|
||||
configurable ? localize('com_ui_tools_configure') : localize('com_ui_tools_info')
|
||||
}
|
||||
className={cn(
|
||||
'flex size-6 items-center justify-center rounded-md text-text-secondary',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
<DetailIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import {
|
||||
Input,
|
||||
OGDialog,
|
||||
|
|
@ -8,19 +9,19 @@ import {
|
|||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { AgentItem, AgentItemKind, ItemFilter } from './items/types';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useBuiltinAuthMap, useUninstallToolCredentials } from './hooks';
|
||||
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
|
||||
import { deriveSelectedItems, itemKey } from './items/selectors';
|
||||
import { computeToggleAction } from './items/mutations';
|
||||
import { useGetFavoritesQuery } from '~/data-provider';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import MarketplaceSidebar from './MarketplaceSidebar';
|
||||
import MarketplaceCatalog from './MarketplaceCatalog';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
|
||||
import { computeToggleAction } from './items/mutations';
|
||||
import { deriveSelectedItems, itemKey } from './items/selectors';
|
||||
import { applyFilter } from './items/filtering';
|
||||
import { buildCatalog } from './items/catalog';
|
||||
|
||||
|
|
@ -49,6 +50,8 @@ export default function ToolsMarketplaceDialog({
|
|||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const builtinAuthMap = useBuiltinAuthMap();
|
||||
const uninstallToolCredentials = useUninstallToolCredentials();
|
||||
|
||||
const { data: favorites } = useGetFavoritesQuery();
|
||||
|
||||
|
|
@ -109,8 +112,9 @@ export default function ToolsMarketplaceDialog({
|
|||
skills: [],
|
||||
actions: agentActions,
|
||||
permissions: { mcp: hasMcpAccess, skills: false },
|
||||
builtinAuthMap,
|
||||
}),
|
||||
[agentsConfig, regularTools, mcpServersMap, agentActions, hasMcpAccess],
|
||||
[agentsConfig, regularTools, mcpServersMap, agentActions, hasMcpAccess, builtinAuthMap],
|
||||
);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
|
|
@ -180,18 +184,39 @@ export default function ToolsMarketplaceDialog({
|
|||
current.filter((t) => t !== patch.id),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
uninstallToolCredentials(patch.id);
|
||||
break;
|
||||
}
|
||||
case 'mcp-add': {
|
||||
if (item.kind !== 'mcp') break;
|
||||
const toolIds = (item.server.tools ?? []).map((t) => t.tool_id);
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
setValue('tools', Array.from(new Set([...current, ...toolIds])), { shouldDirty: true });
|
||||
break;
|
||||
}
|
||||
case 'mcp-remove': {
|
||||
if (item.kind !== 'mcp') break;
|
||||
const toolIds = new Set((item.server.tools ?? []).map((t) => t.tool_id));
|
||||
const serverToken = `${Constants.mcp_server}${Constants.mcp_delimiter}${item.id}`;
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
setValue(
|
||||
'tools',
|
||||
current.filter((t) => t !== serverToken && !toolIds.has(t)),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[getValues, setValue, selectedIds],
|
||||
[getValues, setValue, selectedIds, uninstallToolCredentials],
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(item: AgentItem) => {
|
||||
if (item.kind === 'mcp' || item.kind === 'action') {
|
||||
/** Actions have no simple enable toggle — clicking always opens the editor. */
|
||||
if (item.kind === 'action') {
|
||||
setDetailItem(item);
|
||||
return;
|
||||
}
|
||||
|
|
@ -199,6 +224,12 @@ export default function ToolsMarketplaceDialog({
|
|||
setDetailItem(item);
|
||||
return;
|
||||
}
|
||||
/** An MCP server with no exposed tools yet can't be enabled in place — open
|
||||
* its dialog so it can be connected/configured first. */
|
||||
if (item.kind === 'mcp' && item.toolCount === 0) {
|
||||
setDetailItem(item);
|
||||
return;
|
||||
}
|
||||
const wasSelected = selectedIds.has(itemKey(item));
|
||||
if (!wasSelected && item.status === 'needs_setup') {
|
||||
setDetailItem(item);
|
||||
|
|
|
|||
|
|
@ -2,21 +2,23 @@ import { useState, useMemo, useCallback } from 'react';
|
|||
import { Plus } from 'lucide-react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Label, OGDialog, OGDialogTemplate, useToastContext } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { AgentItem } from './items/types';
|
||||
import ToolRow from './ToolRow';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import SkillsDialog from './SkillsDialog';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
|
||||
import { useBuiltinAuthMap, useUninstallToolCredentials } from './hooks';
|
||||
import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP';
|
||||
import ToolsMarketplaceDialog from './ToolsMarketplaceDialog';
|
||||
import { buildCatalog } from './items/catalog';
|
||||
import { deriveSelectedItems } from './items/selectors';
|
||||
import { computeToggleAction } from './items/mutations';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
|
||||
import { useRemoveMCPTool } from '~/hooks/MCP';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import { buildCatalog } from './items/catalog';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import SkillsDialog from './SkillsDialog';
|
||||
import ToolRow from './ToolRow';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
|
|
@ -56,7 +58,14 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: hasSkillsAccess });
|
||||
const skillsEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.skills) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const showSkills = hasSkillsAccess && skillsEnabled;
|
||||
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
|
||||
const builtinAuthMap = useBuiltinAuthMap();
|
||||
const uninstallToolCredentials = useUninstallToolCredentials();
|
||||
|
||||
const tools = (useWatch({ control, name: 'tools' }) ?? []) as string[];
|
||||
const skills = (useWatch({ control, name: 'skills' }) ?? []) as string[];
|
||||
|
|
@ -83,7 +92,8 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
mcpServersMap: mcpServersMap ?? new Map(),
|
||||
skills: skillsData?.skills ?? [],
|
||||
actions: agentActions,
|
||||
permissions: { mcp: hasMcpAccess, skills: hasSkillsAccess },
|
||||
permissions: { mcp: hasMcpAccess, skills: showSkills },
|
||||
builtinAuthMap,
|
||||
}),
|
||||
[
|
||||
agentsConfig,
|
||||
|
|
@ -92,7 +102,8 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
skillsData,
|
||||
agentActions,
|
||||
hasMcpAccess,
|
||||
hasSkillsAccess,
|
||||
showSkills,
|
||||
builtinAuthMap,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -146,6 +157,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
current.filter((t) => t !== patch.id),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
uninstallToolCredentials(patch.id);
|
||||
break;
|
||||
}
|
||||
case 'skill-remove': {
|
||||
|
|
@ -167,7 +179,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
setOpen(true);
|
||||
}
|
||||
},
|
||||
[getValues, setValue, removeMCPTool],
|
||||
[getValues, setValue, removeMCPTool, uninstallToolCredentials],
|
||||
);
|
||||
|
||||
const confirmActionRemoval = useCallback(() => {
|
||||
|
|
@ -186,7 +198,56 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
setPendingActionRemoval(null);
|
||||
}, [pendingActionRemoval, agentId, deleteAgentAction, showToast, localize]);
|
||||
|
||||
const toolItems = useMemo(() => selected.filter((item) => item.kind !== 'skill'), [selected]);
|
||||
const { mcpServerNames: attachedMcpServers } = useVisibleTools(
|
||||
tools,
|
||||
regularTools ?? undefined,
|
||||
mcpServersMap ?? new Map(),
|
||||
);
|
||||
|
||||
/** MCP servers still referenced by the agent's tools but absent from the available
|
||||
* servers map (removed from config, or a legacy server-only token). The catalog is
|
||||
* built from available servers, so these would otherwise be invisible and
|
||||
* unremovable — surface them as removable "needs setup" rows, mirroring the old
|
||||
* UnconfiguredMCPTool. */
|
||||
const orphanedMcpItems = useMemo<AgentItem[]>(
|
||||
() =>
|
||||
attachedMcpServers
|
||||
.filter((name) => mcpServersMap?.has(name) !== true)
|
||||
.map((name) => ({
|
||||
kind: 'mcp',
|
||||
id: name,
|
||||
name,
|
||||
description: '',
|
||||
iconKey: 'mcp',
|
||||
status: 'needs_setup',
|
||||
toolCount: 0,
|
||||
server: {
|
||||
serverName: name,
|
||||
tools: [],
|
||||
isConfigured: false,
|
||||
isConnected: false,
|
||||
metadata: { name, pluginKey: name, description: '' } as TPlugin,
|
||||
},
|
||||
})),
|
||||
[attachedMcpServers, mcpServersMap],
|
||||
);
|
||||
|
||||
/** MCP rows show how many of the server's tools are enabled for this agent, not
|
||||
* the total the server exposes, so the count reflects what the agent can use. */
|
||||
const toolItems = useMemo(() => {
|
||||
const enabled = new Set(tools);
|
||||
const withCounts = selected
|
||||
.filter((item) => item.kind !== 'skill')
|
||||
.map((item) =>
|
||||
item.kind === 'mcp'
|
||||
? {
|
||||
...item,
|
||||
toolCount: (item.server.tools ?? []).filter((t) => enabled.has(t.tool_id)).length,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
return [...withCounts, ...orphanedMcpItems];
|
||||
}, [selected, orphanedMcpItems, tools]);
|
||||
const skillItems = useMemo(() => selected.filter((item) => item.kind === 'skill'), [selected]);
|
||||
|
||||
return (
|
||||
|
|
@ -201,7 +262,7 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
onInfo={setDialogItem}
|
||||
onRemove={handleQuickRemove}
|
||||
/>
|
||||
{hasSkillsAccess && (
|
||||
{showSkills && (
|
||||
<SelectedSection
|
||||
title={localize('com_ui_skills')}
|
||||
addLabel={localize('com_ui_add_skills')}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks', () => ({
|
||||
useBuiltinAuthMap: () => new Map(),
|
||||
useUninstallToolCredentials: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual('react');
|
||||
const passthrough =
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ jest.mock('~/hooks', () => ({
|
|||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useAgentPanelContext: () => ({
|
||||
agentsConfig: { capabilities: [] },
|
||||
agentsConfig: { capabilities: ['skills'] },
|
||||
regularTools: [],
|
||||
mcpServersMap: new Map(),
|
||||
actions: [],
|
||||
|
|
@ -33,6 +33,12 @@ jest.mock('~/data-provider', () => ({
|
|||
|
||||
jest.mock('~/hooks/MCP', () => ({
|
||||
useRemoveMCPTool: () => ({ removeTool: jest.fn() }),
|
||||
useVisibleTools: () => ({ toolIds: [], mcpServerNames: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks', () => ({
|
||||
useBuiltinAuthMap: () => new Map(),
|
||||
useUninstallToolCredentials: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
|
|
|
|||
53
client/src/components/SidePanel/Agents/Tools/hooks.ts
Normal file
53
client/src/components/SidePanel/Agents/Tools/hooks.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useMemo, useCallback } from 'react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { Tools, AuthType, AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Maps builtin capability ids to whether they still need setup (USER_PROVIDED auth
|
||||
* not yet satisfied). Threaded into `buildCatalog` as `builtinAuthMap` so the
|
||||
* marketplace marks the capability `needs_setup` and routes its toggle to the
|
||||
* config dialog instead of silently enabling it without credentials. Only
|
||||
* `web_search` currently carries a user-provided key with an in-dialog entry point.
|
||||
*/
|
||||
export function useBuiltinAuthMap(): Map<string, boolean> {
|
||||
const { data } = useVerifyAgentToolAuth({ toolId: Tools.web_search }, { retry: 1 });
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
const isUserProvided =
|
||||
data?.authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED) ?? false;
|
||||
if (isUserProvided && data?.authenticated !== true) {
|
||||
map.set(AgentCapabilities.web_search, true);
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callback that revokes a tool's stored user credentials when it is
|
||||
* removed from an agent, mirroring the legacy `AgentTool` removal. Without it,
|
||||
* removing a tool only drops it from the form and leaves the saved credentials
|
||||
* orphaned server-side. Fired unconditionally — a no-op for tools without creds.
|
||||
*/
|
||||
export function useUninstallToolCredentials(): (toolId: string) => void {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
return useCallback(
|
||||
(toolId: string) => {
|
||||
if (!toolId) {
|
||||
return;
|
||||
}
|
||||
updateUserPlugins.mutate(
|
||||
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||
{
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_delete_tool_error'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
},
|
||||
[updateUserPlugins, showToast, localize],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TPlugin, TSkillSummary, Action } from 'librechat-data-provider';
|
||||
import type { MCPServerInfo } from '~/common';
|
||||
import type { AgentItem, SkillItem, BuiltinId } from './types';
|
||||
import type { MCPServerInfo } from '~/common';
|
||||
import { pluginNeedsAuth } from './auth';
|
||||
|
||||
/** Maps skill summaries to catalog items, flagging the current user's own skills. */
|
||||
|
|
@ -65,12 +65,6 @@ const BUILTIN_DEFINITIONS: BuiltinDef[] = [
|
|||
nameKey: 'com_ui_artifacts',
|
||||
descriptionKey: 'com_ui_artifacts_subtext',
|
||||
},
|
||||
{
|
||||
id: AgentCapabilities.context,
|
||||
iconKey: 'context',
|
||||
nameKey: 'com_agents_file_context_label',
|
||||
descriptionKey: 'com_agents_file_context_description',
|
||||
},
|
||||
{
|
||||
id: AgentCapabilities.file_search,
|
||||
iconKey: 'file_search',
|
||||
|
|
@ -118,6 +112,7 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] {
|
|||
iconKey: 'mcp',
|
||||
server,
|
||||
toolCount: server.tools?.length ?? 0,
|
||||
status: server.isConfigured === false ? 'needs_setup' : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { AgentItem } from './types';
|
||||
import { pluginNeedsAuth } from './auth';
|
||||
|
||||
/**
|
||||
* Whether an item's detail dialog exposes configurable controls (credentials,
|
||||
* tool selection, server/action settings) beyond a plain description. Drives the
|
||||
* Settings-vs-Info affordance: a configurable item gets a cog button, an
|
||||
* informational one gets an info icon.
|
||||
*/
|
||||
export function hasConfigurableSettings(item: AgentItem): boolean {
|
||||
switch (item.kind) {
|
||||
case 'builtin':
|
||||
return item.id === 'artifacts' || item.id === 'file_search' || item.id === 'context';
|
||||
case 'tool':
|
||||
return pluginNeedsAuth(item.plugin);
|
||||
case 'mcp':
|
||||
case 'action':
|
||||
return true;
|
||||
case 'skill':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,11 @@ function DropzoneContent({ label, hint }: { label: string; hint?: string }) {
|
|||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-text-primary">{label}</span>
|
||||
{hint ? <span className="text-xs font-normal text-text-tertiary">{hint}</span> : null}
|
||||
{hint ? (
|
||||
<span className="text-xs font-normal text-text-secondary transition-colors group-hover:text-text-primary">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ jest.mock('@ariakit/react', () => ({
|
|||
jest.mock('@librechat/client', () => ({
|
||||
SharePointIcon: () => <span />,
|
||||
DropdownPopup: () => null,
|
||||
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
|
||||
CircleHelpIcon: () => <span />,
|
||||
HoverCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
HoverCardPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { AgentToolType } from 'librechat-data-provider';
|
||||
import MCPToolItem from '../MCPToolItem';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
|
||||
}));
|
||||
|
||||
const tool = {
|
||||
tool_id: 'mcp:srv:execute',
|
||||
metadata: { name: 'execute', description: 'Runs JavaScript code against the API.' },
|
||||
} as unknown as AgentToolType;
|
||||
|
||||
function setup(overrides: Partial<React.ComponentProps<typeof MCPToolItem>> = {}) {
|
||||
const props = {
|
||||
tool,
|
||||
isSelected: false,
|
||||
isDeferred: false,
|
||||
isProgrammatic: false,
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
onToggleSelect: jest.fn(),
|
||||
onToggleDefer: jest.fn(),
|
||||
onToggleProgrammatic: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<MCPToolItem {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('MCPToolItem', () => {
|
||||
test('clicking the row (not just a checkbox) toggles selection', () => {
|
||||
const props = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'execute' }));
|
||||
expect(props.onToggleSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('reflects selection via aria-pressed', () => {
|
||||
setup({ isSelected: true });
|
||||
expect(screen.getByRole('button', { name: 'execute' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('the info button toggles the description panel via aria-expanded', () => {
|
||||
setup();
|
||||
const infoButton = screen.getByRole('button', { name: 'com_ui_tools_info' });
|
||||
expect(infoButton).toHaveAttribute('aria-expanded', 'false');
|
||||
// The description is always rendered (the panel animates open/closed), so it stays queryable.
|
||||
expect(screen.getByText('Runs JavaScript code against the API.')).toBeInTheDocument();
|
||||
fireEvent.click(infoButton);
|
||||
expect(infoButton).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('detail button is an info affordance when no options are enabled', () => {
|
||||
setup();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_tools_info' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_mcp_tool_options' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('detail button stays an info affordance (never a cog) when options exist', () => {
|
||||
setup({ deferredToolsEnabled: true, programmaticToolsEnabled: true });
|
||||
expect(screen.getByRole('button', { name: 'com_ui_tools_info' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_mcp_tool_options' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defer loading is an inline button (left of the cog) that toggles defer', () => {
|
||||
const props = setup({ deferredToolsEnabled: true });
|
||||
const deferButton = screen.getByRole('button', { name: 'com_ui_mcp_defer_loading' });
|
||||
fireEvent.click(deferButton);
|
||||
expect(props.onToggleDefer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('defer button is absent when deferred tools are disabled', () => {
|
||||
setup();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_mcp_defer_loading' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('programmatic is an inline button rendered only when enabled', () => {
|
||||
const props = setup({ programmaticToolsEnabled: true });
|
||||
const programmaticButton = screen.getByRole('button', { name: 'com_ui_mcp_programmatic' });
|
||||
fireEvent.click(programmaticButton);
|
||||
expect(props.onToggleProgrammatic).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +1,45 @@
|
|||
import { useState } from 'react';
|
||||
import { Copy, CopyCheck } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Input, Label } from '@librechat/client';
|
||||
import { AuthTypeEnum } from 'librechat-data-provider';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ActionCallback({ action_id }: { action_id?: string }) {
|
||||
const localize = useLocalize();
|
||||
const { watch } = useFormContext();
|
||||
const { showToast } = useToastContext();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const callbackURL = `${window.location.protocol}//${window.location.host}/api/actions/${action_id}/oauth/callback`;
|
||||
const copyLink = useCopyToClipboard({ text: callbackURL });
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
if (!action_id) {
|
||||
return null;
|
||||
}
|
||||
const type = watch('type');
|
||||
if (type !== AuthTypeEnum.OAuth) {
|
||||
const callbackURL = `${window.location.protocol}//${window.location.host}/api/actions/${action_id}/oauth/callback`;
|
||||
const copyCallbackURL = useCopyToClipboard({ text: callbackURL });
|
||||
|
||||
if (!action_id || watch('type') !== AuthTypeEnum.OAuth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-1.5 flex flex-col space-y-2">
|
||||
<label className="font-semibold">{localize('com_ui_callback_url')}</label>
|
||||
<div className="relative flex items-center">
|
||||
<div className="border-token-border-medium bg-token-surface-primary hover:border-token-border-hover flex h-10 w-full rounded-lg border">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={callbackURL}
|
||||
className="w-full border-0 bg-transparent px-3 py-2 pr-12 text-sm text-text-secondary-alt focus:outline-none"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 flex h-full items-center pr-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCopying) {
|
||||
return;
|
||||
}
|
||||
showToast({ message: localize('com_ui_copied_to_clipboard') });
|
||||
copyLink(setIsCopying);
|
||||
}}
|
||||
className={cn('h-8 rounded-md px-2', isCopying ? 'cursor-default' : '')}
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
>
|
||||
{isCopying ? (
|
||||
<CopyCheck className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-callback-url" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_callback_url')}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="oauth-callback-url"
|
||||
type="text"
|
||||
readOnly
|
||||
dir="ltr"
|
||||
value={callbackURL}
|
||||
aria-label={localize('com_ui_callback_url')}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="pr-10 text-text-secondary"
|
||||
/>
|
||||
<CopyButton
|
||||
iconOnly
|
||||
isCopied={isCopied}
|
||||
onClick={() => copyCallbackURL(setIsCopied)}
|
||||
label={localize('com_ui_copy_link')}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useRef, useState, useLayoutEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import { ChevronRight, KeyRound, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
|
|
@ -9,21 +9,19 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import {
|
||||
Input,
|
||||
Radio,
|
||||
Button,
|
||||
OGDialog,
|
||||
SecretInput,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
OGDialogDescription,
|
||||
} from '@librechat/client';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AuthMethod {
|
||||
value: AuthTypeEnum;
|
||||
|
|
@ -59,6 +57,13 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
|||
const { watch, setValue, trigger } = useFormContext();
|
||||
const type = watch('type');
|
||||
const current = AUTH_METHODS.find((method) => method.value === type) ?? AUTH_METHODS[0];
|
||||
const authOptions = AUTH_METHODS.filter(
|
||||
(method) => !(method.value === AuthTypeEnum.OAuth && disableOAuth === true),
|
||||
).map((method) => ({
|
||||
value: method.value,
|
||||
label: localize(method.titleKey),
|
||||
icon: <method.icon className="size-4" aria-hidden={true} />,
|
||||
}));
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await trigger(undefined, { shouldFocus: true });
|
||||
|
|
@ -75,7 +80,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
|||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-border-medium bg-surface-primary px-3 py-2.5 text-left transition-colors hover:border-border-heavy hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-border-light bg-transparent px-3 py-2.5 text-left transition-colors hover:bg-surface-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
>
|
||||
<current.icon className="size-5 shrink-0 text-text-secondary" aria-hidden={true} />
|
||||
<span className="min-w-0 flex-1">
|
||||
|
|
@ -86,84 +91,32 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
|||
{localize(current.descKey)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-text-tertiary transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<ChevronRight className="text-text-tertiar size-4 shrink-0" aria-hidden={true} />
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
<OGDialogContent className="w-full max-w-lg overflow-hidden bg-surface-primary p-0 text-text-primary">
|
||||
<OGDialogHeader className="space-y-1 border-b border-border-light px-6 py-4 pr-10">
|
||||
<OGDialogContent className="w-full max-w-lg bg-surface-primary text-text-primary">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-semibold">
|
||||
{localize('com_ui_authentication')}
|
||||
</OGDialogTitle>
|
||||
<OGDialogDescription>{localize('com_ui_authentication_desc')}</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-5">
|
||||
<RadioGroup.Root
|
||||
<div>
|
||||
<span id="auth-method-label" className="sr-only">
|
||||
{localize('com_ui_authentication_type')}
|
||||
</span>
|
||||
<Radio
|
||||
options={authOptions}
|
||||
value={type}
|
||||
onValueChange={(value) => setValue('type', value)}
|
||||
aria-label={localize('com_ui_authentication_type')}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{AUTH_METHODS.map((method) => {
|
||||
const selected = type === method.value;
|
||||
const disabled = method.value === AuthTypeEnum.OAuth && disableOAuth === true;
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
key={method.value}
|
||||
value={method.value}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl border p-3 text-left transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
selected
|
||||
? 'border-border-heavy bg-surface-active'
|
||||
: 'border-border-light hover:border-border-medium hover:bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
<method.icon
|
||||
className={cn(
|
||||
'size-5 shrink-0',
|
||||
selected ? 'text-text-primary' : 'text-text-secondary',
|
||||
)}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-text-primary">
|
||||
{localize(method.titleKey)}
|
||||
</span>
|
||||
<span className="block text-xs text-text-secondary">
|
||||
{localize(method.descKey)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
selected ? 'border-text-primary' : 'border-border-heavy',
|
||||
)}
|
||||
>
|
||||
{selected && <span className="size-2 rounded-full bg-text-primary" />}
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
})}
|
||||
</RadioGroup.Root>
|
||||
|
||||
{type !== AuthTypeEnum.None && (
|
||||
<div className="mt-5 space-y-4 border-t border-border-light pt-5">
|
||||
{type === AuthTypeEnum.ServiceHttp ? <ApiKey /> : <OAuth />}
|
||||
</div>
|
||||
)}
|
||||
onChange={(value) => setValue('type', value)}
|
||||
fullWidth
|
||||
aria-labelledby="auth-method-label"
|
||||
/>
|
||||
<AnimatedAuthFields type={type} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border-light px-6 py-4">
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline">{localize('com_ui_cancel')}</Button>
|
||||
</OGDialogClose>
|
||||
<Button variant="submit" onClick={handleSave}>
|
||||
{localize('com_ui_save')}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="default" onClick={handleSave}>
|
||||
{localize('com_ui_done')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
|
|
@ -171,6 +124,49 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates the auth fields' height to their measured content height. Animating to
|
||||
* a measured pixel value (instead of `height: 'auto'`) is what lets the
|
||||
* ApiKey <-> OAuth swap tween between two different heights — `auto` never changes,
|
||||
* so it would jump. The content swaps instantly; only the container height eases.
|
||||
*/
|
||||
function AnimatedAuthFields({ type }: { type: AuthTypeEnum }) {
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState<number | 'auto'>(type === AuthTypeEnum.None ? 0 : 'auto');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = innerRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const measure = () => setHeight(type === AuthTypeEnum.None ? 0 : el.scrollHeight);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [type]);
|
||||
|
||||
let fields: ReactNode = null;
|
||||
if (type === AuthTypeEnum.ServiceHttp) {
|
||||
fields = <ApiKey />;
|
||||
} else if (type === AuthTypeEnum.OAuth) {
|
||||
fields = <OAuth />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height, opacity: type === AuthTypeEnum.None ? 0 : 1 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div ref={innerRef} className="space-y-4 pt-4">
|
||||
{fields}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
htmlFor,
|
||||
label,
|
||||
|
|
@ -209,28 +205,16 @@ function SegmentedField({
|
|||
<label id={id} className="block text-xs font-medium text-text-secondary">
|
||||
{label}
|
||||
</label>
|
||||
<RadioGroup.Root
|
||||
<Radio
|
||||
options={options.map((option) => ({
|
||||
value: option.value,
|
||||
label: localize(option.labelKey),
|
||||
}))}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
onChange={onValueChange}
|
||||
fullWidth
|
||||
aria-labelledby={id}
|
||||
className="inline-flex w-full rounded-lg border border-border-light p-0.5"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioGroup.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
value === option.value
|
||||
? 'bg-surface-active text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{localize(option.labelKey)}
|
||||
</RadioGroup.Item>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -884,6 +884,8 @@
|
|||
"com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool",
|
||||
"com_ui_attachment": "Attachment",
|
||||
"com_ui_auth_apikey_desc": "Send a key or token with each request",
|
||||
"com_ui_auth_format_hint": "This usually starts with \"{{0}}\"",
|
||||
"com_ui_auth_invalid_url": "Enter a valid URL, including http:// or https://",
|
||||
"com_ui_auth_none_desc": "No authentication required",
|
||||
"com_ui_auth_oauth_desc": "Authorize through an OAuth 2.0 provider",
|
||||
"com_ui_auth_type": "Auth Type",
|
||||
|
|
@ -1101,6 +1103,7 @@
|
|||
"com_ui_delete_success": "Successfully deleted",
|
||||
"com_ui_delete_tool": "Delete Tool",
|
||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||
"com_ui_delete_tool_error": "Error removing the tool's saved credentials.",
|
||||
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
|
||||
"com_ui_deleted": "Deleted",
|
||||
"com_ui_deleting": "Deleting...",
|
||||
|
|
@ -1308,8 +1311,8 @@
|
|||
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
|
||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
|
||||
"com_ui_mcp_click_to_defer": "Click to defer - tool will be discoverable via search but not loaded until needed",
|
||||
"com_ui_mcp_click_to_programmatic": "Enable programmatic calling - tool can only be invoked via code execution",
|
||||
"com_ui_mcp_click_to_defer": "Loads this tool only when the agent needs it instead of keeping it active the whole time. Useful when a server has many tools.",
|
||||
"com_ui_mcp_click_to_programmatic": "The agent calls this tool by writing code, not as a direct tool call. Best for tools meant to run in code.",
|
||||
"com_ui_mcp_configure_server": "Configure {{0}}",
|
||||
"com_ui_mcp_configure_server_description": "Configure custom variables for {{0}}",
|
||||
"com_ui_mcp_defer": "Defer",
|
||||
|
|
@ -1387,6 +1390,7 @@
|
|||
"com_ui_message_nav_go_to_user": "Go to user message: {{0}}",
|
||||
"com_ui_message_nav_next": "Navigate to next message",
|
||||
"com_ui_message_nav_previous": "Navigate to previous message",
|
||||
"com_ui_method": "Method",
|
||||
"com_ui_microphone_unavailable": "Microphone is not available",
|
||||
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
|
||||
"com_ui_minimal": "Minimal",
|
||||
|
|
@ -1469,6 +1473,7 @@
|
|||
"com_ui_page": "Page",
|
||||
"com_ui_pagination": "Pagination",
|
||||
"com_ui_parameters": "Parameters",
|
||||
"com_ui_path": "Path",
|
||||
"com_ui_people": "people",
|
||||
"com_ui_people_picker": "People Picker",
|
||||
"com_ui_people_picker_allow_view_groups": "Allow viewing groups",
|
||||
|
|
@ -1868,6 +1873,7 @@
|
|||
"com_ui_token_url": "Token URL",
|
||||
"com_ui_tokens": "tokens",
|
||||
"com_ui_tool_collection_prefix": "A collection of tools from",
|
||||
"com_ui_tool_credentials_saved": "Credentials saved",
|
||||
"com_ui_tool_failed": "failed",
|
||||
"com_ui_tool_list_collapse": "Collapse {{serverName}} tool list",
|
||||
"com_ui_tool_list_expand": "Expand {{serverName}} tool list",
|
||||
|
|
|
|||
|
|
@ -2663,7 +2663,7 @@ html {
|
|||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 150;
|
||||
z-index: 900;
|
||||
cursor: pointer;
|
||||
border-radius: 0.275rem;
|
||||
background-color: var(--surface-primary);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const Input: React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HT
|
|||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'lc-field flex h-10 w-full rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary ring-offset-background placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'lc-field flex h-10 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary ring-offset-background placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className ?? '',
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, memo } from 'react';
|
||||
import React, { useState, useRef, useLayoutEffect, useCallback, memo } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface Option {
|
||||
|
|
@ -29,6 +29,7 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
|
|||
'aria-labelledby': ariaLabelledBy,
|
||||
}: RadioProps) {
|
||||
const localize = useLocalize();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
|
||||
|
|
@ -41,36 +42,33 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
|
|||
|
||||
const updateBackgroundStyle = useCallback(() => {
|
||||
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
|
||||
if (selectedIndex >= 0 && buttonRefs.current[selectedIndex]) {
|
||||
const selectedButton = buttonRefs.current[selectedIndex];
|
||||
const container = selectedButton?.parentElement;
|
||||
if (selectedButton && container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const buttonRect = selectedButton.getBoundingClientRect();
|
||||
const offsetLeft = buttonRect.left - containerRect.left;
|
||||
setBackgroundStyle({
|
||||
width: `${buttonRect.width}px`,
|
||||
transform: `translateX(${offsetLeft}px)`,
|
||||
});
|
||||
}
|
||||
const selectedButton = buttonRefs.current[selectedIndex];
|
||||
if (selectedIndex < 0 || !selectedButton) {
|
||||
return;
|
||||
}
|
||||
// offsetWidth/offsetLeft are layout metrics: unlike getBoundingClientRect they
|
||||
// are not distorted by the dialog's open transform (scale), and they resolve to
|
||||
// whole pixels, so the indicator matches its segment and keeps crisp borders.
|
||||
setBackgroundStyle({
|
||||
width: `${selectedButton.offsetWidth}px`,
|
||||
transform: `translateX(${selectedButton.offsetLeft}px)`,
|
||||
});
|
||||
}, [currentValue, options]);
|
||||
|
||||
// Mark as mounted after dialog animations settle
|
||||
// Timeout ensures we wait for CSS transitions to complete
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsMounted(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
// Measure before paint and re-measure on any later layout change (the dialog's
|
||||
// open animation settling, a window resize). A fixed timeout previously raced
|
||||
// the dialog transition and left the indicator mis-sized.
|
||||
useLayoutEffect(() => {
|
||||
if (isMounted) {
|
||||
updateBackgroundStyle();
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
}, [isMounted, updateBackgroundStyle]);
|
||||
updateBackgroundStyle();
|
||||
setIsMounted(true);
|
||||
const observer = new ResizeObserver(() => updateBackgroundStyle());
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [updateBackgroundStyle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (value !== undefined) {
|
||||
|
|
@ -96,7 +94,8 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted ${className}`}
|
||||
ref={containerRef}
|
||||
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted px-1 ${className}`}
|
||||
role="radiogroup"
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.tooltip {
|
||||
z-index: 150;
|
||||
z-index: 900;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
border-radius: 0.275rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue