mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-17 16:05:32 +01:00
Merge branch 'main' into feat/Multitenant-login-OIDC
This commit is contained in:
commit
c14751cef5
417 changed files with 28394 additions and 9012 deletions
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="LibreChat - An open source chat application with support for multiple AI models" />
|
||||
<title>LibreChat</title>
|
||||
<link rel="shortcut icon" href="#" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
|
||||
|
|
@ -53,6 +54,5 @@
|
|||
<div id="root">
|
||||
<div id="loading-container"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.7.7-rc1",
|
||||
"version": "v0.7.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -28,7 +28,8 @@
|
|||
},
|
||||
"homepage": "https://librechat.ai",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.11",
|
||||
"@ariakit/react": "^0.4.15",
|
||||
"@ariakit/react-core": "^0.4.15",
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
"@dicebear/collection": "^7.0.4",
|
||||
"@dicebear/core": "^7.0.4",
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
|
|
@ -63,6 +65,8 @@
|
|||
"framer-motion": "^11.5.4",
|
||||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
@ -82,7 +86,7 @@
|
|||
"react-i18next": "^15.4.0",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
|
|
@ -138,6 +142,7 @@
|
|||
"typescript": "^5.3.3",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-node-polyfills": "^0.17.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 4.8 KiB |
BIN
client/public/assets/icon-192x192.png
Normal file
BIN
client/public/assets/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 12 KiB |
3
client/public/robots.txt
Normal file
3
client/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
User-agent: *
|
||||
Disallow: /api/
|
||||
Allow: /
|
||||
9
client/src/@types/i18next.d.ts
vendored
Normal file
9
client/src/@types/i18next.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defaultNS, resources } from '~/locales/i18n';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type { OptionWithIcon, ExtendedFile } from './types';
|
|||
export type TAgentOption = OptionWithIcon &
|
||||
Agent & {
|
||||
knowledge_files?: Array<[string, ExtendedFile]>;
|
||||
context_files?: Array<[string, ExtendedFile]>;
|
||||
code_files?: Array<[string, ExtendedFile]>;
|
||||
};
|
||||
|
||||
|
|
@ -27,4 +28,5 @@ export type AgentForm = {
|
|||
provider?: AgentProvider | OptionWithIcon;
|
||||
agent_ids?: string[];
|
||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||
recursion_limit?: number;
|
||||
} & TAgentCapabilities;
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export type IconsRecord = {
|
|||
export type AgentIconMapProps = IconMapProps & { agentName?: string };
|
||||
|
||||
export type NavLink = {
|
||||
title: string;
|
||||
title: TranslationKeys;
|
||||
label?: string;
|
||||
icon: LucideIcon | React.FC;
|
||||
Component?: React.ComponentType;
|
||||
|
|
@ -131,6 +131,7 @@ export interface DataColumnMeta {
|
|||
}
|
||||
|
||||
export enum Panel {
|
||||
advanced = 'advanced',
|
||||
builder = 'builder',
|
||||
actions = 'actions',
|
||||
model = 'model',
|
||||
|
|
@ -181,6 +182,7 @@ export type AgentPanelProps = {
|
|||
activePanel?: string;
|
||||
action?: t.Action;
|
||||
actions?: t.Action[];
|
||||
createMutation: UseMutationResult<t.Agent, Error, t.AgentCreateParams>;
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
|
||||
endpointsConfig?: t.TEndpointsConfig;
|
||||
|
|
@ -370,12 +372,12 @@ export type TDangerButtonProps = {
|
|||
showText?: boolean;
|
||||
mutation?: UseMutationResult<unknown>;
|
||||
onClick: () => void;
|
||||
infoTextCode: string;
|
||||
actionTextCode: string;
|
||||
infoTextCode: TranslationKeys;
|
||||
actionTextCode: TranslationKeys;
|
||||
dataTestIdInitial: string;
|
||||
dataTestIdConfirm: string;
|
||||
infoDescriptionCode?: string;
|
||||
confirmActionTextCode?: string;
|
||||
infoDescriptionCode?: TranslationKeys;
|
||||
confirmActionTextCode?: TranslationKeys;
|
||||
};
|
||||
|
||||
export type TDialogProps = {
|
||||
|
|
@ -399,7 +401,7 @@ export type TAuthContext = {
|
|||
isAuthenticated: boolean;
|
||||
error: string | undefined;
|
||||
login: (data: t.TLoginUser) => void;
|
||||
logout: () => void;
|
||||
logout: (redirect?: string) => void;
|
||||
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
roles?: Record<string, t.TRole | null | undefined>;
|
||||
};
|
||||
|
|
@ -483,6 +485,7 @@ export interface ExtendedFile {
|
|||
attached?: boolean;
|
||||
embedded?: boolean;
|
||||
tool_resource?: string;
|
||||
metadata?: t.TFile['metadata'];
|
||||
}
|
||||
|
||||
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
import { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { ArtifactFiles, Artifact } from '~/common';
|
||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
import { useEditArtifact } from '~/data-provider';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
|
||||
const createDebouncedMutation = (
|
||||
|
|
@ -124,6 +124,17 @@ export const ArtifactCodeEditor = memo(function ({
|
|||
sharedProps: Partial<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const options: typeof sharedOptions = useMemo(() => {
|
||||
if (!config) {
|
||||
return sharedOptions;
|
||||
}
|
||||
return {
|
||||
...sharedOptions,
|
||||
bundlerURL: config.bundlerURL,
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -135,7 +146,7 @@ export const ArtifactCodeEditor = memo(function ({
|
|||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={{ ...sharedOptions }}
|
||||
options={options}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { ArtifactFiles } from '~/common';
|
||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
|
||||
export const ArtifactPreview = memo(function ({
|
||||
|
|
@ -23,6 +24,8 @@ export const ArtifactPreview = memo(function ({
|
|||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
}) {
|
||||
const { currentCode } = useEditorContext();
|
||||
const { data: config } = useGetStartupConfig();
|
||||
|
||||
const artifactFiles = useMemo(() => {
|
||||
if (Object.keys(files).length === 0) {
|
||||
return files;
|
||||
|
|
@ -38,6 +41,17 @@ export const ArtifactPreview = memo(function ({
|
|||
},
|
||||
};
|
||||
}, [currentCode, files, fileKey]);
|
||||
|
||||
const options: typeof sharedOptions = useMemo(() => {
|
||||
if (!config) {
|
||||
return sharedOptions;
|
||||
}
|
||||
return {
|
||||
...sharedOptions,
|
||||
bundlerURL: config.bundlerURL,
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
if (Object.keys(artifactFiles).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -48,7 +62,7 @@ export const ArtifactPreview = memo(function ({
|
|||
...artifactFiles,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={{ ...sharedOptions }}
|
||||
options={options}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ function AuthLayout({
|
|||
</h1>
|
||||
)}
|
||||
{children}
|
||||
{(pathname.includes('login') || pathname.includes('register')) && (
|
||||
{!pathname.includes('2fa') &&
|
||||
(pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender startupConfig={startupConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,78 @@
|
|||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
||||
import { getLoginError } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import LoginForm from './LoginForm';
|
||||
import SocialButton from '~/components/Auth/SocialButton';
|
||||
import { OpenIDIcon } from '~/components';
|
||||
|
||||
function Login() {
|
||||
const localize = useLocalize();
|
||||
const { error, setError, login } = useAuthContext();
|
||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Determine if auto-redirect should be disabled based on the URL parameter
|
||||
const disableAutoRedirect = searchParams.get('redirect') === 'false';
|
||||
|
||||
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
|
||||
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
|
||||
|
||||
// Once the disable flag is detected, update local state and remove the parameter from the URL.
|
||||
useEffect(() => {
|
||||
if (disableAutoRedirect) {
|
||||
setIsAutoRedirectDisabled(true);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('redirect');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [disableAutoRedirect, searchParams, setSearchParams]);
|
||||
|
||||
// Determine whether we should auto-redirect to OpenID.
|
||||
const shouldAutoRedirect =
|
||||
startupConfig?.openidLoginEnabled &&
|
||||
startupConfig?.openidAutoRedirect &&
|
||||
startupConfig?.serverDomain &&
|
||||
!isAutoRedirectDisabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoRedirect) {
|
||||
console.log('Auto-redirecting to OpenID provider...');
|
||||
window.location.href = `${startupConfig.serverDomain}/oauth/openid`;
|
||||
}
|
||||
}, [shouldAutoRedirect, startupConfig]);
|
||||
|
||||
// Render fallback UI if auto-redirect is active.
|
||||
if (shouldAutoRedirect) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<p className="text-lg font-semibold">
|
||||
{localize('com_ui_redirecting_to_provider', { 0: startupConfig.openidLabel })}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SocialButton
|
||||
key="openid"
|
||||
enabled={startupConfig.openidLoginEnabled}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
oauthPath="openid"
|
||||
Icon={() =>
|
||||
startupConfig.openidImageUrl ? (
|
||||
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)
|
||||
}
|
||||
label={startupConfig.openidLabel}
|
||||
id="openid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error != null && <ErrorMessage>{localize(getLoginError(error))}</ErrorMessage>}
|
||||
|
|
|
|||
|
|
@ -166,9 +166,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
type="submit"
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
|
|
|
|||
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '~/components';
|
||||
import { useVerifyTwoFactorTempMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VerifyPayload {
|
||||
tempToken: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
}
|
||||
|
||||
type TwoFactorFormInputs = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
const TwoFactorScreen: React.FC = React.memo(() => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const tempTokenRaw = searchParams.get('tempToken');
|
||||
const tempToken = tempTokenRaw !== null && tempTokenRaw !== '' ? tempTokenRaw : '';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TwoFactorFormInputs>();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [useBackup, setUseBackup] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { mutate: verifyTempMutate } = useVerifyTwoFactorTempMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.token != null && result.token !== '') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsLoading(true);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setIsLoading(false);
|
||||
const err = error as { response?: { data?: { message?: unknown } } };
|
||||
const errorMsg =
|
||||
typeof err.response?.data?.message === 'string'
|
||||
? err.response.data.message
|
||||
: 'Error verifying 2FA';
|
||||
showToast({ message: errorMsg, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: TwoFactorFormInputs) => {
|
||||
const payload: VerifyPayload = { tempToken };
|
||||
if (useBackup && data.backupCode != null && data.backupCode !== '') {
|
||||
payload.backupCode = data.backupCode;
|
||||
} else if (data.token != null && data.token !== '') {
|
||||
payload.token = data.token;
|
||||
}
|
||||
verifyTempMutate(payload);
|
||||
},
|
||||
[tempToken, useBackup, verifyTempMutate],
|
||||
);
|
||||
|
||||
const toggleBackupOn = useCallback(() => {
|
||||
setUseBackup(true);
|
||||
}, []);
|
||||
|
||||
const toggleBackupOff = useCallback(() => {
|
||||
setUseBackup(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Label className="flex justify-center break-keep text-center text-sm text-text-primary">
|
||||
{localize('com_auth_two_factor')}
|
||||
</Label>
|
||||
{!useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="token"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.token && <span className="text-sm text-red-500">{errors.token.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="backupCode"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.backupCode && (
|
||||
<span className="text-sm text-red-500">{errors.backupCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-80 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{isLoading ? localize('com_auth_email_verifying_ellipsis') : localize('com_ui_verify')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
{!useBackup ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOn}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOff}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_2fa_code')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TwoFactorScreen;
|
||||
|
|
@ -4,3 +4,4 @@ export { default as ResetPassword } from './ResetPassword';
|
|||
export { default as VerifyEmail } from './VerifyEmail';
|
||||
export { default as ApiErrorWatcher } from './ApiErrorWatcher';
|
||||
export { default as RequestPasswordReset } from './RequestPasswordReset';
|
||||
export { default as TwoFactorScreen } from './TwoFactorScreen';
|
||||
|
|
|
|||
|
|
@ -81,17 +81,25 @@ export default function AudioRecorder({
|
|||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
id="audio-recorder"
|
||||
aria-label={localize('com_ui_use_micrphone')}
|
||||
onClick={isListening === true ? handleStopRecording : handleStartRecording}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
|
||||
isRTL ? 'bottom-2 left-2' : 'bottom-2 right-2',
|
||||
)}
|
||||
description={localize('com_ui_use_micrphone')}
|
||||
>
|
||||
{renderIcon()}
|
||||
</TooltipAnchor>
|
||||
render={
|
||||
<button
|
||||
id="audio-recorder"
|
||||
type="button"
|
||||
aria-label={localize('com_ui_use_micrphone')}
|
||||
onClick={isListening === true ? handleStopRecording : handleStartRecording}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
|
||||
isRTL ? 'bottom-2 left-2' : 'bottom-2 right-2',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
)}
|
||||
title={localize('com_ui_use_micrphone')}
|
||||
aria-pressed={isListening}
|
||||
>
|
||||
{renderIcon()}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon } from 'lucide-react';
|
||||
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { FileUpload, TooltipAnchor, DropdownPopup } from '~/components/ui';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { AttachmentIcon } from '~/components/svg';
|
||||
|
|
@ -49,6 +49,17 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
|
|||
},
|
||||
];
|
||||
|
||||
if (capabilities.includes(EToolResources.ocr)) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.ocr);
|
||||
handleUploadClick();
|
||||
},
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.includes(EToolResources.file_search)) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { EModelEndpoint, EToolResources } from 'librechat-data-provider';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon } from 'lucide-react';
|
||||
import { FileSearch, ImageUpIcon, FileType2Icon, TerminalSquareIcon } from 'lucide-react';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
|
@ -50,6 +50,12 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
value: EToolResources.execute_code,
|
||||
icon: <TerminalSquareIcon className="icon-md" />,
|
||||
});
|
||||
} else if (capability === EToolResources.ocr) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
value: EToolResources.ocr,
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const FilePreview = ({
|
|||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const radius = 55; // Radius of the SVG circle
|
||||
const radius = 55;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = useProgress(
|
||||
file?.['progress'] ?? 1,
|
||||
|
|
@ -27,16 +27,15 @@ const FilePreview = ({
|
|||
(file as ExtendedFile | undefined)?.size ?? 1,
|
||||
);
|
||||
|
||||
// Calculate the offset based on the loading progress
|
||||
const offset = circumference - progress * circumference;
|
||||
const circleCSSProperties = {
|
||||
transition: 'stroke-dashoffset 0.5s linear',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('size-10 shrink-0 overflow-hidden rounded-xl', className)}>
|
||||
<div className={cn('relative size-10 shrink-0 overflow-hidden rounded-xl', className)}>
|
||||
<FileIcon file={file} fileType={fileType} />
|
||||
<SourceIcon source={file?.source} />
|
||||
<SourceIcon source={file?.source} isCodeFile={!!file?.['metadata']?.fileIdentifier} />
|
||||
{progress < 1 && (
|
||||
<ProgressCircle
|
||||
circumference={circumference}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
|
||||
let statusText: string;
|
||||
if (!status) {
|
||||
statusText = text ?? localize('com_endpoint_import');
|
||||
statusText = text ?? localize('com_ui_import');
|
||||
} else if (status === 'success') {
|
||||
statusText = successText ?? localize('com_ui_upload_success');
|
||||
} else {
|
||||
|
|
@ -72,12 +72,12 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
)}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span className="flex text-xs ">{statusText}</span>
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
<input
|
||||
id={`file-upload-${id}`}
|
||||
value=""
|
||||
type="file"
|
||||
className={cn('hidden ', className)}
|
||||
className={cn('hidden', className)}
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Terminal, Type, Database } from 'lucide-react';
|
||||
import { EModelEndpoint, FileSources } from 'librechat-data-provider';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -6,9 +7,13 @@ const sourceToEndpoint = {
|
|||
[FileSources.openai]: EModelEndpoint.openAI,
|
||||
[FileSources.azure]: EModelEndpoint.azureOpenAI,
|
||||
};
|
||||
|
||||
const sourceToClassname = {
|
||||
[FileSources.openai]: 'bg-white/75 dark:bg-black/65',
|
||||
[FileSources.azure]: 'azure-bg-color opacity-85',
|
||||
[FileSources.execute_code]: 'bg-black text-white opacity-85',
|
||||
[FileSources.text]: 'bg-blue-500 dark:bg-blue-900 opacity-85 text-white',
|
||||
[FileSources.vectordb]: 'bg-yellow-700 dark:bg-yellow-900 opacity-85 text-white',
|
||||
};
|
||||
|
||||
const defaultClassName =
|
||||
|
|
@ -16,13 +21,41 @@ const defaultClassName =
|
|||
|
||||
export default function SourceIcon({
|
||||
source,
|
||||
isCodeFile,
|
||||
className = defaultClassName,
|
||||
}: {
|
||||
source?: FileSources;
|
||||
isCodeFile?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (source === FileSources.local || source === FileSources.firebase) {
|
||||
return null;
|
||||
if (isCodeFile === true) {
|
||||
return (
|
||||
<div className={cn(className, sourceToClassname[FileSources.execute_code] ?? '')}>
|
||||
<span className="flex items-center justify-center">
|
||||
<Terminal className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (source === FileSources.text) {
|
||||
return (
|
||||
<div className={cn(className, sourceToClassname[source] ?? '')}>
|
||||
<span className="flex items-center justify-center">
|
||||
<Type className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (source === FileSources.vectordb) {
|
||||
return (
|
||||
<div className={cn(className, sourceToClassname[source] ?? '')}>
|
||||
<span className="flex items-center justify-center">
|
||||
<Database className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const endpoint = sourceToEndpoint[source ?? ''];
|
||||
|
|
@ -31,7 +64,7 @@ export default function SourceIcon({
|
|||
return null;
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cn(className, sourceToClassname[source ?? ''] ?? '')}>
|
||||
<div className={cn(className, sourceToClassname[source ?? ''] ?? '')}>
|
||||
<span className="flex items-center justify-center">
|
||||
<MinimalIcon
|
||||
endpoint={endpoint}
|
||||
|
|
@ -40,6 +73,6 @@ export default function SourceIcon({
|
|||
iconClassName="h-3 w-3"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
|
||||
import { ArrowUpDown, Database } from 'lucide-react';
|
||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
|
@ -7,10 +7,10 @@ import { Button, Checkbox, OpenAIMinimalIcon, AzureMinimalIcon } from '~/compone
|
|||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { SortFilterHeader } from './SortFilterHeader';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { formatDate, getFileType } from '~/utils';
|
||||
|
||||
const contextMap = {
|
||||
const contextMap: Record<any, TranslationKeys> = {
|
||||
[FileContext.avatar]: 'com_ui_avatar',
|
||||
[FileContext.unknown]: 'com_ui_unknown',
|
||||
[FileContext.assistants]: 'com_ui_assistants',
|
||||
|
|
@ -127,8 +127,8 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
),
|
||||
}}
|
||||
valueMap={{
|
||||
[FileSources.azure]: 'Azure',
|
||||
[FileSources.openai]: 'OpenAI',
|
||||
[FileSources.azure]: 'com_ui_azure',
|
||||
[FileSources.openai]: 'com_ui_openai',
|
||||
[FileSources.local]: 'com_ui_host',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -182,7 +182,7 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{localize(contextMap[context ?? FileContext.unknown] ?? 'com_ui_unknown')}
|
||||
{localize(contextMap[context ?? FileContext.unknown])}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -212,4 +212,4 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
return `${value}${suffix}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
|
@ -16,7 +16,7 @@ interface SortFilterHeaderProps<TData, TValue> extends React.HTMLAttributes<HTML
|
|||
title: string;
|
||||
column: Column<TData, TValue>;
|
||||
filters?: Record<string, string[] | number[]>;
|
||||
valueMap?: Record<string, string>;
|
||||
valueMap?: Record<any, TranslationKeys>;
|
||||
}
|
||||
|
||||
export function SortFilterHeader<TData, TValue>({
|
||||
|
|
@ -82,7 +82,7 @@ export function SortFilterHeader<TData, TValue>({
|
|||
const translationKey = valueMap?.[value ?? ''];
|
||||
const filterValue =
|
||||
translationKey != null && translationKey.length
|
||||
? localize(translationKey as TranslationKeys)
|
||||
? localize(translationKey)
|
||||
: String(value);
|
||||
if (!filterValue) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { tConvoUpdateSchema, EModelEndpoint, isParamEndpoint } from 'librechat-data-provider';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
isParamEndpoint,
|
||||
isAgentsEndpoint,
|
||||
tConvoUpdateSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
|
||||
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
||||
import { PluginStoreDialog, TooltipAnchor } from '~/components';
|
||||
|
|
@ -42,7 +47,6 @@ export default function HeaderOptions({
|
|||
if (endpoint && noSettings[endpoint]) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, noSettings]);
|
||||
|
||||
const saveAsPreset = () => {
|
||||
|
|
@ -67,7 +71,7 @@ export default function HeaderOptions({
|
|||
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
|
||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||
<div className="z-[61] flex w-full items-center justify-center gap-2">
|
||||
{interfaceConfig?.modelSelect === true && (
|
||||
{interfaceConfig?.modelSelect === true && !isAgentsEndpoint(endpoint) && (
|
||||
<ModelSelect
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { MentionOption, ConvoGenerator } from '~/common';
|
|||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
import { useAssistantsMapContext } from '~/Providers';
|
||||
import useMentions from '~/hooks/Input/useMentions';
|
||||
import { useLocalize, useCombobox } from '~/hooks';
|
||||
import { useLocalize, useCombobox, TranslationKeys } from '~/hooks';
|
||||
import { removeCharIfLast } from '~/utils';
|
||||
import MentionItem from './MentionItem';
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export default function Mention({
|
|||
newConversation: ConvoGenerator;
|
||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
commandChar?: string;
|
||||
placeholder?: string;
|
||||
placeholder?: TranslationKeys;
|
||||
includeAssistants?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -162,7 +162,7 @@ export default function Mention({
|
|||
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
|
||||
<input
|
||||
// The user expects focus to transition to the input field when the popover is opened
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
placeholder={localize(placeholder)}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
|||
return localize('com_nav_welcome_agent');
|
||||
}
|
||||
|
||||
return localize('com_nav_welcome_message');
|
||||
return typeof startupConfig?.interface?.customWelcome === 'string'
|
||||
? startupConfig?.interface?.customWelcome
|
||||
: localize('com_nav_welcome_message');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -118,10 +120,13 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
|||
<div className="flex flex-col items-center gap-0 p-2">
|
||||
<div className="text-center text-2xl font-medium dark:text-white">{name}</div>
|
||||
<div className="max-w-md text-center text-sm font-normal text-text-primary ">
|
||||
{description ? description : localize('com_nav_welcome_message')}
|
||||
{description ||
|
||||
(typeof startupConfig?.interface?.customWelcome === 'string'
|
||||
? startupConfig?.interface?.customWelcome
|
||||
: localize('com_nav_welcome_message'))}
|
||||
</div>
|
||||
{/* <div className="mt-1 flex items-center gap-1 text-token-text-tertiary">
|
||||
<div className="text-sm text-token-text-tertiary">By Daniel Avila</div>
|
||||
<div className="text-sm text-token-text-tertiary">By Daniel Avila</div>
|
||||
</div> */}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
{showIconInMenu && <SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />}
|
||||
<div>
|
||||
{title}
|
||||
<div className="text-token-text-tertiary">{description}</div>
|
||||
<div className="text-text-secondary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,9 @@ const ContentParts = memo(
|
|||
return val;
|
||||
})
|
||||
}
|
||||
label={isSubmitting ? localize('com_ui_thinking') : localize('com_ui_thoughts')}
|
||||
label={
|
||||
isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -137,6 +139,7 @@ const ContentParts = memo(
|
|||
isSubmitting={isSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const Image = ({
|
|||
height,
|
||||
width,
|
||||
placeholderDimensions,
|
||||
className,
|
||||
}: {
|
||||
imagePath: string;
|
||||
altText: string;
|
||||
|
|
@ -38,6 +39,7 @@ const Image = ({
|
|||
height?: string;
|
||||
width?: string;
|
||||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -57,7 +59,12 @@ const Image = ({
|
|||
return (
|
||||
<Dialog.Root>
|
||||
<div ref={containerRef}>
|
||||
<div className="relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||
<div
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-surface-active-alt text-text-secondary-alt',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<LazyLoadImage
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useRecoilValue } from 'recoil';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkDirective from 'remark-directive';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { Pluggable } from 'unified';
|
||||
import {
|
||||
useToastContext,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
||||
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import store from '~/store';
|
||||
|
|
@ -28,6 +30,10 @@ type TCodeProps = {
|
|||
};
|
||||
|
||||
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const canRunCode = useHasAccess({
|
||||
permissionType: PermissionTypes.RUN_CODE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMath = lang === 'math';
|
||||
|
|
@ -49,7 +55,14 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
</code>
|
||||
);
|
||||
} else {
|
||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} blockIndex={blockIndex} />;
|
||||
return (
|
||||
<CodeBlock
|
||||
lang={lang ?? 'text'}
|
||||
codeChildren={children}
|
||||
blockIndex={blockIndex}
|
||||
allowExecution={canRunCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -153,15 +166,12 @@ export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
|
|||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
const cursor = ' ';
|
||||
|
||||
type TContentProps = {
|
||||
content: string;
|
||||
showCursor?: boolean;
|
||||
isLatestMessage: boolean;
|
||||
};
|
||||
|
||||
const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentProps) => {
|
||||
const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
|
||||
const isInitializing = content === '';
|
||||
|
||||
|
|
@ -227,7 +237,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
|||
}
|
||||
}
|
||||
>
|
||||
{isLatestMessage && (showCursor ?? false) ? currentContent + cursor : currentContent}
|
||||
{currentContent}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
|
|
|
|||
|
|
@ -83,9 +83,7 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
|
|||
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
content = (
|
||||
<Markdown content={text} showCursor={showCursorState} isLatestMessage={isLatestMessage} />
|
||||
);
|
||||
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
content = <MarkdownLite content={text} />;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import {
|
|||
import { memo } from 'react';
|
||||
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
|
||||
import { ErrorMessage } from './MessageContent';
|
||||
import AgentUpdate from './Parts/AgentUpdate';
|
||||
import ExecuteCode from './Parts/ExecuteCode';
|
||||
import RetrievalCall from './RetrievalCall';
|
||||
import Reasoning from './Parts/Reasoning';
|
||||
import EmptyText from './Parts/EmptyText';
|
||||
import CodeAnalyze from './CodeAnalyze';
|
||||
import Container from './Container';
|
||||
import ToolCall from './ToolCall';
|
||||
|
|
@ -20,140 +22,159 @@ import Image from './Image';
|
|||
|
||||
type PartProps = {
|
||||
part?: TMessageContentParts;
|
||||
isLast?: boolean;
|
||||
isSubmitting: boolean;
|
||||
showCursor: boolean;
|
||||
isCreatedByUser: boolean;
|
||||
attachments?: TAttachment[];
|
||||
};
|
||||
|
||||
const Part = memo(({ part, isSubmitting, attachments, showCursor, isCreatedByUser }: PartProps) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (part.type === ContentTypes.ERROR) {
|
||||
return <ErrorMessage text={part[ContentTypes.TEXT].value} className="my-2" />;
|
||||
} else if (part.type === ContentTypes.TEXT) {
|
||||
const text = typeof part.text === 'string' ? part.text : part.text.value;
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (part.tool_call_ids != null && !text) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
</Container>
|
||||
);
|
||||
} else if (part.type === ContentTypes.THINK) {
|
||||
const reasoning = typeof part.think === 'string' ? part.think : part.think.value;
|
||||
if (typeof reasoning !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return <Reasoning reasoning={reasoning} />;
|
||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
|
||||
if (!toolCall) {
|
||||
const Part = memo(
|
||||
({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser }: PartProps) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isToolCall =
|
||||
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
|
||||
if (isToolCall && toolCall.name === Tools.execute_code) {
|
||||
if (part.type === ContentTypes.ERROR) {
|
||||
return (
|
||||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
<ErrorMessage
|
||||
text={part[ContentTypes.ERROR] ?? part[ContentTypes.TEXT]?.value}
|
||||
className="my-2"
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall) {
|
||||
} else if (part.type === ContentTypes.AGENT_UPDATE) {
|
||||
return (
|
||||
<ToolCall
|
||||
args={toolCall.args ?? ''}
|
||||
name={toolCall.name || ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
auth={toolCall.auth}
|
||||
expires_at={toolCall.expires_at}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
|
||||
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
|
||||
return (
|
||||
<CodeAnalyze
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
toolCall.type === ToolCallTypes.RETRIEVAL ||
|
||||
toolCall.type === ToolCallTypes.FILE_SEARCH
|
||||
) {
|
||||
return (
|
||||
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
|
||||
);
|
||||
} else if (
|
||||
toolCall.type === ToolCallTypes.FUNCTION &&
|
||||
ToolCallTypes.FUNCTION in toolCall &&
|
||||
imageGenTools.has(toolCall.function.name)
|
||||
) {
|
||||
return (
|
||||
<ImageGen
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
args={toolCall.function.arguments as string}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
|
||||
if (isImageVisionTool(toolCall)) {
|
||||
if (isSubmitting && showCursor) {
|
||||
return (
|
||||
<>
|
||||
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} />
|
||||
{isLast && showCursor && (
|
||||
<Container>
|
||||
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
<EmptyText />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (part.type === ContentTypes.TEXT) {
|
||||
const text = typeof part.text === 'string' ? part.text : part.text.value;
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (part.tool_call_ids != null && !text) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
</Container>
|
||||
);
|
||||
} else if (part.type === ContentTypes.THINK) {
|
||||
const reasoning = typeof part.think === 'string' ? part.think : part.think.value;
|
||||
if (typeof reasoning !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return <Reasoning reasoning={reasoning} />;
|
||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
|
||||
if (!toolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isToolCall =
|
||||
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
|
||||
if (isToolCall && toolCall.name === Tools.execute_code) {
|
||||
return (
|
||||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall) {
|
||||
return (
|
||||
<ToolCall
|
||||
args={toolCall.args ?? ''}
|
||||
name={toolCall.name || ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
auth={toolCall.auth}
|
||||
expires_at={toolCall.expires_at}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
|
||||
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
|
||||
return (
|
||||
<CodeAnalyze
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
toolCall.type === ToolCallTypes.RETRIEVAL ||
|
||||
toolCall.type === ToolCallTypes.FILE_SEARCH
|
||||
) {
|
||||
return (
|
||||
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
|
||||
);
|
||||
} else if (
|
||||
toolCall.type === ToolCallTypes.FUNCTION &&
|
||||
ToolCallTypes.FUNCTION in toolCall &&
|
||||
imageGenTools.has(toolCall.function.name)
|
||||
) {
|
||||
return (
|
||||
<ImageGen
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
args={toolCall.function.arguments as string}
|
||||
/>
|
||||
);
|
||||
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
|
||||
if (isImageVisionTool(toolCall)) {
|
||||
if (isSubmitting && showCursor) {
|
||||
return (
|
||||
<Container>
|
||||
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
args={toolCall.function.arguments as string}
|
||||
name={toolCall.function.name}
|
||||
output={toolCall.function.output}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||
const height = imageFile.height ?? 1920;
|
||||
const width = imageFile.width ?? 1080;
|
||||
return (
|
||||
<ToolCall
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
args={toolCall.function.arguments as string}
|
||||
name={toolCall.function.name}
|
||||
output={toolCall.function.output}
|
||||
<Image
|
||||
imagePath={imageFile.filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||
placeholderDimensions={{
|
||||
height: height + 'px',
|
||||
width: width + 'px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||
const height = imageFile.height ?? 1920;
|
||||
const width = imageFile.width ?? 1080;
|
||||
return (
|
||||
<Image
|
||||
imagePath={imageFile.filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||
placeholderDimensions={{
|
||||
height: height + 'px',
|
||||
width: width + 'px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
export default Part;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
|
||||
interface AgentUpdateProps {
|
||||
currentAgentId: string;
|
||||
}
|
||||
|
||||
const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
|
||||
const agentsMap = useAgentsMapContext() || {};
|
||||
const currentAgent = useMemo(() => agentsMap[currentAgentId], [agentsMap, currentAgentId]);
|
||||
if (!currentAgentId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute -left-6 flex h-full w-4 items-center justify-center">
|
||||
<div className="relative h-full w-4">
|
||||
<div className="absolute left-0 top-0 h-1/2 w-px border border-border-medium"></div>
|
||||
<div className="absolute left-0 top-1/2 h-px w-3 border border-border-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon
|
||||
endpoint={EModelEndpoint.agents}
|
||||
agentName={currentAgent?.name ?? ''}
|
||||
iconURL={currentAgent?.avatar?.filepath}
|
||||
isCreatedByUser={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-medium text-text-primary">{currentAgent?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentUpdate;
|
||||
|
|
@ -12,7 +12,13 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
|
|||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Image altText={attachment.filename} imagePath={filepath} height={height} width={width} />
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
const EmptyTextPart = memo(() => {
|
||||
return (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default EmptyTextPart;
|
||||
|
|
@ -29,9 +29,7 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
|
|||
|
||||
const content: ContentType = useMemo(() => {
|
||||
if (!isCreatedByUser) {
|
||||
return (
|
||||
<Markdown content={text} showCursor={showCursorState} isLatestMessage={isLatestMessage} />
|
||||
);
|
||||
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
return <MarkdownLite content={text} />;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ function ConvoOptions({
|
|||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
id="conversation-menu-button"
|
||||
id={`conversation-menu-${conversationId}`}
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
className={cn(
|
||||
'z-30 inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { GitFork, InfoIcon } from 'lucide-react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
|
|
@ -21,9 +21,9 @@ import store from '~/store';
|
|||
|
||||
interface PopoverButtonProps {
|
||||
children: React.ReactNode;
|
||||
setting: string;
|
||||
onClick: (setting: string) => void;
|
||||
setActiveSetting: React.Dispatch<React.SetStateAction<string>>;
|
||||
setting: ForkOptions;
|
||||
onClick: (setting: ForkOptions) => void;
|
||||
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
|
||||
sideOffset?: number;
|
||||
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
hoverInfo?: React.ReactNode | string;
|
||||
|
|
@ -31,11 +31,11 @@ interface PopoverButtonProps {
|
|||
hoverDescription?: React.ReactNode | string;
|
||||
}
|
||||
|
||||
const optionLabels = {
|
||||
const optionLabels: Record<ForkOptions, TranslationKeys> = {
|
||||
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
|
||||
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
|
||||
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
|
||||
default: 'com_ui_fork_from_message',
|
||||
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
|
||||
};
|
||||
|
||||
const PopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
|
|
@ -65,10 +65,10 @@ const PopoverButton: React.FC<PopoverButtonProps> = ({
|
|||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setActiveSetting(optionLabels.default);
|
||||
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
|
||||
}, 175);
|
||||
}}
|
||||
className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white text-gray-700 transition duration-300 ease-in-out hover:bg-gray-200 hover:text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-100 "
|
||||
className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white text-gray-700 transition duration-300 ease-in-out hover:bg-gray-200 hover:text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-100"
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
|
|
@ -77,18 +77,12 @@ const PopoverButton: React.FC<PopoverButtonProps> = ({
|
|||
(hoverTitle != null && hoverTitle !== '') ||
|
||||
(hoverDescription != null && hoverDescription !== '')) && (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
className="z-[999] w-80 dark:bg-gray-700"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<HoverCardContent side="right" className="z-[999] w-80 dark:bg-gray-700" sideOffset={sideOffset}>
|
||||
<div className="space-y-2">
|
||||
<p className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{hoverInfo != null && hoverInfo !== '' && hoverInfo}
|
||||
{hoverTitle != null && hoverTitle !== '' && (
|
||||
<span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>
|
||||
)}
|
||||
{hoverDescription != null && hoverDescription !== '' && hoverDescription}
|
||||
{hoverInfo && hoverInfo}
|
||||
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
|
||||
{hoverDescription && hoverDescription}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
|
|
@ -201,7 +195,7 @@ export default function Fork({
|
|||
align="center"
|
||||
>
|
||||
<div className="flex h-6 w-full items-center justify-center text-sm dark:text-gray-200">
|
||||
{localize(activeSetting as TranslationKeys)}
|
||||
{localize(activeSetting )}
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-gray-500 dark:text-white/50" />
|
||||
|
|
@ -235,7 +229,7 @@ export default function Fork({
|
|||
hoverTitle={
|
||||
<>
|
||||
<GitCommit className="h-5 w-5 rotate-90" />
|
||||
{localize(optionLabels[ForkOptions.DIRECT_PATH] as TranslationKeys)}
|
||||
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_visible')}
|
||||
|
|
@ -253,7 +247,7 @@ export default function Fork({
|
|||
hoverTitle={
|
||||
<>
|
||||
<GitBranchPlus className="h-4 w-4 rotate-180" />
|
||||
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES] as TranslationKeys)}
|
||||
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_branches')}
|
||||
|
|
@ -272,7 +266,7 @@ export default function Fork({
|
|||
<>
|
||||
<ListTree className="h-5 w-5" />
|
||||
{`${localize(
|
||||
optionLabels[ForkOptions.TARGET_LEVEL] as TranslationKeys,
|
||||
optionLabels[ForkOptions.TARGET_LEVEL],
|
||||
)} (${localize('com_endpoint_default')})`}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ function AccountSettings() {
|
|||
!isNaN(parseFloat(balanceQuery.data)) && (
|
||||
<>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
{localize('com_nav_balance')}: ${parseFloat(balanceQuery.data).toFixed(2)}
|
||||
{localize('com_nav_balance')}: {parseFloat(balanceQuery.data).toFixed(2)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { MessageSquare, Command } from 'lucide-react';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
|
|
@ -6,7 +6,7 @@ import type { TDialogProps } from '~/common';
|
|||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
|
||||
import { General, Chat, Speech, Beta, Commands, Data, Account } from './SettingsTabs';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
|
|
@ -47,6 +47,44 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const settingsTabs: { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[] = [
|
||||
{
|
||||
value: SettingsTabValues.GENERAL,
|
||||
icon: <GearIcon />,
|
||||
label: 'com_nav_setting_general',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.CHAT,
|
||||
icon: <MessageSquare className="icon-sm" />,
|
||||
label: 'com_nav_setting_chat',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.BETA,
|
||||
icon: <ExperimentIcon />,
|
||||
label: 'com_nav_setting_beta',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.COMMANDS,
|
||||
icon: <Command className="icon-sm" />,
|
||||
label: 'com_nav_commands',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.SPEECH,
|
||||
icon: <SpeechIcon className="icon-sm" />,
|
||||
label: 'com_nav_setting_speech',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.DATA,
|
||||
icon: <DataIcon />,
|
||||
label: 'com_nav_setting_data',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.ACCOUNT,
|
||||
icon: <UserIcon />,
|
||||
label: 'com_nav_setting_account',
|
||||
},
|
||||
];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as SettingsTabValues);
|
||||
};
|
||||
|
|
@ -126,43 +164,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: SettingsTabValues.GENERAL,
|
||||
icon: <GearIcon />,
|
||||
label: 'com_nav_setting_general',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.CHAT,
|
||||
icon: <MessageSquare className="icon-sm" />,
|
||||
label: 'com_nav_setting_chat',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.BETA,
|
||||
icon: <ExperimentIcon />,
|
||||
label: 'com_nav_setting_beta',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.COMMANDS,
|
||||
icon: <Command className="icon-sm" />,
|
||||
label: 'com_nav_commands',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.SPEECH,
|
||||
icon: <SpeechIcon className="icon-sm" />,
|
||||
label: 'com_nav_setting_speech',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.DATA,
|
||||
icon: <DataIcon />,
|
||||
label: 'com_nav_setting_data',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.ACCOUNT,
|
||||
icon: <UserIcon />,
|
||||
label: 'com_nav_setting_account',
|
||||
},
|
||||
].map(({ value, icon, label }) => (
|
||||
{settingsTabs.map(({ value, icon, label }) => (
|
||||
<Tabs.Trigger
|
||||
key={value}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -2,19 +2,36 @@ import React from 'react';
|
|||
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
||||
import DeleteAccount from './DeleteAccount';
|
||||
import Avatar from './Avatar';
|
||||
import EnableTwoFactorItem from './TwoFactorAuthentication';
|
||||
import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const user = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{user?.user?.twoFactorEnabled && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function Avatar() {
|
|||
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
|
||||
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
||||
openButtonRef.current?.click();
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -133,9 +133,11 @@ function Avatar() {
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef} className="btn btn-neutral relative">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef}>
|
||||
<Button variant="outline">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RefreshCcw, ShieldX } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogTitle,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Label,
|
||||
Spinner,
|
||||
TooltipAnchor,
|
||||
} from '~/components';
|
||||
import { useRegenerateBackupCodesMutation } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import store from '~/store';
|
||||
|
||||
const BackupCodesItem: React.FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation();
|
||||
|
||||
const fetchBackupCodes = (auto: boolean = false) => {
|
||||
regenerateBackupCodes(undefined, {
|
||||
onSuccess: (data: TRegenerateBackupCodesResponse) => {
|
||||
const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({
|
||||
codeHash,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
}));
|
||||
|
||||
setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser);
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerated'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
// Trigger file download only when user explicitly clicks the button.
|
||||
if (!auto && newBackupCodes.length) {
|
||||
const codesString = data.backupCodes.join('\n');
|
||||
const blob = new Blob([codesString], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerate_error'),
|
||||
status: 'error',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
fetchBackupCodes(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Label className="font-light">{localize('com_ui_backup_codes')}</Label>
|
||||
</div>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button aria-label="Show Backup Codes" variant="outline">
|
||||
{localize('com_ui_show')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-lg">
|
||||
<OGDialogTitle className="mb-6 text-2xl font-semibold">
|
||||
{localize('com_ui_backup_codes')}
|
||||
</OGDialogTitle>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-4"
|
||||
>
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{user?.backupCodes.map((code, index) => {
|
||||
const isUsed = code.used;
|
||||
const description = `Backup code number ${index + 1}, ${
|
||||
isUsed
|
||||
? `used on ${code.usedAt ? new Date(code.usedAt).toLocaleDateString() : 'an unknown date'}`
|
||||
: 'not used yet'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={code.codeHash}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={description}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
onFocus={() => {
|
||||
const announcement = new CustomEvent('announce', {
|
||||
detail: { message: description },
|
||||
});
|
||||
document.dispatchEvent(announcement);
|
||||
}}
|
||||
className={`flex flex-col rounded-xl border p-4 backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||
isUsed
|
||||
? 'border-red-200 bg-red-50/80 dark:border-red-800 dark:bg-red-900/20'
|
||||
: 'border-green-200 bg-green-50/80 dark:border-green-800 dark:bg-green-900/20'
|
||||
} `}
|
||||
>
|
||||
<div className="flex items-center justify-between" aria-hidden="true">
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={
|
||||
code.usedAt ? new Date(code.usedAt).toLocaleDateString() : ''
|
||||
}
|
||||
disabled={!isUsed}
|
||||
focusable={false}
|
||||
className={isUsed ? 'cursor-pointer' : 'cursor-default'}
|
||||
render={
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
isUsed
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{isUsed ? localize('com_ui_used') : localize('com_ui_not_used')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner className="mr-2" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isLoading
|
||||
? localize('com_ui_regenerating')
|
||||
: localize('com_ui_regenerate_backup')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<ShieldX className="h-12 w-12 text-text-primary" />
|
||||
<p className="text-lg text-text-secondary">{localize('com_ui_no_backup_codes')}</p>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2" />}
|
||||
{localize('com_ui_generate_backup')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(BackupCodesItem);
|
||||
|
|
@ -14,6 +14,7 @@ import { useDeleteUserMutation } from '~/data-provider';
|
|||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { LocalizeFunction } from '~/common';
|
||||
|
||||
const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -56,7 +57,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
<OGDialogContent className="w-11/12 max-w-2xl">
|
||||
<OGDialogContent className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-medium leading-6">
|
||||
{localize('com_nav_delete_account_confirm')}
|
||||
|
|
@ -103,7 +104,7 @@ const renderDeleteButton = (
|
|||
handleDeleteUser: () => void,
|
||||
isDeleting: boolean,
|
||||
isLocked: boolean,
|
||||
localize: (key: string) => string,
|
||||
localize: LocalizeFunction,
|
||||
) => (
|
||||
<button
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
// import { motion } from 'framer-motion';
|
||||
// import { LockIcon, UnlockIcon } from 'lucide-react';
|
||||
import { Label, Button } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface DisableTwoFactorToggleProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
disabled,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={enabled ? 'destructive' : 'outline'}
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{enabled ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_enable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { SmartphoneIcon } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { TUser, TVerify2FARequest } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle, Progress } from '~/components';
|
||||
import { SetupPhase, QRPhase, VerifyPhase, BackupPhase, DisablePhase } from './TwoFactorPhases';
|
||||
import { DisableTwoFactorToggle } from './DisableTwoFactorToggle';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
import {
|
||||
useConfirmTwoFactorMutation,
|
||||
useDisableTwoFactorMutation,
|
||||
useEnableTwoFactorMutation,
|
||||
useVerifyTwoFactorMutation,
|
||||
} from '~/data-provider';
|
||||
|
||||
export type Phase = 'setup' | 'qr' | 'verify' | 'backup' | 'disable';
|
||||
|
||||
const phaseVariants = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } },
|
||||
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.3, ease: 'easeIn' } },
|
||||
};
|
||||
|
||||
const TwoFactorAuthentication: React.FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [disableToken, setDisableToken] = useState<string>('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [verificationToken, setVerificationToken] = useState<string>('');
|
||||
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
|
||||
|
||||
const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation();
|
||||
const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation();
|
||||
const { mutate: verify2FAMutate, isLoading: isVerifying } = useVerifyTwoFactorMutation();
|
||||
const { mutate: disable2FAMutate, isLoading: isDisabling } = useDisableTwoFactorMutation();
|
||||
|
||||
const steps = ['Setup', 'Scan QR', 'Verify', 'Backup'];
|
||||
const phasesLabel: Record<Phase, string> = {
|
||||
setup: 'Setup',
|
||||
qr: 'Scan QR',
|
||||
verify: 'Verify',
|
||||
backup: 'Backup',
|
||||
disable: '',
|
||||
};
|
||||
|
||||
const currentStep = steps.indexOf(phasesLabel[phase]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
if (user?.twoFactorEnabled && otpauthUrl) {
|
||||
disable2FAMutate(undefined, {
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
}
|
||||
|
||||
setOtpauthUrl('');
|
||||
setSecret('');
|
||||
setBackupCodes([]);
|
||||
setVerificationToken('');
|
||||
setDisableToken('');
|
||||
setPhase(user?.twoFactorEnabled ? 'disable' : 'setup');
|
||||
setDownloaded(false);
|
||||
}, [user, otpauthUrl, disable2FAMutate, localize, showToast]);
|
||||
|
||||
const handleGenerateQRCode = useCallback(() => {
|
||||
enable2FAMutate(undefined, {
|
||||
onSuccess: ({ otpauthUrl, backupCodes }) => {
|
||||
setOtpauthUrl(otpauthUrl);
|
||||
setSecret(otpauthUrl.split('secret=')[1].split('&')[0]);
|
||||
setBackupCodes(backupCodes);
|
||||
setPhase('qr');
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_generate_error'), status: 'error' }),
|
||||
});
|
||||
}, [enable2FAMutate, localize, showToast]);
|
||||
|
||||
const handleVerify = useCallback(() => {
|
||||
if (!verificationToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
verify2FAMutate(
|
||||
{ token: verificationToken },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_verified') });
|
||||
confirm2FAMutate(
|
||||
{ token: verificationToken },
|
||||
{
|
||||
onSuccess: () => setPhase('backup'),
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
}, [verificationToken, verify2FAMutate, confirm2FAMutate, localize, showToast]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!backupCodes.length) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloaded(true);
|
||||
}, [backupCodes]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
setPhase('disable');
|
||||
showToast({ message: localize('com_ui_2fa_enabled') });
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
backupCodes: backupCodes.map((code) => ({
|
||||
code,
|
||||
codeHash: code,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
})),
|
||||
twoFactorEnabled: true,
|
||||
}) as TUser,
|
||||
);
|
||||
}, [setUser, localize, showToast, backupCodes]);
|
||||
|
||||
const handleDisableVerify = useCallback(
|
||||
(token: string, useBackup: boolean) => {
|
||||
// Validate: if not using backup, ensure token has at least 6 digits;
|
||||
// if using backup, ensure backup code has at least 8 characters.
|
||||
if (!useBackup && token.trim().length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useBackup && token.trim().length < 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TVerify2FARequest = {};
|
||||
if (useBackup) {
|
||||
payload.backupCode = token.trim();
|
||||
} else {
|
||||
payload.token = token.trim();
|
||||
}
|
||||
|
||||
verify2FAMutate(payload, {
|
||||
onSuccess: () => {
|
||||
disable2FAMutate(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DisableTwoFactorToggle
|
||||
enabled={!!user?.twoFactorEnabled}
|
||||
onChange={() => setDialogOpen(true)}
|
||||
disabled={isVerifying || isDisabling || isGenerating}
|
||||
/>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-lg p-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={phase}
|
||||
variants={phaseVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="space-y-6"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="mb-2 flex items-center gap-3 text-2xl font-bold">
|
||||
<SmartphoneIcon className="h-6 w-6 text-primary" />
|
||||
{user?.twoFactorEnabled
|
||||
? localize('com_ui_2fa_disable')
|
||||
: localize('com_ui_2fa_setup')}
|
||||
</OGDialogTitle>
|
||||
{user?.twoFactorEnabled && phase !== 'disable' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Progress
|
||||
value={(steps.indexOf(phasesLabel[phase]) / (steps.length - 1)) * 100}
|
||||
className="h-2 rounded-full"
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
{steps.map((step, index) => (
|
||||
<motion.span
|
||||
key={step}
|
||||
animate={{
|
||||
color:
|
||||
currentStep >= index ? 'var(--text-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
className="font-medium"
|
||||
>
|
||||
{step}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OGDialogHeader>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'setup' && (
|
||||
<SetupPhase
|
||||
isGenerating={isGenerating}
|
||||
onGenerate={handleGenerateQRCode}
|
||||
onNext={() => setPhase('qr')}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'qr' && (
|
||||
<QRPhase
|
||||
secret={secret}
|
||||
otpauthUrl={otpauthUrl}
|
||||
onNext={() => setPhase('verify')}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'verify' && (
|
||||
<VerifyPhase
|
||||
token={verificationToken}
|
||||
onTokenChange={setVerificationToken}
|
||||
isVerifying={isVerifying}
|
||||
onNext={handleVerify}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'backup' && (
|
||||
<BackupPhase
|
||||
backupCodes={backupCodes}
|
||||
onDownload={handleDownload}
|
||||
downloaded={downloaded}
|
||||
onNext={handleConfirm}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'disable' && (
|
||||
<DisablePhase
|
||||
onDisable={handleDisableVerify}
|
||||
isDisabling={isDisabling}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TwoFactorAuthentication);
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button, Label } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface BackupPhaseProps {
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
backupCodes: string[];
|
||||
onDownload: () => void;
|
||||
downloaded: boolean;
|
||||
}
|
||||
|
||||
export const BackupPhase: React.FC<BackupPhaseProps> = ({
|
||||
backupCodes,
|
||||
onDownload,
|
||||
downloaded,
|
||||
onNext,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<Label className="break-keep text-sm">{localize('com_ui_download_backup_tooltip')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-xl bg-surface-secondary p-6">
|
||||
{backupCodes.map((code, index) => (
|
||||
<motion.div
|
||||
key={code}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="rounded-lg bg-surface-tertiary p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="hidden text-sm text-text-secondary sm:inline">#{index + 1}</span>
|
||||
<span className="font-mono text-lg">{code}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={onDownload} className="flex-1 gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{localize('com_ui_download_backup')}</span>
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!downloaded} className="flex-1">
|
||||
{localize('com_ui_complete_setup')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import {
|
||||
Button,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Spinner,
|
||||
} from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface DisablePhaseProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onDisable: (token: string, useBackup: boolean) => void;
|
||||
isDisabling: boolean;
|
||||
}
|
||||
|
||||
export const DisablePhase: React.FC<DisablePhaseProps> = ({ onDisable, isDisabling }) => {
|
||||
const localize = useLocalize();
|
||||
const [token, setToken] = useState('');
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={token}
|
||||
onChange={setToken}
|
||||
maxLength={useBackup ? 8 : 6}
|
||||
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
{useBackup ? (
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
) : (
|
||||
<>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</>
|
||||
)}
|
||||
</InputOTP>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onDisable(token, useBackup)}
|
||||
disabled={isDisabling || token.length !== (useBackup ? 8 : 6)}
|
||||
className="w-full rounded-xl px-6 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isDisabling && <Spinner className="mr-2" />}
|
||||
{isDisabling ? localize('com_ui_disabling') : localize('com_ui_2fa_disable')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setUseBackup(!useBackup)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Input, Button, Label } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface QRPhaseProps {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
onNext: () => void;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
setIsCopying(true);
|
||||
setTimeout(() => setIsCopying(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="rounded-2xl bg-white p-4 shadow-lg"
|
||||
>
|
||||
<QRCodeSVG value={otpauthUrl} size={240} />
|
||||
</motion.div>
|
||||
<div className="w-full space-y-3">
|
||||
<Label className="text-sm font-medium text-text-secondary">
|
||||
{localize('com_ui_secret_key')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-lg tracking-wider" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
className={cn('h-auto shrink-0', isCopying ? 'cursor-default' : '')}
|
||||
>
|
||||
{isCopying ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onNext} className="w-full">
|
||||
{localize('com_ui_continue')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button, Spinner } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface SetupPhaseProps {
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
isGenerating: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({ isGenerating, onGenerate }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<div className="rounded-xl bg-surface-secondary p-6">
|
||||
<h3 className="mb-4 flex justify-center text-lg font-medium">
|
||||
{localize('com_ui_2fa_account_security')}
|
||||
</h3>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onGenerate}
|
||||
className="flex w-full"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? <Spinner className="size-5" /> : <QrCode className="size-5" />}
|
||||
{isGenerating ? localize('com_ui_generating') : localize('com_ui_generate_qrcode')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '~/components';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface VerifyPhaseProps {
|
||||
token: string;
|
||||
onTokenChange: (value: string) => void;
|
||||
isVerifying: boolean;
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const VerifyPhase: React.FC<VerifyPhaseProps> = ({
|
||||
token,
|
||||
onTokenChange,
|
||||
isVerifying,
|
||||
onNext,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={token}
|
||||
onChange={onTokenChange}
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<InputOTPSlot key={i} index={i} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<InputOTPSlot key={i + 3} index={i + 3} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<Button onClick={onNext} disabled={isVerifying || token.length !== 6} className="w-full">
|
||||
{localize('com_ui_verify')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './BackupPhase';
|
||||
export * from './QRPhase';
|
||||
export * from './VerifyPhase';
|
||||
export * from './SetupPhase';
|
||||
export * from './DisablePhase';
|
||||
|
|
@ -18,7 +18,6 @@ export default function PlusCommandSwitch() {
|
|||
id="plusCommand"
|
||||
checked={plusCommand}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
f
|
||||
className="ml-4"
|
||||
data-testid="plusCommand"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export default function SlashCommandSwitch() {
|
|||
id="slashCommand"
|
||||
checked={slashCommand}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
f
|
||||
data-testid="slashCommand"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ function ImportConversations() {
|
|||
onClick={handleImportClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!allowImport}
|
||||
aria-label={localize('com_ui_import_conversation')}
|
||||
aria-label={localize('com_ui_import')}
|
||||
className="btn btn-neutral relative"
|
||||
>
|
||||
{allowImport ? (
|
||||
|
|
@ -90,7 +90,7 @@ function ImportConversations() {
|
|||
) : (
|
||||
<Spinner className="mr-1 w-4" />
|
||||
)}
|
||||
<span>{localize('com_ui_import_conversation')}</span>
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
|||
|
|
@ -270,9 +270,7 @@ export default function SharedLinks() {
|
|||
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<button className="btn btn-neutral relative">
|
||||
{localize('com_nav_shared_links_manage')}
|
||||
</button>
|
||||
<Button variant="outline">{localize('com_ui_manage')}</Button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function ArchivedChats() {
|
|||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
{localize('com_nav_archived_chats_manage')}
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
|
|
|
|||
|
|
@ -51,20 +51,24 @@ export const LangSelector = ({
|
|||
const languageOptions = [
|
||||
{ value: 'auto', label: localize('com_nav_lang_auto') },
|
||||
{ value: 'en-US', label: localize('com_nav_lang_english') },
|
||||
{ value: 'zh-CN', label: localize('com_nav_lang_chinese') },
|
||||
{ value: 'zh-TW', label: localize('com_nav_lang_traditionalchinese') },
|
||||
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
|
||||
{ value: 'zh-Hant', label: localize('com_nav_lang_traditional_chinese') },
|
||||
{ value: 'ar-EG', label: localize('com_nav_lang_arabic') },
|
||||
{ value: 'de-DE', label: localize('com_nav_lang_german') },
|
||||
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
|
||||
{ value: 'et-EE', label: localize('com_nav_lang_estonian') },
|
||||
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
|
||||
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
|
||||
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
|
||||
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
|
||||
{ value: 'pt-PT', label: localize('com_nav_lang_portuguese') },
|
||||
{ value: 'ru-RU', label: localize('com_nav_lang_russian') },
|
||||
{ value: 'ja-JP', label: localize('com_nav_lang_japanese') },
|
||||
{ value: 'ka-GE', label: localize('com_nav_lang_georgian') },
|
||||
{ value: 'sv-SE', label: localize('com_nav_lang_swedish') },
|
||||
{ value: 'ko-KR', label: localize('com_nav_lang_korean') },
|
||||
{ value: 'vi-VN', label: localize('com_nav_lang_vietnamese') },
|
||||
{ value: 'th-TH', label: localize('com_nav_lang_thai') },
|
||||
{ value: 'tr-TR', label: localize('com_nav_lang_turkish') },
|
||||
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
|
||||
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const Command = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light">
|
||||
<div className="rounded-xl border border-border-light shadow-md">
|
||||
<h3 className="flex h-10 items-center gap-1 pl-4 text-sm text-text-secondary">
|
||||
<SquareSlash className="icon-sm" aria-hidden="true" />
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const Description = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light">
|
||||
<div className="rounded-xl border border-border-light shadow-md">
|
||||
<h3 className="flex h-10 items-center gap-1 pl-4 text-sm text-text-secondary">
|
||||
<Info className="icon-sm" aria-hidden="true" />
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export default function List({
|
|||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full bg-transparent px-3"
|
||||
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
|
||||
onClick={() => navigate('/d/prompts/new')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
|||
<div
|
||||
role="button"
|
||||
className={cn(
|
||||
'w-full flex-1 overflow-auto rounded-b-xl border border-border-light p-2 transition-all duration-150 sm:p-4',
|
||||
'w-full flex-1 overflow-auto rounded-b-xl border border-border-light p-2 shadow-md transition-all duration-150 sm:p-4',
|
||||
{
|
||||
'cursor-pointer bg-surface-primary hover:bg-surface-secondary active:bg-surface-tertiary':
|
||||
!isEditing,
|
||||
|
|
@ -105,6 +105,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
|||
isEditing ? (
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
autoFocus
|
||||
className="w-full resize-none overflow-y-auto rounded bg-transparent text-sm text-text-primary focus:outline-none sm:text-base"
|
||||
minRows={3}
|
||||
maxRows={14}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,6 @@ const PromptForm = () => {
|
|||
payload: { name: groupName, category: value },
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
|
||||
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
||||
|
|
@ -349,7 +348,7 @@ const PromptForm = () => {
|
|||
{isLoadingPrompts ? (
|
||||
<Skeleton className="h-96" aria-live="polite" />
|
||||
) : (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="mb-2 flex h-full flex-col gap-4">
|
||||
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
|
||||
<PromptVariables promptText={promptText} />
|
||||
<Description
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const PromptVariables = ({
|
|||
}, [promptText]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light bg-transparent p-4 shadow-md ">
|
||||
<div className="rounded-xl border border-border-light bg-transparent p-4 shadow-md">
|
||||
<h3 className="flex items-center gap-2 py-2 text-lg font-semibold text-text-primary">
|
||||
<Variable className="icon-sm" aria-hidden="true" />
|
||||
{localize('com_ui_variables')}
|
||||
|
|
@ -71,7 +71,7 @@ const PromptVariables = ({
|
|||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-text-primary text-sm font-medium">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_dropdown_variables')}
|
||||
</span>
|
||||
<span className="text-sm text-text-secondary">
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export default function AgentSwitcher({ isCollapsed }: SwitcherProps) {
|
|||
ariaLabel={'agent'}
|
||||
setValue={onSelect}
|
||||
items={agentOptions}
|
||||
iconClassName="assistant-item"
|
||||
SelectIcon={
|
||||
<Icon
|
||||
isCreatedByUser={false}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ const AdminSettings = () => {
|
|||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative mb-4 h-9 w-full gap-1 rounded-lg font-medium"
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
interface AdvancedButtonProps {
|
||||
setActivePanel: (panel: Panel) => void;
|
||||
}
|
||||
|
||||
const AdvancedButton: React.FC<AdvancedButtonProps> = ({ setActivePanel }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
|
||||
onClick={() => setActivePanel(Panel.advanced)}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_advanced')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedButton;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { useMemo } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
import MaxAgentSteps from './MaxAgentSteps';
|
||||
import AgentChain from './AgentChain';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AdvancedPanel({
|
||||
agentsConfig,
|
||||
setActivePanel,
|
||||
}: Pick<AgentPanelProps, 'setActivePanel' | 'agentsConfig'>) {
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { control, watch } = methods;
|
||||
const currentAgentId = watch('id');
|
||||
const chainEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities.includes(AgentCapabilities.chain) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-full min-h-[40vh] overflow-auto pb-12 text-sm">
|
||||
<div className="advanced-panel relative flex flex-col items-center px-16 py-4 text-center">
|
||||
<div className="absolute left-0 top-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => {
|
||||
setActivePanel(Panel.builder);
|
||||
}}
|
||||
>
|
||||
<div className="advanced-panel-content flex w-full items-center justify-center gap-2">
|
||||
<ChevronLeft />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 mt-2 text-xl font-medium">{localize('com_ui_advanced_settings')}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-2">
|
||||
<MaxAgentSteps />
|
||||
{chainEnabled && (
|
||||
<Controller
|
||||
name="agent_ids"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
render={({ field }) => <AgentChain field={field} currentAgentId={currentAgentId} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
client/src/components/SidePanel/Agents/Advanced/AgentChain.tsx
Normal file
179
client/src/components/SidePanel/Agents/Advanced/AgentChain.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { X, Link2, PlusCircle } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import type { AgentForm, OptionWithIcon } from '~/common';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { HoverCard, HoverCardPortal, HoverCardContent, HoverCardTrigger } from '~/components/ui';
|
||||
import { CircleHelpIcon } from '~/components/svg';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
interface AgentChainProps {
|
||||
field: ControllerRenderProps<AgentForm, 'agent_ids'>;
|
||||
currentAgentId: string;
|
||||
}
|
||||
|
||||
/** TODO: make configurable */
|
||||
const MAX_AGENTS = 10;
|
||||
|
||||
const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
|
||||
const localize = useLocalize();
|
||||
const [newAgentId, setNewAgentId] = useState('');
|
||||
const agentsMap = useAgentsMapContext() || {};
|
||||
const agentIds = field.value || [];
|
||||
|
||||
const agents = useMemo(() => Object.values(agentsMap), [agentsMap]);
|
||||
|
||||
const selectableAgents = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.filter((agent) => agent?.id !== currentAgentId)
|
||||
.map(
|
||||
(agent) =>
|
||||
({
|
||||
label: agent?.name || '',
|
||||
value: agent?.id,
|
||||
icon: (
|
||||
<Icon
|
||||
endpoint={EModelEndpoint.agents}
|
||||
agentName={agent?.name ?? ''}
|
||||
iconURL={agent?.avatar?.filepath}
|
||||
isCreatedByUser={false}
|
||||
/>
|
||||
),
|
||||
}) as OptionWithIcon,
|
||||
),
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
|
||||
const getAgentDetails = useCallback((id: string) => agentsMap[id], [agentsMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newAgentId && agentIds.length < MAX_AGENTS) {
|
||||
field.onChange([...agentIds, newAgentId]);
|
||||
setNewAgentId('');
|
||||
}
|
||||
}, [newAgentId, agentIds, field]);
|
||||
|
||||
const removeAgentAt = (index: number) => {
|
||||
field.onChange(agentIds.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateAgentAt = (index: number, id: string) => {
|
||||
const updated = [...agentIds];
|
||||
updated[index] = id;
|
||||
field.onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="font-semibold text-text-primary">
|
||||
{localize('com_ui_agent_chain')}
|
||||
</label>
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{agentIds.length} / {MAX_AGENTS}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{/* Current fixed agent */}
|
||||
<div className="flex h-10 items-center justify-between rounded-md border border-border-medium bg-surface-primary-contrast px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon
|
||||
endpoint={EModelEndpoint.agents}
|
||||
agentName={getAgentDetails(currentAgentId)?.name ?? ''}
|
||||
iconURL={getAgentDetails(currentAgentId)?.avatar?.filepath}
|
||||
isCreatedByUser={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-medium text-text-primary">
|
||||
{getAgentDetails(currentAgentId)?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{<Link2 className="mx-auto text-text-secondary" size={14} />}
|
||||
{agentIds.map((agentId, idx) => (
|
||||
<React.Fragment key={agentId}>
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
|
||||
selectedValue={agentId}
|
||||
setValue={(id) => updateAgentAt(idx, id)}
|
||||
selectPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
|
||||
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
|
||||
items={selectableAgents}
|
||||
displayValue={getAgentDetails(agentId)?.name ?? ''}
|
||||
SelectIcon={
|
||||
<Icon
|
||||
endpoint={EModelEndpoint.agents}
|
||||
isCreatedByUser={false}
|
||||
agentName={getAgentDetails(agentId)?.name ?? ''}
|
||||
iconURL={getAgentDetails(agentId)?.avatar?.filepath}
|
||||
/>
|
||||
}
|
||||
className="flex-1 border-border-heavy"
|
||||
containerClassName="px-0"
|
||||
/>
|
||||
{/* Future Settings button? */}
|
||||
{/* <button className="hover:bg-surface-hover p-1 rounded transition">
|
||||
<Settings size={16} className="text-text-secondary" />
|
||||
</button> */}
|
||||
<button
|
||||
className="rounded-xl p-1 transition hover:bg-surface-hover"
|
||||
onClick={() => removeAgentAt(idx)}
|
||||
>
|
||||
<X size={18} className="text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
{idx < agentIds.length - 1 && (
|
||||
<Link2 className="mx-auto text-text-secondary" size={14} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{agentIds.length < MAX_AGENTS && (
|
||||
<>
|
||||
{agentIds.length > 0 && <Link2 className="mx-auto text-text-secondary" size={14} />}
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}
|
||||
selectedValue=""
|
||||
setValue={setNewAgentId}
|
||||
selectPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}
|
||||
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
|
||||
items={selectableAgents}
|
||||
className="h-10 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
|
||||
containerClassName="px-0"
|
||||
SelectIcon={<PlusCircle size={16} className="text-text-secondary" />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{agentIds.length >= MAX_AGENTS && (
|
||||
<p className="pt-1 text-center text-xs italic text-text-tertiary">
|
||||
{localize('com_ui_agent_chain_max', { 0: MAX_AGENTS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_agent_chain_info')}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentChain;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
HoverCard,
|
||||
FormInput,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '~/components/ui';
|
||||
import { CircleHelpIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
export default function AdvancedPanel() {
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { control } = methods;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<Controller
|
||||
name="recursion_limit"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
field={field}
|
||||
containerClass="w-1/2"
|
||||
inputClass="w-full"
|
||||
label={localize('com_ui_agent_recursion_limit')}
|
||||
placeholder={localize('com_nav_theme_system')}
|
||||
type="number"
|
||||
labelClass="w-fit"
|
||||
labelAdjacent={
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_ui_agent_recursion_limit_info')}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1,19 @@
|
|||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
QueryKeys,
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
EModelEndpoint,
|
||||
PermissionTypes,
|
||||
AgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import { QueryKeys, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
||||
import { useCreateAgentMutation, useUpdateAgentMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import { useToastContext, useFileMapContext } from '~/Providers';
|
||||
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
|
||||
import Action from '~/components/SidePanel/Builder/Action';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import DuplicateAgent from './DuplicateAgent';
|
||||
import { processAgentOption } from '~/utils';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import AgentAvatar from './AgentAvatar';
|
||||
import { Spinner } from '~/components';
|
||||
import FileContext from './FileContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import FileSearch from './FileSearch';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import Artifacts from './Artifacts';
|
||||
import AgentTool from './AgentTool';
|
||||
import CodeForm from './Code/Form';
|
||||
|
|
@ -42,11 +30,10 @@ export default function AgentConfig({
|
|||
setAction,
|
||||
actions = [],
|
||||
agentsConfig,
|
||||
endpointsConfig,
|
||||
createMutation,
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
endpointsConfig,
|
||||
}: AgentPanelProps) {
|
||||
const { user } = useAuthContext();
|
||||
const fileMap = useFileMapContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -65,11 +52,6 @@ export default function AgentConfig({
|
|||
const tools = useWatch({ control, name: 'tools' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
|
||||
const hasAccessToShareAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.SHARED_GLOBAL,
|
||||
});
|
||||
|
||||
const toolsEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities.includes(AgentCapabilities.tools),
|
||||
[agentsConfig],
|
||||
|
|
@ -82,6 +64,10 @@ export default function AgentConfig({
|
|||
() => agentsConfig?.capabilities.includes(AgentCapabilities.artifacts) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const ocrEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities.includes(AgentCapabilities.ocr) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const fileSearchEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities.includes(AgentCapabilities.file_search) ?? false,
|
||||
[agentsConfig],
|
||||
|
|
@ -91,6 +77,26 @@ export default function AgentConfig({
|
|||
[agentsConfig],
|
||||
);
|
||||
|
||||
const context_files = useMemo(() => {
|
||||
if (typeof agent === 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (agent?.id !== agent_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (agent.context_files) {
|
||||
return agent.context_files;
|
||||
}
|
||||
|
||||
const _agent = processAgentOption({
|
||||
agent,
|
||||
fileMap,
|
||||
});
|
||||
return _agent.context_files ?? [];
|
||||
}, [agent, agent_id, fileMap]);
|
||||
|
||||
const knowledge_files = useMemo(() => {
|
||||
if (typeof agent === 'string') {
|
||||
return [];
|
||||
|
|
@ -131,46 +137,6 @@ export default function AgentConfig({
|
|||
return _agent.code_files ?? [];
|
||||
}, [agent, agent_id, fileMap]);
|
||||
|
||||
/* Mutations */
|
||||
const update = useUpdateAgentMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_success')} ${
|
||||
data.name ?? localize('com_ui_agent')
|
||||
}`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
}`,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const create = useCreateAgentMutation({
|
||||
onSuccess: (data) => {
|
||||
setCurrentAgentId(data.id);
|
||||
showToast({
|
||||
message: `${localize('com_assistants_create_success')} ${
|
||||
data.name ?? localize('com_ui_agent')
|
||||
}`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_agents_create_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
}`,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddActions = useCallback(() => {
|
||||
if (!agent_id) {
|
||||
showToast({
|
||||
|
|
@ -200,26 +166,14 @@ export default function AgentConfig({
|
|||
Icon = icons[iconKey];
|
||||
}
|
||||
|
||||
const renderSaveButton = () => {
|
||||
if (create.isLoading || update.isLoading) {
|
||||
return <Spinner className="icon-md" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (agent_id) {
|
||||
return localize('com_ui_save');
|
||||
}
|
||||
|
||||
return localize('com_ui_create');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-auto bg-white px-4 pb-8 pt-3 dark:bg-transparent">
|
||||
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
|
||||
{/* Avatar & Name */}
|
||||
<div className="mb-4">
|
||||
<AgentAvatar
|
||||
createMutation={create}
|
||||
agent_id={agent_id}
|
||||
createMutation={createMutation}
|
||||
avatar={agent?.['avatar'] ?? null}
|
||||
/>
|
||||
<label className={labelClass} htmlFor="name">
|
||||
|
|
@ -334,17 +288,19 @@ export default function AgentConfig({
|
|||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{(codeEnabled || fileSearchEnabled || artifactsEnabled) && (
|
||||
{(codeEnabled || fileSearchEnabled || artifactsEnabled || ocrEnabled) && (
|
||||
<div className="mb-4 flex w-full flex-col items-start gap-3">
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
{localize('com_assistants_capabilities')}
|
||||
</label>
|
||||
{/* Code Execution */}
|
||||
{codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />}
|
||||
{/* File Search */}
|
||||
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
|
||||
{/* File Context (OCR) */}
|
||||
{ocrEnabled && <FileContext agent_id={agent_id} files={context_files} />}
|
||||
{/* Artifacts */}
|
||||
{artifactsEnabled && <Artifacts />}
|
||||
{/* File Search */}
|
||||
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
|
||||
</div>
|
||||
)}
|
||||
{/* Agent Tools & Actions */}
|
||||
|
|
@ -404,34 +360,6 @@ export default function AgentConfig({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DeleteButton
|
||||
agent_id={agent_id}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
createMutation={create}
|
||||
/>
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
|
||||
hasAccessToShareAgents && (
|
||||
<ShareAgent
|
||||
agent_id={agent_id}
|
||||
agentName={agent?.name ?? ''}
|
||||
projectIds={agent?.projectIds ?? []}
|
||||
isCollaborative={agent?.isCollaborative}
|
||||
/>
|
||||
)}
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
type="submit"
|
||||
disabled={create.isLoading || update.isLoading}
|
||||
aria-busy={create.isLoading || update.isLoading}
|
||||
>
|
||||
{renderSaveButton()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolSelectDialog
|
||||
isOpen={showToolDialog}
|
||||
|
|
|
|||
86
client/src/components/SidePanel/Agents/AgentFooter.tsx
Normal file
86
client/src/components/SidePanel/Agents/AgentFooter.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { useWatch, useFormContext } from 'react-hook-form';
|
||||
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import { useUpdateAgentMutation } from '~/data-provider';
|
||||
import AdvancedButton from './Advanced/AdvancedButton';
|
||||
import DuplicateAgent from './DuplicateAgent';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import { Spinner } from '~/components';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentFooter({
|
||||
activePanel,
|
||||
createMutation,
|
||||
updateMutation,
|
||||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
}: Pick<
|
||||
AgentPanelProps,
|
||||
'setCurrentAgentId' | 'createMutation' | 'activePanel' | 'setActivePanel'
|
||||
> & {
|
||||
updateMutation: ReturnType<typeof useUpdateAgentMutation>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const methods = useFormContext<AgentForm>();
|
||||
|
||||
const { control } = methods;
|
||||
const agent = useWatch({ control, name: 'agent' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
|
||||
const hasAccessToShareAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.SHARED_GLOBAL,
|
||||
});
|
||||
|
||||
const renderSaveButton = () => {
|
||||
if (createMutation.isLoading || updateMutation.isLoading) {
|
||||
return <Spinner className="icon-md" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (agent_id) {
|
||||
return localize('com_ui_save');
|
||||
}
|
||||
|
||||
return localize('com_ui_create');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
|
||||
{activePanel !== Panel.advanced && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DeleteButton
|
||||
agent_id={agent_id}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
createMutation={createMutation}
|
||||
/>
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
|
||||
hasAccessToShareAgents && (
|
||||
<ShareAgent
|
||||
agent_id={agent_id}
|
||||
agentName={agent?.name ?? ''}
|
||||
projectIds={agent?.projectIds ?? []}
|
||||
isCollaborative={agent?.isCollaborative}
|
||||
/>
|
||||
)}
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
type="submit"
|
||||
disabled={createMutation.isLoading || updateMutation.isLoading}
|
||||
aria-busy={createMutation.isLoading || updateMutation.isLoading}
|
||||
>
|
||||
{renderSaveButton()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Plus } from 'lucide-react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useWatch, useForm, FormProvider } from 'react-hook-form';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { Controller, useWatch, useForm, FormProvider } from 'react-hook-form';
|
||||
import {
|
||||
Tools,
|
||||
SystemRoles,
|
||||
|
|
@ -18,8 +19,10 @@ import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
|||
import AgentPanelSkeleton from './AgentPanelSkeleton';
|
||||
import { createProviderOption } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import AdvancedPanel from './Advanced/AdvancedPanel';
|
||||
import AgentConfig from './AgentConfig';
|
||||
import AgentSelect from './AgentSelect';
|
||||
import AgentFooter from './AgentFooter';
|
||||
import { Button } from '~/components';
|
||||
import ModelPanel from './ModelPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
|
@ -129,6 +132,7 @@ export default function AgentPanel({
|
|||
agent_ids,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
} = data;
|
||||
|
||||
const model = _model ?? '';
|
||||
|
|
@ -150,6 +154,7 @@ export default function AgentPanel({
|
|||
agent_ids,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
|
@ -174,6 +179,7 @@ export default function AgentPanel({
|
|||
agent_ids,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
});
|
||||
},
|
||||
[agent_id, create, update, showToast, localize],
|
||||
|
|
@ -200,10 +206,6 @@ export default function AgentPanel({
|
|||
user?.role,
|
||||
]);
|
||||
|
||||
if (agentQuery.isInitialLoading) {
|
||||
return <AgentPanelSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
|
|
@ -211,37 +213,53 @@ export default function AgentPanel({
|
|||
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
||||
aria-label="Agent configuration form"
|
||||
>
|
||||
<div className="mt-2 flex w-full flex-wrap gap-2">
|
||||
<Controller
|
||||
name="agent"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AgentSelect
|
||||
reset={reset}
|
||||
value={field.value}
|
||||
agentQuery={agentQuery}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
selectedAgentId={current_agent_id ?? null}
|
||||
createMutation={create}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Select Button */}
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="w-full">
|
||||
<AgentSelect
|
||||
createMutation={create}
|
||||
agentQuery={agentQuery}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
// The following is required to force re-render the component when the form's agent ID changes
|
||||
// Also maintains ComboBox Focus for Accessibility
|
||||
selectedAgentId={agentQuery.isInitialLoading ? null : (current_agent_id ?? null)}
|
||||
/>
|
||||
</div>
|
||||
{/* Create + Select Button */}
|
||||
{agent_id && (
|
||||
<Button
|
||||
variant="submit"
|
||||
disabled={!agent_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAgent();
|
||||
}}
|
||||
aria-label="Select agent"
|
||||
>
|
||||
{localize('com_ui_select')}
|
||||
</Button>
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
reset(defaultAgentFormValues);
|
||||
setCurrentAgentId(undefined);
|
||||
}}
|
||||
disabled={agentQuery.isInitialLoading}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{localize('com_ui_create') +
|
||||
' ' +
|
||||
localize('com_ui_new') +
|
||||
' ' +
|
||||
localize('com_ui_agent')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="submit"
|
||||
disabled={!agent_id || agentQuery.isInitialLoading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAgent();
|
||||
}}
|
||||
aria-label={localize('com_ui_select') + ' ' + localize('com_ui_agent')}
|
||||
>
|
||||
{localize('com_ui_select')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!canEditAgent && (
|
||||
{agentQuery.isInitialLoading && <AgentPanelSkeleton />}
|
||||
{!canEditAgent && !agentQuery.isInitialLoading && (
|
||||
<div className="flex h-[30vh] w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-token-text-primary m-2 text-xl font-semibold">
|
||||
|
|
@ -251,7 +269,7 @@ export default function AgentPanel({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canEditAgent && activePanel === Panel.model && (
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.model && (
|
||||
<ModelPanel
|
||||
setActivePanel={setActivePanel}
|
||||
agent_id={agent_id}
|
||||
|
|
@ -259,16 +277,29 @@ export default function AgentPanel({
|
|||
models={models}
|
||||
/>
|
||||
)}
|
||||
{canEditAgent && activePanel === Panel.builder && (
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
|
||||
<AgentConfig
|
||||
actions={actions}
|
||||
setAction={setAction}
|
||||
createMutation={create}
|
||||
agentsConfig={agentsConfig}
|
||||
setActivePanel={setActivePanel}
|
||||
endpointsConfig={endpointsConfig}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
/>
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (
|
||||
<AdvancedPanel setActivePanel={setActivePanel} agentsConfig={agentsConfig} />
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && (
|
||||
<AgentFooter
|
||||
createMutation={create}
|
||||
updateMutation={update}
|
||||
activePanel={activePanel}
|
||||
setActivePanel={setActivePanel}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,75 +3,67 @@ import { Skeleton } from '~/components/ui';
|
|||
|
||||
export default function AgentPanelSkeleton() {
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden">
|
||||
{/* Agent Select and Button */}
|
||||
<div className="mt-1 flex w-full gap-2">
|
||||
<Skeleton className="h-[40px] w-4/5 rounded-lg" />
|
||||
<Skeleton className="h-[40px] w-1/5 rounded-lg" />
|
||||
<div className="h-auto bg-white dark:bg-transparent">
|
||||
{/* Avatar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex w-full items-center justify-center gap-4">
|
||||
<Skeleton className="relative h-20 w-20 rounded-full" />
|
||||
</div>
|
||||
{/* Name */}
|
||||
<Skeleton className="mb-2 h-5 w-1/5 rounded-lg" />
|
||||
<Skeleton className="mb-1 h-[40px] w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-1/4 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div className="h-auto bg-white px-4 pb-8 pt-3 dark:bg-transparent">
|
||||
{/* Avatar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex w-full items-center justify-center gap-4">
|
||||
<Skeleton className="relative h-20 w-20 rounded-full" />
|
||||
</div>
|
||||
{/* Name */}
|
||||
<Skeleton className="mb-2 h-5 w-1/5 rounded-lg" />
|
||||
<Skeleton className="mb-1 h-[40px] w-full rounded-lg" />
|
||||
<Skeleton className="h-3 w-1/4 rounded-lg" />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-[40px] w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-[40px] w-full rounded-lg" />
|
||||
</div>
|
||||
{/* Instructions */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-[100px] w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-[100px] w-full rounded-lg" />
|
||||
</div>
|
||||
{/* Model and Provider */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-[40px] w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Model and Provider */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="h-[40px] w-full rounded-lg" />
|
||||
</div>
|
||||
{/* Capabilities */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="mb-2 h-5 w-36 rounded-lg" />
|
||||
<Skeleton className="mb-4 h-[35px] w-full rounded-lg" />
|
||||
<Skeleton className="mb-2 h-5 w-24 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="mb-2 h-5 w-36 rounded-lg" />
|
||||
<Skeleton className="mb-4 h-[35px] w-full rounded-lg" />
|
||||
<Skeleton className="mb-2 h-5 w-24 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-full rounded-lg" />
|
||||
{/* Tools & Actions */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="mb-2 h-[35px] w-full rounded-lg" />
|
||||
<Skeleton className="mb-2 h-[35px] w-full rounded-lg" />
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-8 w-1/2 rounded-lg" />
|
||||
<Skeleton className="h-8 w-1/2 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools & Actions */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
|
||||
<Skeleton className="mb-2 h-[35px] w-full rounded-lg" />
|
||||
<Skeleton className="mb-2 h-[35px] w-full rounded-lg" />
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-8 w-1/2 rounded-lg" />
|
||||
<Skeleton className="h-8 w-1/2 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Admin Settings */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-[35px] w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Admin Settings */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-[35px] w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Bottom Buttons */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Skeleton className="h-[35px] w-16 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-16 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-16 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-full rounded-lg" />
|
||||
</div>
|
||||
{/* Bottom Buttons */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Skeleton className="h-[35px] w-16 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-16 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-16 rounded-lg" />
|
||||
<Skeleton className="h-[35px] w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,27 +1,23 @@
|
|||
import { Plus, EarthIcon } from 'lucide-react';
|
||||
import { EarthIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider';
|
||||
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
|
||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||
import type { UseFormReset } from 'react-hook-form';
|
||||
import type { TAgentCapabilities, AgentForm, TAgentOption } from '~/common';
|
||||
import { cn, createDropdownSetter, createProviderOption, processAgentOption } from '~/utils';
|
||||
import type { TAgentCapabilities, AgentForm } from '~/common';
|
||||
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import SelectDropDown from '~/components/ui/SelectDropDown';
|
||||
import { cn, createProviderOption, processAgentOption } from '~/utils';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const keys = new Set(Object.keys(defaultAgentFormValues));
|
||||
|
||||
export default function AgentSelect({
|
||||
reset,
|
||||
agentQuery,
|
||||
value: currentAgentValue,
|
||||
selectedAgentId = null,
|
||||
setCurrentAgentId,
|
||||
createMutation,
|
||||
}: {
|
||||
reset: UseFormReset<AgentForm>;
|
||||
value?: TAgentOption;
|
||||
selectedAgentId: string | null;
|
||||
agentQuery: QueryObserverResult<Agent>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
|
|
@ -29,6 +25,7 @@ export default function AgentSelect({
|
|||
}) {
|
||||
const localize = useLocalize();
|
||||
const lastSelectedAgent = useRef<string | null>(null);
|
||||
const { control, reset } = useFormContext();
|
||||
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: agents = null } = useListAgentsQuery(undefined, {
|
||||
|
|
@ -84,10 +81,29 @@ export default function AgentSelect({
|
|||
return;
|
||||
}
|
||||
|
||||
if (capabilities[name] !== undefined) {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'agent_ids' &&
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === 'string')
|
||||
) {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keys.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'recursion_limit' && typeof value === 'number') {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'number' && typeof value !== 'object') {
|
||||
formValues[name] = value;
|
||||
}
|
||||
|
|
@ -152,50 +168,40 @@ export default function AgentSelect({
|
|||
}, [selectedAgentId, agents, onSelect]);
|
||||
|
||||
const createAgent = localize('com_ui_create') + ' ' + localize('com_ui_agent');
|
||||
const hasAgentValue = !!(typeof currentAgentValue === 'object'
|
||||
? currentAgentValue.value != null && currentAgentValue.value !== ''
|
||||
: typeof currentAgentValue !== 'undefined');
|
||||
|
||||
return (
|
||||
<SelectDropDown
|
||||
value={!hasAgentValue ? createAgent : (currentAgentValue as TAgentOption)}
|
||||
setValue={createDropdownSetter(onSelect)}
|
||||
availableValues={
|
||||
agents ?? [
|
||||
{
|
||||
label: 'Loading...',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
iconSide="left"
|
||||
optionIconSide="right"
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
showOptionIcon={true}
|
||||
containerClassName="flex-grow"
|
||||
searchClassName="dark:from-gray-850"
|
||||
searchPlaceholder={localize('com_agents_search_name')}
|
||||
optionsClass="hover:bg-gray-20/50 dark:border-gray-700"
|
||||
optionsListClass="rounded-lg shadow-lg dark:bg-gray-850 dark:border-gray-700 dark:last:border"
|
||||
currentValueClass={cn(
|
||||
'text-md font-semibold text-gray-900 dark:text-white',
|
||||
hasAgentValue ? 'text-gray-500' : '',
|
||||
)}
|
||||
className={cn(
|
||||
'rounded-md dark:border-gray-700 dark:bg-gray-850',
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center truncate px-4 hover:cursor-pointer hover:border-green-500 focus:border-gray-400',
|
||||
)}
|
||||
renderOption={() => (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2 text-gray-800 dark:text-gray-100">
|
||||
<Plus className="w-[16px]" />
|
||||
</span>
|
||||
<span className={cn('ml-4 flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100')}>
|
||||
{createAgent}
|
||||
</span>
|
||||
</span>
|
||||
<Controller
|
||||
name="agent"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ControlCombobox
|
||||
containerClassName="px-0"
|
||||
selectedValue={(field?.value?.value ?? '') + ''}
|
||||
displayValue={field?.value?.label ?? ''}
|
||||
selectPlaceholder={createAgent}
|
||||
iconSide="right"
|
||||
searchPlaceholder={localize('com_agents_search_name')}
|
||||
SelectIcon={field?.value?.icon}
|
||||
setValue={onSelect}
|
||||
items={
|
||||
agents?.map((agent) => ({
|
||||
label: agent.name ?? '',
|
||||
value: agent.id ?? '',
|
||||
icon: agent.icon,
|
||||
})) ?? [
|
||||
{
|
||||
label: 'Loading...',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
className={cn(
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center truncate rounded-md bg-transparent font-bold',
|
||||
)}
|
||||
ariaLabel={localize('com_ui_agent')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
128
client/src/components/SidePanel/Agents/FileContext.tsx
Normal file
128
client/src/components/SidePanel/Agents/FileContext.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
mergeFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
|
||||
import FileRow from '~/components/Chat/Input/Files/FileRow';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger } from '~/components/ui';
|
||||
import { AttachmentIcon, CircleHelpIcon } from '~/components/svg';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
export default function FileContext({
|
||||
agent_id,
|
||||
files: _files,
|
||||
}: {
|
||||
agent_id: string;
|
||||
files?: [string, ExtendedFile][];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { setFilesLoading } = useChatContext();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.ocr },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
|
||||
useLazyEffect(
|
||||
() => {
|
||||
if (_files) {
|
||||
setFiles(new Map(_files));
|
||||
}
|
||||
},
|
||||
[_files],
|
||||
750,
|
||||
);
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
|
||||
const isUploadDisabled = endpointFileConfig.disabled ?? false;
|
||||
|
||||
if (isUploadDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleButtonClick = () => {
|
||||
// necessary to reset the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="flex items-center gap-2">
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
{localize('com_agents_file_context')}
|
||||
</label>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_agents_file_context_info')}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</div>
|
||||
</HoverCard>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File Context (OCR) Files */}
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
agent_id={agent_id}
|
||||
tool_resource={EToolResources.ocr}
|
||||
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!agent_id}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
|
||||
<input
|
||||
multiple={true}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
disabled={!agent_id}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{localize('com_ui_upload_file_context')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/* Disabled Message */}
|
||||
{agent_id ? null : (
|
||||
<div className="text-xs text-text-secondary">
|
||||
{localize('com_agents_file_context_disabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft, RotateCcw } from 'lucide-react';
|
||||
import { getSettingsKeys } from 'librechat-data-provider';
|
||||
import { useFormContext, useWatch, Controller } from 'react-hook-form';
|
||||
import { getSettingsKeys, alternateName } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { agentSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
import { getEndpointField, cn, cardStyle } from '~/utils';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { SelectDropDown } from '~/components/ui';
|
||||
import { getEndpointField, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ export default function Parameters({
|
|||
return value ?? '';
|
||||
}, [providerOption]);
|
||||
const models = useMemo(
|
||||
() => (provider ? modelsData[provider] ?? [] : []),
|
||||
() => (provider ? (modelsData[provider] ?? []) : []),
|
||||
[modelsData, provider],
|
||||
);
|
||||
|
||||
|
|
@ -77,9 +77,9 @@ export default function Parameters({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-full min-h-[50vh] overflow-auto pb-12 text-sm">
|
||||
<div className="model-panel relative flex flex-col items-center px-16 py-6 text-center">
|
||||
<div className="absolute left-0 top-6">
|
||||
<div className="mx-1 mb-1 flex h-full min-h-[50vh] w-full flex-col gap-2 text-sm">
|
||||
<div className="model-panel relative flex flex-col items-center px-16 py-4 text-center">
|
||||
<div className="absolute left-0 top-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
|
|
@ -99,6 +99,7 @@ export default function Parameters({
|
|||
{/* Endpoint aka Provider for Agents */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
id="provider-label"
|
||||
className="text-token-text-primary model-panel-label mb-2 block font-medium"
|
||||
htmlFor="provider"
|
||||
>
|
||||
|
|
@ -108,38 +109,47 @@ export default function Parameters({
|
|||
name="provider"
|
||||
control={control}
|
||||
rules={{ required: true, minLength: 1 }}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<>
|
||||
<SelectDropDown
|
||||
emptyTitle={true}
|
||||
value={field.value ?? ''}
|
||||
title={localize('com_ui_provider')}
|
||||
placeholder={localize('com_ui_select_provider')}
|
||||
searchPlaceholder={localize('com_ui_select_search_provider')}
|
||||
setValue={field.onChange}
|
||||
availableValues={providers}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'flex h-9 w-full flex-none items-center justify-center border-none px-4 hover:cursor-pointer',
|
||||
(field.value === undefined || field.value === '') &&
|
||||
'border-2 border-yellow-400',
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const value =
|
||||
typeof field.value === 'string'
|
||||
? field.value
|
||||
: ((field.value as StringOption)?.value ?? '');
|
||||
const display =
|
||||
typeof field.value === 'string'
|
||||
? field.value
|
||||
: ((field.value as StringOption)?.label ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlCombobox
|
||||
selectedValue={value}
|
||||
displayValue={alternateName[display] ?? display}
|
||||
selectPlaceholder={localize('com_ui_select_provider')}
|
||||
searchPlaceholder={localize('com_ui_select_search_provider')}
|
||||
setValue={field.onChange}
|
||||
items={providers.map((provider) => ({
|
||||
label: typeof provider === 'string' ? provider : provider.label,
|
||||
value: typeof provider === 'string' ? provider : provider.value,
|
||||
}))}
|
||||
className={cn(error ? 'border-2 border-red-500' : '')}
|
||||
ariaLabel={localize('com_ui_provider')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
{error && (
|
||||
<span className="model-panel-error text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
containerClassName={cn('rounded-md', error ? 'border-red-500 border-2' : '')}
|
||||
/>
|
||||
{error && (
|
||||
<span className="model-panel-error text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Model */}
|
||||
<div className="model-panel-section mb-4">
|
||||
<label
|
||||
id="model-label"
|
||||
className={cn(
|
||||
'text-token-text-primary model-panel-label mb-2 block font-medium',
|
||||
!provider && 'text-gray-500 dark:text-gray-400',
|
||||
|
|
@ -152,35 +162,36 @@ export default function Parameters({
|
|||
name="model"
|
||||
control={control}
|
||||
rules={{ required: true, minLength: 1 }}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<>
|
||||
<SelectDropDown
|
||||
emptyTitle={true}
|
||||
placeholder={
|
||||
provider
|
||||
? localize('com_ui_select_model')
|
||||
: localize('com_ui_select_provider_first')
|
||||
}
|
||||
value={field.value}
|
||||
setValue={field.onChange}
|
||||
availableValues={models}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
disabled={!provider}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'flex h-[40px] w-full flex-none items-center justify-center border-none px-4',
|
||||
!provider ? 'cursor-not-allowed bg-gray-200' : 'hover:cursor-pointer',
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<>
|
||||
<ControlCombobox
|
||||
selectedValue={field.value || ''}
|
||||
selectPlaceholder={
|
||||
provider
|
||||
? localize('com_ui_select_model')
|
||||
: localize('com_ui_select_provider_first')
|
||||
}
|
||||
searchPlaceholder={localize('com_ui_select_model')}
|
||||
setValue={field.onChange}
|
||||
items={models.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}))}
|
||||
disabled={!provider}
|
||||
className={cn('disabled:opacity-50', error ? 'border-2 border-red-500' : '')}
|
||||
ariaLabel={localize('com_ui_model')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
{provider && error && (
|
||||
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
containerClassName={cn('rounded-md', error ? 'border-red-500 border-2' : '')}
|
||||
/>
|
||||
{provider && error && (
|
||||
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -188,7 +199,6 @@ export default function Parameters({
|
|||
{parameters && (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-2">
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{' '}
|
||||
{/* This is the parent element containing all settings */}
|
||||
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}
|
||||
{parameters.map((setting) => {
|
||||
|
|
@ -214,19 +224,17 @@ export default function Parameters({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Reset Parameters Button */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetParameters}
|
||||
className="btn btn-neutral flex w-full items-center justify-center gap-2 px-4 py-2 text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_reset_var', { 0: localize('com_ui_model_parameters') })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Reset Parameters Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetParameters}
|
||||
className="btn btn-neutral my-1 flex w-full items-center justify-center gap-2 px-4 py-2 text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_reset_var', { 0: localize('com_ui_model_parameters') })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
Checkbox,
|
||||
HoverCard,
|
||||
// HoverCardContent,
|
||||
// HoverCardPortal,
|
||||
// HoverCardTrigger,
|
||||
} from '~/components/ui';
|
||||
// import { CircleHelpIcon } from '~/components/svg';
|
||||
// import { useLocalize } from '~/hooks';
|
||||
// import { ESide } from '~/common';
|
||||
|
||||
export default function HideSequential() {
|
||||
// const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { control, setValue, getValues } = methods;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="my-2 flex items-center">
|
||||
<Controller
|
||||
name={AgentCapabilities.hide_sequential_outputs}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() =>
|
||||
|
||||
setValue(
|
||||
AgentCapabilities.hide_sequential_outputs,
|
||||
!getValues(AgentCapabilities.hide_sequential_outputs),
|
||||
{
|
||||
shouldDirty: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<label
|
||||
className="form-check-label text-token-text-primary w-full cursor-pointer"
|
||||
htmlFor={AgentCapabilities.hide_sequential_outputs}
|
||||
>
|
||||
Hide Sequential Agent Outputs except the last agent's
|
||||
</label>
|
||||
{/* <HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-5 w-5 text-gray-500" />
|
||||
</HoverCardTrigger> */}
|
||||
</button>
|
||||
{/* <HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_agents_ttg_info')}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal> */}
|
||||
</div>
|
||||
</HoverCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import { Plus, X } from 'lucide-react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Transition } from 'react-transition-group';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
|
||||
import { TooltipAnchor } from '~/components/ui';
|
||||
import HideSequential from './HideSequential';
|
||||
|
||||
interface SequentialAgentsProps {
|
||||
field: {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const labelClass = 'mb-2 text-token-text-primary block font-medium';
|
||||
const inputClass = cn(
|
||||
defaultTextProps,
|
||||
'flex w-full px-3 py-2 dark:border-gray-800 dark:bg-gray-800 rounded-xl mb-2',
|
||||
removeFocusOutlines,
|
||||
);
|
||||
|
||||
const maxAgents = 5;
|
||||
|
||||
const SequentialAgents: React.FC<SequentialAgentsProps> = ({ field }) => {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const nodeRef = useRef(null);
|
||||
const [newAgentId, setNewAgentId] = useState('');
|
||||
|
||||
const handleAddAgentId = () => {
|
||||
if (newAgentId.trim() && field.value.length < maxAgents) {
|
||||
const newValues = [...field.value, newAgentId];
|
||||
field.onChange(newValues);
|
||||
setNewAgentId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAgentId = (index: number) => {
|
||||
const newValues = field.value.filter((_, i) => i !== index);
|
||||
field.onChange(newValues);
|
||||
};
|
||||
|
||||
const defaultStyle = {
|
||||
transition: 'opacity 200ms ease-in-out',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const triggerShake = (element: HTMLElement) => {
|
||||
element.classList.remove('shake');
|
||||
void element.offsetWidth;
|
||||
element.classList.add('shake');
|
||||
setTimeout(() => {
|
||||
element.classList.remove('shake');
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const transitionStyles = {
|
||||
entering: { opacity: 1 },
|
||||
entered: { opacity: 1 },
|
||||
exiting: { opacity: 0 },
|
||||
exited: { opacity: 0 },
|
||||
};
|
||||
|
||||
const hasReachedMax = field.value.length >= Constants.MAX_CONVO_STARTERS;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label className={labelClass} htmlFor="agent_ids">
|
||||
Sequential Agents
|
||||
</label>
|
||||
<div className="mt-4 space-y-2">
|
||||
<HideSequential />
|
||||
{/* Display existing agents first */}
|
||||
{field.value.map((agentId, index) => (
|
||||
<div key={index} className="relative">
|
||||
<input
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
value={agentId}
|
||||
onChange={(e) => {
|
||||
const newValue = [...field.value];
|
||||
newValue[index] = e.target.value;
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
className={`${inputClass} pr-10`}
|
||||
type="text"
|
||||
maxLength={64}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
side="top"
|
||||
description={'Remove agent ID'}
|
||||
className="absolute right-1 top-1 flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
|
||||
onClick={() => handleDeleteAgentId(index)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</TooltipAnchor>
|
||||
</div>
|
||||
))}
|
||||
{/* Input for new agent at the bottom */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={(el) => (inputRefs.current[field.value.length] = el)}
|
||||
value={newAgentId}
|
||||
maxLength={64}
|
||||
className={`${inputClass} pr-10`}
|
||||
type="text"
|
||||
placeholder={hasReachedMax ? 'Max agents reached' : 'Enter agent ID (e.g. agent_1234)'}
|
||||
onChange={(e) => setNewAgentId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (hasReachedMax) {
|
||||
triggerShake(e.currentTarget);
|
||||
} else {
|
||||
handleAddAgentId();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Transition
|
||||
nodeRef={nodeRef}
|
||||
in={field.value.length < Constants.MAX_CONVO_STARTERS}
|
||||
timeout={200}
|
||||
unmountOnExit
|
||||
>
|
||||
{(state: string) => (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
...defaultStyle,
|
||||
...transitionStyles[state as keyof typeof transitionStyles],
|
||||
transition: state === 'entering' ? 'none' : defaultStyle.transition,
|
||||
}}
|
||||
className="absolute right-1 top-1"
|
||||
>
|
||||
<TooltipAnchor
|
||||
side="top"
|
||||
description={hasReachedMax ? 'Max agents reached' : 'Add agent ID'}
|
||||
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
|
||||
onClick={handleAddAgentId}
|
||||
disabled={hasReachedMax}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</TooltipAnchor>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SequentialAgents;
|
||||
|
|
@ -78,6 +78,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
|
|||
ariaLabel={'assistant'}
|
||||
setValue={onSelect}
|
||||
items={assistantOptions}
|
||||
iconClassName="assistant-item"
|
||||
SelectIcon={
|
||||
<Icon
|
||||
isCreatedByUser={false}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
|||
</div>
|
||||
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
|
||||
<div className="h-9 grow px-3 py-2">
|
||||
{localize(`com_ui_${type}` as TranslationKeys)}
|
||||
{localize(getAuthLocalizationKey(type))}
|
||||
</div>
|
||||
<div className="bg-token-border-medium w-px"></div>
|
||||
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
|
||||
|
|
@ -269,6 +269,18 @@ const ApiKey = () => {
|
|||
);
|
||||
};
|
||||
|
||||
/** Returns the appropriate localization key for authentication type */
|
||||
function getAuthLocalizationKey(type: AuthTypeEnum): TranslationKeys {
|
||||
switch (type) {
|
||||
case AuthTypeEnum.ServiceHttp:
|
||||
return 'com_ui_api_key';
|
||||
case AuthTypeEnum.OAuth:
|
||||
return 'com_ui_oauth';
|
||||
default:
|
||||
return 'com_ui_none';
|
||||
}
|
||||
}
|
||||
|
||||
const OAuth = () => {
|
||||
const localize = useLocalize();
|
||||
const { register, watch, setValue } = useFormContext();
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
import { ArrowUpDown } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import PanelFileCell from './PanelFileCell';
|
||||
import { Button } from '~/components/ui';
|
||||
import { formatDate } from '~/utils';
|
||||
|
||||
export const columns: ColumnDef<TFile>[] = [
|
||||
export const columns: ColumnDef<TFile | undefined>[] = [
|
||||
{
|
||||
accessorKey: 'filename',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-surface-hover"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Name
|
||||
{localize('com_ui_name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
|
@ -31,20 +33,21 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
size: '10%',
|
||||
},
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-surface-hover"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Date
|
||||
{localize('com_ui_date')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<span className="flex justify-end text-xs">
|
||||
{formatDate(row.original.updatedAt?.toString() ?? '')}
|
||||
{formatDate(row.original?.updatedAt?.toString() ?? '')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { getFileType } from '~/utils';
|
|||
|
||||
export default function PanelFileCell({ row }: { row: Row<TFile | undefined> }) {
|
||||
const file = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{file?.type.startsWith('image') === true ? (
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
filename: fileData.filename,
|
||||
source: fileData.source,
|
||||
size: fileData.bytes,
|
||||
metadata: fileData.metadata,
|
||||
});
|
||||
},
|
||||
[addFile, fileMap, conversation, localize, showToast, fileConfig.endpoints],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -66,7 +66,7 @@ function DynamicCheckbox({
|
|||
htmlFor={`${settingKey}-dynamic-checkbox`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}:{' '}
|
||||
|
|
@ -85,7 +85,7 @@ function DynamicCheckbox({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) ?? description : description}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { OptionTypes } from 'librechat-data-provider';
|
|||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -93,7 +93,7 @@ function DynamicCombobox({
|
|||
htmlFor={`${settingKey}-dynamic-combobox`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) ?? label : label || settingKey}
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}: {defaultValue})
|
||||
|
|
@ -105,10 +105,10 @@ function DynamicCombobox({
|
|||
<ControlCombobox
|
||||
displayValue={selectedValue}
|
||||
selectPlaceholder={
|
||||
selectPlaceholderCode === true ? localize(selectPlaceholder) : selectPlaceholder
|
||||
selectPlaceholderCode === true ? localize(selectPlaceholder as TranslationKeys) : selectPlaceholder
|
||||
}
|
||||
searchPlaceholder={
|
||||
searchPlaceholderCode === true ? localize(searchPlaceholder) : searchPlaceholder
|
||||
searchPlaceholderCode === true ? localize(searchPlaceholder as TranslationKeys) : searchPlaceholder
|
||||
}
|
||||
isCollapsed={isCollapsed}
|
||||
ariaLabel={settingKey}
|
||||
|
|
@ -120,7 +120,7 @@ function DynamicCombobox({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) ?? description : description}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, HoverCard, HoverCardTrigger, SelectDropDown } from '~/components/ui';
|
||||
import { useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -78,7 +78,7 @@ function DynamicDropdown({
|
|||
htmlFor={`${settingKey}-dynamic-dropdown`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) ?? label : label || settingKey}
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}: {defaultValue})
|
||||
|
|
@ -96,12 +96,12 @@ function DynamicDropdown({
|
|||
availableValues={options}
|
||||
containerClassName="w-full"
|
||||
id={`${settingKey}-dynamic-dropdown`}
|
||||
placeholder={placeholderCode ? localize(placeholder) ?? placeholder : placeholder}
|
||||
placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) ?? description : description}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
|
||||
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
|
||||
import { Label, Input, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
|
|
@ -27,12 +27,9 @@ function DynamicInput({
|
|||
const localize = useLocalize();
|
||||
const { preset } = useChatContext();
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
initialValue:
|
||||
optionType !== OptionTypes.Custom
|
||||
? (conversation?.[settingKey] as string)
|
||||
: (defaultValue as string),
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
});
|
||||
|
|
@ -73,7 +70,7 @@ function DynamicInput({
|
|||
htmlFor={`${settingKey}-dynamic-input`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) || label : label || settingKey}{' '}
|
||||
{labelCode ? localize(label as TranslationKeys) || label : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
(
|
||||
|
|
@ -88,9 +85,13 @@ function DynamicInput({
|
|||
<Input
|
||||
id={`${settingKey}-dynamic-input`}
|
||||
disabled={readonly}
|
||||
value={inputValue ?? ''}
|
||||
value={inputValue ?? defaultValue ?? ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholderCode ? localize(placeholder) || placeholder : placeholder}
|
||||
placeholder={
|
||||
placeholderCode
|
||||
? localize(placeholder as TranslationKeys) || placeholder
|
||||
: placeholder
|
||||
}
|
||||
className={cn(
|
||||
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
|
||||
)}
|
||||
|
|
@ -98,7 +99,11 @@ function DynamicInput({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) || description : description}
|
||||
description={
|
||||
descriptionCode
|
||||
? localize(description as TranslationKeys) || description
|
||||
: description
|
||||
}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMemo, useCallback } from 'react';
|
|||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Slider, HoverCard, Input, InputNumber, HoverCardTrigger } from '~/components/ui';
|
||||
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
|
||||
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
|
||||
import { cn, defaultTextProps, optionText } from '~/utils';
|
||||
import { ESide, defaultDebouncedDelay } from '~/common';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
|
@ -117,7 +117,7 @@ function DynamicSlider({
|
|||
htmlFor={`${settingKey}-dynamic-setting`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}: {defaultValue})
|
||||
|
|
@ -176,7 +176,7 @@ function DynamicSlider({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) ?? description : description}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
|
|||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -65,7 +65,7 @@ function DynamicSwitch({
|
|||
htmlFor={`${settingKey}-dynamic-switch`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}:{' '}
|
||||
|
|
@ -84,7 +84,7 @@ function DynamicSwitch({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) ?? description : description}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
|
||||
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
|
||||
import { cn, defaultTextProps } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
|
|
@ -58,7 +58,7 @@ function DynamicTextarea({
|
|||
htmlFor={`${settingKey}-dynamic-textarea`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
(
|
||||
|
|
@ -75,7 +75,7 @@ function DynamicTextarea({
|
|||
disabled={readonly}
|
||||
value={inputValue ?? ''}
|
||||
onChange={setInputValue}
|
||||
placeholder={placeholderCode ? localize(placeholder) ?? placeholder : placeholder}
|
||||
placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder}
|
||||
className={cn(
|
||||
// TODO: configurable max height
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none rounded-lg bg-surface-secondary px-3 py-2 focus:outline-none',
|
||||
|
|
@ -84,7 +84,7 @@ function DynamicTextarea({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description) ?? description : description}
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { HoverCardPortal, HoverCardContent } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
type TOptionHoverProps = {
|
||||
|
|
@ -24,7 +24,7 @@ function OptionHover({
|
|||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
const text = langCode ? localize(description) : description;
|
||||
const text = langCode ? localize(description as TranslationKeys) : description;
|
||||
return (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={side} className={`z-[999] w-80 ${className}`} sideOffset={sideOffset}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { RotateCcw } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { getSettingsKeys, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||
import { excludedKeys, getSettingsKeys, tConvoUpdateSchema } from 'librechat-data-provider';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { SaveAsPresetDialog } from '~/components/Endpoints';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
|
|
@ -9,23 +10,6 @@ import { componentMapping } from './components';
|
|||
import { useChatContext } from '~/Providers';
|
||||
import { settings } from './settings';
|
||||
|
||||
const excludedKeys = new Set([
|
||||
'conversationId',
|
||||
'title',
|
||||
'endpoint',
|
||||
'endpointType',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'messages',
|
||||
'isArchived',
|
||||
'tags',
|
||||
'user',
|
||||
'__v',
|
||||
'_id',
|
||||
'tools',
|
||||
'model',
|
||||
]);
|
||||
|
||||
export default function Parameters() {
|
||||
const localize = useLocalize();
|
||||
const { conversation, setConversation } = useChatContext();
|
||||
|
|
@ -105,6 +89,31 @@ export default function Parameters() {
|
|||
});
|
||||
}, [parameters, setConversation]);
|
||||
|
||||
const resetParameters = useCallback(() => {
|
||||
setConversation((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const updatedConversation = { ...prev };
|
||||
const resetKeys: string[] = [];
|
||||
|
||||
Object.keys(updatedConversation).forEach((key) => {
|
||||
if (excludedKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedConversation[key] !== undefined) {
|
||||
resetKeys.push(key);
|
||||
delete updatedConversation[key];
|
||||
}
|
||||
});
|
||||
|
||||
logger.log('parameters', 'parameters reset, affected keys:', resetKeys);
|
||||
return updatedConversation;
|
||||
});
|
||||
}, [setConversation]);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
const newPreset = tConvoUpdateSchema.parse({
|
||||
...conversation,
|
||||
|
|
@ -146,7 +155,17 @@ export default function Parameters() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetParameters}
|
||||
className="btn btn-neutral flex w-full items-center justify-center gap-2 px-4 py-2 text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_ui_reset_var', { 0: localize('com_ui_model_parameters') })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<button
|
||||
onClick={openDialog}
|
||||
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
|
||||
|
|
|
|||
|
|
@ -278,12 +278,42 @@ const anthropic: Record<string, SettingDefinition> = {
|
|||
description: 'com_endpoint_anthropic_prompt_cache',
|
||||
descriptionCode: true,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
default: anthropicSettings.promptCache.default,
|
||||
component: 'switch',
|
||||
optionType: 'conversation',
|
||||
showDefault: false,
|
||||
columnSpan: 2,
|
||||
},
|
||||
thinking: {
|
||||
key: 'thinking',
|
||||
label: 'com_endpoint_thinking',
|
||||
labelCode: true,
|
||||
description: 'com_endpoint_anthropic_thinking',
|
||||
descriptionCode: true,
|
||||
type: 'boolean',
|
||||
default: anthropicSettings.thinking.default,
|
||||
component: 'switch',
|
||||
optionType: 'conversation',
|
||||
showDefault: false,
|
||||
columnSpan: 2,
|
||||
},
|
||||
thinkingBudget: {
|
||||
key: 'thinkingBudget',
|
||||
label: 'com_endpoint_thinking_budget',
|
||||
labelCode: true,
|
||||
description: 'com_endpoint_anthropic_thinking_budget',
|
||||
descriptionCode: true,
|
||||
type: 'number',
|
||||
component: 'input',
|
||||
default: anthropicSettings.thinkingBudget.default,
|
||||
range: {
|
||||
min: anthropicSettings.thinkingBudget.min,
|
||||
max: anthropicSettings.thinkingBudget.max,
|
||||
step: anthropicSettings.thinkingBudget.step,
|
||||
},
|
||||
optionType: 'conversation',
|
||||
columnSpan: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const bedrock: Record<string, SettingDefinition> = {
|
||||
|
|
@ -467,10 +497,10 @@ const openAICol1: SettingsConfiguration = [
|
|||
baseDefinitions.model as SettingDefinition,
|
||||
openAIParams.chatGptLabel,
|
||||
librechat.promptPrefix,
|
||||
librechat.maxContextTokens,
|
||||
];
|
||||
|
||||
const openAICol2: SettingsConfiguration = [
|
||||
librechat.maxContextTokens,
|
||||
openAIParams.max_tokens,
|
||||
openAIParams.temperature,
|
||||
openAIParams.top_p,
|
||||
|
|
@ -492,6 +522,8 @@ const anthropicConfig: SettingsConfiguration = [
|
|||
anthropic.topK,
|
||||
librechat.resendFiles,
|
||||
anthropic.promptCache,
|
||||
anthropic.thinking,
|
||||
anthropic.thinkingBudget,
|
||||
];
|
||||
|
||||
const anthropicCol1: SettingsConfiguration = [
|
||||
|
|
@ -508,6 +540,8 @@ const anthropicCol2: SettingsConfiguration = [
|
|||
anthropic.topK,
|
||||
librechat.resendFiles,
|
||||
anthropic.promptCache,
|
||||
anthropic.thinking,
|
||||
anthropic.thinkingBudget,
|
||||
];
|
||||
|
||||
const bedrockAnthropic: SettingsConfiguration = [
|
||||
|
|
@ -519,8 +553,10 @@ const bedrockAnthropic: SettingsConfiguration = [
|
|||
bedrock.topP,
|
||||
bedrock.topK,
|
||||
baseDefinitions.stop,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
anthropic.thinking,
|
||||
anthropic.thinkingBudget,
|
||||
];
|
||||
|
||||
const bedrockMistral: SettingsConfiguration = [
|
||||
|
|
@ -530,8 +566,8 @@ const bedrockMistral: SettingsConfiguration = [
|
|||
bedrock.maxTokens,
|
||||
mistral.temperature,
|
||||
mistral.topP,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
const bedrockCohere: SettingsConfiguration = [
|
||||
|
|
@ -541,8 +577,8 @@ const bedrockCohere: SettingsConfiguration = [
|
|||
bedrock.maxTokens,
|
||||
cohere.temperature,
|
||||
cohere.topP,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
const bedrockGeneral: SettingsConfiguration = [
|
||||
|
|
@ -551,8 +587,8 @@ const bedrockGeneral: SettingsConfiguration = [
|
|||
librechat.maxContextTokens,
|
||||
meta.temperature,
|
||||
meta.topP,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
const bedrockAnthropicCol1: SettingsConfiguration = [
|
||||
|
|
@ -568,8 +604,10 @@ const bedrockAnthropicCol2: SettingsConfiguration = [
|
|||
bedrock.temperature,
|
||||
bedrock.topP,
|
||||
bedrock.topK,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
anthropic.thinking,
|
||||
anthropic.thinkingBudget,
|
||||
];
|
||||
|
||||
const bedrockMistralCol1: SettingsConfiguration = [
|
||||
|
|
@ -583,8 +621,8 @@ const bedrockMistralCol2: SettingsConfiguration = [
|
|||
bedrock.maxTokens,
|
||||
mistral.temperature,
|
||||
mistral.topP,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
const bedrockCohereCol1: SettingsConfiguration = [
|
||||
|
|
@ -598,8 +636,8 @@ const bedrockCohereCol2: SettingsConfiguration = [
|
|||
bedrock.maxTokens,
|
||||
cohere.temperature,
|
||||
cohere.topP,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
const bedrockGeneralCol1: SettingsConfiguration = [
|
||||
|
|
@ -612,8 +650,8 @@ const bedrockGeneralCol2: SettingsConfiguration = [
|
|||
librechat.maxContextTokens,
|
||||
meta.temperature,
|
||||
meta.topP,
|
||||
bedrock.region,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
];
|
||||
|
||||
export const settings: Record<string, SettingsConfiguration | undefined> = {
|
||||
|
|
@ -627,6 +665,7 @@ export const settings: Record<string, SettingsConfiguration | undefined> = {
|
|||
[`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneral,
|
||||
[`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneral,
|
||||
[`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneral,
|
||||
[`${EModelEndpoint.bedrock}-${BedrockProviders.DeepSeek}`]: bedrockGeneral,
|
||||
[EModelEndpoint.google]: googleConfig,
|
||||
};
|
||||
|
||||
|
|
@ -670,6 +709,7 @@ export const presetSettings: Record<
|
|||
[`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneralColumns,
|
||||
[`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneralColumns,
|
||||
[`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneralColumns,
|
||||
[`${EModelEndpoint.bedrock}-${BedrockProviders.DeepSeek}`]: bedrockGeneralColumns,
|
||||
[EModelEndpoint.google]: {
|
||||
col1: googleCol1,
|
||||
col2: googleCol2,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ const SidePanel = ({
|
|||
id="controls-nav"
|
||||
order={hasArtifacts != null ? 3 : 2}
|
||||
aria-label={localize('com_ui_controls')}
|
||||
role="region"
|
||||
role="navigation"
|
||||
collapsedSize={collapsedSize}
|
||||
defaultSize={defaultSize}
|
||||
collapsible={true}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
import { Search, ChevronDown } from 'lucide-react';
|
||||
import { useMemo, useState, useRef, memo, useEffect } from 'react';
|
||||
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ControlComboboxProps {
|
||||
selectedValue: string;
|
||||
|
|
@ -16,6 +16,13 @@ interface ControlComboboxProps {
|
|||
selectPlaceholder?: string;
|
||||
isCollapsed: boolean;
|
||||
SelectIcon?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
showCarat?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
selectId?: string;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
|
@ -28,18 +35,47 @@ function ControlCombobox({
|
|||
ariaLabel,
|
||||
searchPlaceholder,
|
||||
selectPlaceholder,
|
||||
containerClassName,
|
||||
isCollapsed,
|
||||
SelectIcon,
|
||||
showCarat,
|
||||
className,
|
||||
disabled,
|
||||
iconClassName,
|
||||
iconSide = 'left',
|
||||
selectId,
|
||||
}: ControlComboboxProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
|
||||
|
||||
const getItem = (option: OptionWithIcon) => ({
|
||||
id: `item-${option.value}`,
|
||||
value: option.value as string | undefined,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
});
|
||||
|
||||
const combobox = Ariakit.useComboboxStore({
|
||||
defaultItems: items.map(getItem),
|
||||
resetValueOnHide: true,
|
||||
value: searchValue,
|
||||
setValue: setSearchValue,
|
||||
});
|
||||
|
||||
const select = Ariakit.useSelectStore({
|
||||
combobox,
|
||||
defaultItems: items.map(getItem),
|
||||
value: selectedValue,
|
||||
setValue,
|
||||
});
|
||||
|
||||
const matches = useMemo(() => {
|
||||
return matchSorter(items, searchValue, {
|
||||
const filteredItems = matchSorter(items, searchValue, {
|
||||
keys: ['value', 'label'],
|
||||
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
|
||||
});
|
||||
return filteredItems.map(getItem);
|
||||
}, [searchValue, items]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -48,104 +84,95 @@ function ControlCombobox({
|
|||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const rowRenderer = ({
|
||||
index,
|
||||
key,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
key: string;
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const item = matches[index];
|
||||
return (
|
||||
<Ariakit.SelectItem
|
||||
key={key}
|
||||
value={`${item.value ?? ''}`}
|
||||
aria-label={`${item.label ?? item.value ?? ''}`}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={<Ariakit.ComboboxItem />}
|
||||
style={style}
|
||||
>
|
||||
{item.icon != null && (
|
||||
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{item.label}</span>
|
||||
</Ariakit.SelectItem>
|
||||
);
|
||||
};
|
||||
const selectIconClassName = cn(
|
||||
'flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
const optionIconClassName = cn(
|
||||
'mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-1">
|
||||
<Ariakit.ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
<div className={cn('flex w-full items-center justify-center px-1', containerClassName)}>
|
||||
<Ariakit.SelectLabel store={select} className="sr-only">
|
||||
{ariaLabel}
|
||||
</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
store={select}
|
||||
id={selectId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Ariakit.SelectProvider value={selectedValue} setValue={setValue}>
|
||||
<Ariakit.SelectLabel className="sr-only">{ariaLabel}</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
|
||||
{SelectIcon != null && iconSide === 'left' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-grow truncate text-left">
|
||||
{displayValue != null
|
||||
? displayValue || selectPlaceholder
|
||||
: selectedValue || selectPlaceholder}
|
||||
</span>
|
||||
{SelectIcon != null && iconSide === 'right' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
>
|
||||
{SelectIcon != null && (
|
||||
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{SelectIcon}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span className="flex-grow truncate text-left">
|
||||
{displayValue ?? selectPlaceholder}
|
||||
</span>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
gutter={4}
|
||||
portal
|
||||
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
|
||||
style={{ width: isCollapsed ? '300px' : buttonWidth ?? '300px' }}
|
||||
>
|
||||
<div className="p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||
<Ariakit.Combobox
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[50vh]">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={Math.min(matches.length * ROW_HEIGHT, 300)}
|
||||
rowCount={matches.length}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowRenderer={rowRenderer}
|
||||
overscanRowCount={5}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</Ariakit.SelectProvider>
|
||||
</Ariakit.ComboboxProvider>
|
||||
{showCarat && <ChevronDown className="h-4 w-4 text-text-secondary" />}
|
||||
</>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
store={select}
|
||||
gutter={4}
|
||||
portal
|
||||
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
|
||||
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
|
||||
>
|
||||
<div className="p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||
<Ariakit.Combobox
|
||||
store={combobox}
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Ariakit.ComboboxList store={combobox}>
|
||||
<SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
|
||||
{({ value, icon, label, ...item }) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={item.id}
|
||||
{...item}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={<Ariakit.SelectItem value={value} />}
|
||||
>
|
||||
{icon != null && iconSide === 'left' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{label}</span>
|
||||
{icon != null && iconSide === 'right' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
</Ariakit.ComboboxItem>
|
||||
)}
|
||||
</SelectRenderer>
|
||||
</Ariakit.ComboboxList>
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
import { TrashIcon, Spinner } from '~/components/svg';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { LocalizeFunction } from '~/common';
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
|
|
@ -177,7 +178,7 @@ const DeleteButton = memo(
|
|||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
localize: (key: string) => string;
|
||||
localize:LocalizeFunction;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
|
|
|
|||
62
client/src/components/ui/FormInput.tsx
Normal file
62
client/src/components/ui/FormInput.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { Label, Input } from '~/components/ui';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function FormInput({
|
||||
field,
|
||||
label,
|
||||
labelClass,
|
||||
inputClass,
|
||||
containerClass,
|
||||
labelAdjacent,
|
||||
placeholder = '',
|
||||
type = 'string',
|
||||
}: {
|
||||
field: any;
|
||||
label: string;
|
||||
labelClass?: string;
|
||||
inputClass?: string;
|
||||
placeholder?: string;
|
||||
containerClass?: string;
|
||||
type?: 'string' | 'number';
|
||||
labelAdjacent?: React.ReactNode;
|
||||
}) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (type !== 'number') {
|
||||
field.onChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
field.onChange(value);
|
||||
} else if (!isNaN(Number(value))) {
|
||||
field.onChange(Number(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col items-center gap-2', containerClass)}>
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<Label
|
||||
htmlFor={`${field.name}-input`}
|
||||
className={cn('text-left text-sm font-semibold text-text-primary', labelClass)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{labelAdjacent}
|
||||
</div>
|
||||
<Input
|
||||
id={`${field.name}-input`}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
|
||||
inputClass,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
client/src/components/ui/InputOTP.tsx
Normal file
68
client/src/components/ui/InputOTP.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
|
||||
isActive && 'z-10 ring-1 ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue