mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-31 23:58:50 +01:00
Merge branch 'dev' into feat/multi-lang-Terms-of-service
This commit is contained in:
commit
126b1fe412
323 changed files with 20207 additions and 4039 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"scripts": {
|
||||
"data-provider": "cd .. && npm run build:data-provider",
|
||||
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",
|
||||
"build:ci": "cross-env NODE_ENV=development vite build --mode ci",
|
||||
"dev": "cross-env NODE_ENV=development vite",
|
||||
"preview-prod": "cross-env NODE_ENV=development vite preview",
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
"react-i18next": "^15.4.0",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.8",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
|
|
@ -139,6 +139,7 @@
|
|||
"postcss": "^8.4.31",
|
||||
"postcss-loader": "^7.1.0",
|
||||
"postcss-preset-env": "^8.2.0",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
|
|
|
|||
1
client/public/assets/google.svg
Normal file
1
client/public/assets/google.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="56" style="flex: 0 0 auto; line-height: 1;" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 587 B |
1
client/public/assets/openai.svg
Normal file
1
client/public/assets/openai.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="56" style="flex: 0 0 auto; line-height: 1;" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
client/public/assets/qwen.svg
Normal file
1
client/public/assets/qwen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="56" style="flex: 0 0 auto; line-height: 1;" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#00055F" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
14
client/scripts/post-build.cjs
Normal file
14
client/scripts/post-build.cjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const fs = require('fs-extra');
|
||||
|
||||
async function postBuild() {
|
||||
try {
|
||||
await fs.copy('public/assets', 'dist/assets');
|
||||
await fs.copy('public/robots.txt', 'dist/robots.txt');
|
||||
console.log('✅ PWA icons and robots.txt copied successfully. Glob pattern warnings resolved.');
|
||||
} catch (err) {
|
||||
console.error('❌ Error copying files:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
postBuild();
|
||||
9
client/src/Providers/SearchContext.tsx
Normal file
9
client/src/Providers/SearchContext.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { SearchResultData } from 'librechat-data-provider';
|
||||
|
||||
type SearchContext = {
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
};
|
||||
|
||||
export const SearchContext = createContext<SearchContext>({} as SearchContext);
|
||||
export const useSearchContext = () => useContext(SearchContext);
|
||||
|
|
@ -20,3 +20,4 @@ export * from './ArtifactContext';
|
|||
export * from './CodeBlockContext';
|
||||
export * from './ToolCallsMapContext';
|
||||
export * from './SetConvoContext';
|
||||
export * from './SearchContext';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type TAgentOption = OptionWithIcon &
|
|||
};
|
||||
|
||||
export type TAgentCapabilities = {
|
||||
[AgentCapabilities.web_search]: boolean;
|
||||
[AgentCapabilities.file_search]: boolean;
|
||||
[AgentCapabilities.execute_code]: boolean;
|
||||
[AgentCapabilities.end_after_tools]?: boolean;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export enum Panel {
|
|||
builder = 'builder',
|
||||
actions = 'actions',
|
||||
model = 'model',
|
||||
version = 'version',
|
||||
}
|
||||
|
||||
export type FileSetter =
|
||||
|
|
@ -535,6 +536,7 @@ export type NewConversationParams = {
|
|||
buildDefault?: boolean;
|
||||
keepLatestMessage?: boolean;
|
||||
keepAddedConvos?: boolean;
|
||||
disableParams?: boolean;
|
||||
};
|
||||
|
||||
export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
const { data: config } = useGetStartupConfig();
|
||||
const useUsernameLogin = config?.ldap?.username;
|
||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && error.includes('422') && !showResendLink) {
|
||||
|
|
@ -100,20 +101,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
},
|
||||
})}
|
||||
aria-invalid={!!errors.email}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
|
||||
placeholder=" "
|
||||
/>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="
|
||||
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
|
||||
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
|
||||
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
|
||||
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
|
||||
"
|
||||
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
|
||||
>
|
||||
{useUsernameLogin
|
||||
? localize('com_auth_username').replace(/ \(.*$/, '')
|
||||
|
|
@ -135,20 +128,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
|
||||
})}
|
||||
aria-invalid={!!errors.password}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
|
||||
placeholder=" "
|
||||
/>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="
|
||||
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
|
||||
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
|
||||
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
|
||||
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
|
||||
"
|
||||
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
|
||||
>
|
||||
{localize('com_auth_password')}
|
||||
</label>
|
||||
|
|
@ -164,16 +149,15 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
</a>
|
||||
)}
|
||||
|
||||
{/* Render Turnstile only if enabled in startupConfig */}
|
||||
{startupConfig.turnstile && (
|
||||
{requireCaptcha && (
|
||||
<div className="my-4 flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={startupConfig.turnstile.siteKey}
|
||||
siteKey={startupConfig.turnstile!.siteKey}
|
||||
options={{
|
||||
...startupConfig.turnstile.options,
|
||||
...startupConfig.turnstile!.options,
|
||||
theme: validTheme,
|
||||
}}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
onSuccess={setTurnstileToken}
|
||||
onError={() => setTurnstileToken(null)}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
|
|
@ -185,11 +169,8 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
type="submit"
|
||||
disabled={startupConfig.turnstile ? !turnstileToken : false}
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
disabled={requireCaptcha && !turnstileToken}
|
||||
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-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ const Registration: React.FC = () => {
|
|||
const token = queryParams.get('token');
|
||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
// only require captcha if we have a siteKey
|
||||
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
|
||||
|
||||
const registerUser = useRegisterUserMutation({
|
||||
onMutate: () => {
|
||||
setIsSubmitting(true);
|
||||
|
|
@ -73,21 +76,13 @@ const Registration: React.FC = () => {
|
|||
validation,
|
||||
)}
|
||||
aria-invalid={!!errors[id]}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
|
||||
placeholder=" "
|
||||
data-testid={id}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="
|
||||
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
|
||||
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
|
||||
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
|
||||
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
|
||||
"
|
||||
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
|
||||
>
|
||||
{localize(label)}
|
||||
</label>
|
||||
|
|
@ -183,8 +178,7 @@ const Registration: React.FC = () => {
|
|||
value === password || localize('com_auth_password_not_match'),
|
||||
})}
|
||||
|
||||
{/* Render Turnstile only if enabled in startupConfig */}
|
||||
{startupConfig?.turnstile && (
|
||||
{startupConfig?.turnstile?.siteKey && (
|
||||
<div className="my-4 flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={startupConfig.turnstile.siteKey}
|
||||
|
|
@ -204,16 +198,11 @@ const Registration: React.FC = () => {
|
|||
disabled={
|
||||
Object.keys(errors).length > 0 ||
|
||||
isSubmitting ||
|
||||
(startupConfig?.turnstile ? !turnstileToken : false)
|
||||
(requireCaptcha && !turnstileToken)
|
||||
}
|
||||
type="submit"
|
||||
aria-label="Submit registration"
|
||||
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
|
||||
"
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
|
||||
import {
|
||||
GoogleIcon,
|
||||
FacebookIcon,
|
||||
OpenIDIcon,
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
AppleIcon,
|
||||
SamlIcon,
|
||||
} from '~/components';
|
||||
|
||||
import SocialButton from './SocialButton';
|
||||
|
||||
|
|
@ -90,6 +98,23 @@ function SocialLoginRender({
|
|||
id="openid"
|
||||
/>
|
||||
),
|
||||
saml: startupConfig.samlLoginEnabled && (
|
||||
<SocialButton
|
||||
key="saml"
|
||||
enabled={startupConfig.samlLoginEnabled}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
oauthPath="saml"
|
||||
Icon={() =>
|
||||
startupConfig.samlImageUrl ? (
|
||||
<img src={startupConfig.samlImageUrl} alt="SAML Logo" className="h-5 w-5" />
|
||||
) : (
|
||||
<SamlIcon />
|
||||
)
|
||||
}
|
||||
label={startupConfig.samlLabel ? startupConfig.samlLabel : localize('com_auth_saml_login')}
|
||||
id="saml"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const mockStartupConfig = {
|
|||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
|
||||
discordLoginEnabled: true,
|
||||
facebookLoginEnabled: true,
|
||||
githubLoginEnabled: true,
|
||||
|
|
@ -24,6 +24,9 @@ const mockStartupConfig = {
|
|||
openidLoginEnabled: true,
|
||||
openidLabel: 'Test OpenID',
|
||||
openidImageUrl: 'http://test-server.com',
|
||||
samlLoginEnabled: true,
|
||||
samlLabel: 'Test SAML',
|
||||
samlImageUrl: 'http://test-server.com',
|
||||
ldap: {
|
||||
enabled: false,
|
||||
},
|
||||
|
|
@ -143,6 +146,11 @@ test('renders login form', () => {
|
|||
'href',
|
||||
'mock-server/oauth/discord',
|
||||
);
|
||||
expect(getByRole('link', { name: /Test SAML/i })).toBeInTheDocument();
|
||||
expect(getByRole('link', { name: /Test SAML/i })).toHaveAttribute(
|
||||
'href',
|
||||
'mock-server/oauth/saml',
|
||||
);
|
||||
});
|
||||
|
||||
test('calls loginUser.mutate on login', async () => {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jest.mock('librechat-data-provider/react-query');
|
|||
const mockLogin = jest.fn();
|
||||
|
||||
const mockStartupConfig: TStartupConfig = {
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
|
||||
discordLoginEnabled: true,
|
||||
facebookLoginEnabled: true,
|
||||
githubLoginEnabled: true,
|
||||
|
|
@ -20,6 +20,9 @@ const mockStartupConfig: TStartupConfig = {
|
|||
openidLoginEnabled: true,
|
||||
openidLabel: 'Test OpenID',
|
||||
openidImageUrl: 'http://test-server.com',
|
||||
samlLoginEnabled: true,
|
||||
samlLabel: 'Test SAML',
|
||||
samlImageUrl: 'http://test-server.com',
|
||||
registrationEnabled: true,
|
||||
emailLoginEnabled: true,
|
||||
socialLoginEnabled: true,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const mockStartupConfig = {
|
|||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord'],
|
||||
socialLogins: ['google', 'facebook', 'openid', 'github', 'discord', 'saml'],
|
||||
discordLoginEnabled: true,
|
||||
facebookLoginEnabled: true,
|
||||
githubLoginEnabled: true,
|
||||
|
|
@ -25,6 +25,9 @@ const mockStartupConfig = {
|
|||
openidLoginEnabled: true,
|
||||
openidLabel: 'Test OpenID',
|
||||
openidImageUrl: 'http://test-server.com',
|
||||
samlLoginEnabled: true,
|
||||
samlLabel: 'Test SAML',
|
||||
samlImageUrl: 'http://test-server.com',
|
||||
registrationEnabled: true,
|
||||
socialLoginEnabled: true,
|
||||
serverDomain: 'mock-server',
|
||||
|
|
@ -146,6 +149,11 @@ test('renders registration form', () => {
|
|||
'href',
|
||||
'mock-server/oauth/discord',
|
||||
);
|
||||
expect(getByRole('link', { name: /Test SAML/i })).toBeInTheDocument();
|
||||
expect(getByRole('link', { name: /Test SAML/i })).toHaveAttribute(
|
||||
'href',
|
||||
'mock-server/oauth/saml',
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { BadgeItem } from '~/common';
|
|||
import { useChatBadges } from '~/hooks';
|
||||
import { Badge } from '~/components/ui';
|
||||
import MCPSelect from './MCPSelect';
|
||||
import WebSearch from './WebSearch';
|
||||
import store from '~/store';
|
||||
|
||||
interface BadgeRowProps {
|
||||
|
|
@ -354,6 +355,7 @@ function BadgeRow({
|
|||
)}
|
||||
{showEphemeralBadges === true && (
|
||||
<>
|
||||
<WebSearch conversationId={conversationId} />
|
||||
<CodeInterpreter conversationId={conversationId} />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -206,8 +206,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
<form
|
||||
onSubmit={methods.handleSubmit(submitMessage)}
|
||||
className={cn(
|
||||
'mx-auto flex flex-row gap-3 sm:px-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
|
||||
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
centerFormOnLanding &&
|
||||
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
||||
!isSubmitting &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import React, { memo, useMemo, useCallback, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { TerminalSquareIcon } from 'lucide-react';
|
||||
import {
|
||||
|
|
@ -32,6 +32,7 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
|||
};
|
||||
|
||||
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
|
||||
const triggerRef = useRef<HTMLInputElement>(null);
|
||||
const localize = useLocalize();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
|
|
@ -73,9 +74,10 @@ function CodeInterpreter({ conversationId }: { conversationId?: string | null })
|
|||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
|
||||
if (!isAuthenticated) {
|
||||
setIsDialogOpen(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setRunCode(isChecked);
|
||||
|
|
@ -95,6 +97,7 @@ function CodeInterpreter({ conversationId }: { conversationId?: string | null })
|
|||
return (
|
||||
<>
|
||||
<CheckboxButton
|
||||
ref={triggerRef}
|
||||
className="max-w-fit"
|
||||
defaultChecked={runCode}
|
||||
setValue={debouncedChange}
|
||||
|
|
@ -105,6 +108,7 @@ function CodeInterpreter({ conversationId }: { conversationId?: string | null })
|
|||
<ApiKeyDialog
|
||||
onSubmit={onSubmit}
|
||||
isOpen={isDialogOpen}
|
||||
triggerRef={triggerRef}
|
||||
register={methods.register}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useLocalize, useFileHandling } from '~/hooks';
|
||||
|
|
@ -22,6 +22,10 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
|
|||
overrideEndpoint: EModelEndpoint.agents,
|
||||
});
|
||||
|
||||
/** TODO: Ephemeral Agent Capabilities
|
||||
* Allow defining agent capabilities on a per-endpoint basis
|
||||
* Use definition for agents endpoint for ephemeral agents
|
||||
* */
|
||||
const capabilities = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
|
||||
[endpointsConfig],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileIcon from '~/components/svg/Files/FileIcon';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import { Spinner } from '~/components';
|
||||
import SourceIcon from './SourceIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const FilePreview = ({
|
||||
|
|
@ -19,28 +18,15 @@ const FilePreview = ({
|
|||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const radius = 55;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = useProgress(
|
||||
file?.['progress'] ?? 1,
|
||||
0.001,
|
||||
(file as ExtendedFile | undefined)?.size ?? 1,
|
||||
);
|
||||
|
||||
const offset = circumference - progress * circumference;
|
||||
const circleCSSProperties = {
|
||||
transition: 'stroke-dashoffset 0.5s linear',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative size-10 shrink-0 overflow-hidden rounded-xl', className)}>
|
||||
<FileIcon file={file} fileType={fileType} />
|
||||
<SourceIcon source={file?.source} isCodeFile={!!file?.['metadata']?.fileIdentifier} />
|
||||
{progress < 1 && (
|
||||
<ProgressCircle
|
||||
circumference={circumference}
|
||||
offset={offset}
|
||||
circleCSSProperties={circleCSSProperties}
|
||||
{typeof file?.['progress'] === 'number' && file?.['progress'] < 1 && (
|
||||
<Spinner
|
||||
bgOpacity={0.2}
|
||||
color="white"
|
||||
className="absolute inset-0 m-2.5 flex items-center justify-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,20 +75,20 @@ export default function FileRow({
|
|||
const renderFiles = () => {
|
||||
const rowStyle = isRTL
|
||||
? {
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={rowStyle as React.CSSProperties}>
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ const ImagePreview = ({
|
|||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className={cn('w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto')}
|
||||
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
|
||||
disableScroll={false}
|
||||
>
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
|
|
@ -36,6 +37,7 @@ import { TrashIcon, Spinner } from '~/components/svg';
|
|||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
|
@ -60,12 +62,14 @@ type Style = {
|
|||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const setFiles = useSetRecoilState(store.filesByIndex(0));
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
|
|
@ -96,7 +100,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
const filesToDelete = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
deleteFiles({ files: filesToDelete as TFile[], setFiles });
|
||||
setRowSelection({});
|
||||
}}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
|
|
@ -218,13 +222,10 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
<div className="flex items-center justify-end gap-2 py-4">
|
||||
<div className="ml-2 flex-1 truncate text-xs text-muted-foreground sm:ml-4 sm:text-sm">
|
||||
<span className="hidden sm:inline">
|
||||
{localize(
|
||||
'com_files_number_selected',
|
||||
{
|
||||
0: `${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
1: `${table.getFilteredRowModel().rows.length}`,
|
||||
},
|
||||
)}
|
||||
{localize('com_files_number_selected', {
|
||||
0: `${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
1: `${table.getFilteredRowModel().rows.length}`,
|
||||
})}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{`${table.getFilteredSelectedRowModel().rows.length}/${
|
||||
|
|
|
|||
|
|
@ -79,19 +79,19 @@ export default function HeaderOptions({
|
|||
{!noSettings[endpoint] &&
|
||||
interfaceConfig?.parameters === true &&
|
||||
paramEndpoint === false && (
|
||||
<TooltipAnchor
|
||||
id="parameters-button"
|
||||
aria-label={localize('com_ui_model_parameters')}
|
||||
description={localize('com_ui_model_parameters')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={triggerAdvancedMode}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
<TooltipAnchor
|
||||
id="parameters-button"
|
||||
aria-label={localize('com_ui_model_parameters')}
|
||||
description={localize('com_ui_model_parameters')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={triggerAdvancedMode}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
</div>
|
||||
{interfaceConfig?.parameters === true && paramEndpoint === false && (
|
||||
<OptionsPopover
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function PopoverButtons({
|
|||
buttonClass?: string;
|
||||
iconClass?: string;
|
||||
endpoint?: EModelEndpoint | string;
|
||||
endpointType?: EModelEndpoint | string;
|
||||
endpointType?: EModelEndpoint | string | null;
|
||||
model?: string | null;
|
||||
}) {
|
||||
const {
|
||||
|
|
|
|||
123
client/src/components/Chat/Input/WebSearch.tsx
Normal file
123
client/src/components/Chat/Input/WebSearch.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import React, { memo, useRef, useMemo, useCallback } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
Tools,
|
||||
AuthType,
|
||||
Constants,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
LocalStorageKeys,
|
||||
} from 'librechat-data-provider';
|
||||
import ApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog';
|
||||
import { useLocalize, useHasAccess, useSearchApiKeyForm } from '~/hooks';
|
||||
import CheckboxButton from '~/components/ui/CheckboxButton';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue === 'true' && value === false) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null && value !== '' && value !== false;
|
||||
};
|
||||
|
||||
function WebSearch({ conversationId }: { conversationId?: string | null }) {
|
||||
const triggerRef = useRef<HTMLInputElement>(null);
|
||||
const localize = useLocalize();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
const canUseWebSearch = useHasAccess({
|
||||
permissionType: PermissionTypes.WEB_SEARCH,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const isWebSearchToggleEnabled = useMemo(() => {
|
||||
return ephemeralAgent?.web_search ?? false;
|
||||
}, [ephemeralAgent?.web_search]);
|
||||
|
||||
const { data } = useVerifyAgentToolAuth(
|
||||
{ toolId: Tools.web_search },
|
||||
{
|
||||
retry: 1,
|
||||
},
|
||||
);
|
||||
const authTypes = useMemo(() => data?.authTypes ?? [], [data?.authTypes]);
|
||||
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
|
||||
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
|
||||
useSearchApiKeyForm({});
|
||||
|
||||
const setValue = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
web_search: isChecked,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [webSearch, setWebSearch] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`,
|
||||
isWebSearchToggleEnabled,
|
||||
setValue,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
|
||||
if (!isAuthenticated) {
|
||||
setIsDialogOpen(true);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setWebSearch(isChecked);
|
||||
},
|
||||
[setWebSearch, setIsDialogOpen, isAuthenticated],
|
||||
);
|
||||
|
||||
const debouncedChange = useMemo(
|
||||
() => debounce(handleChange, 50, { leading: true }),
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
if (!canUseWebSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckboxButton
|
||||
ref={triggerRef}
|
||||
className="max-w-fit"
|
||||
defaultChecked={webSearch}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_ui_search')}
|
||||
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
icon={<Globe className="icon-md" />}
|
||||
/>
|
||||
<ApiKeyDialog
|
||||
onSubmit={onSubmit}
|
||||
authTypes={authTypes}
|
||||
isOpen={isDialogOpen}
|
||||
triggerRef={triggerRef}
|
||||
register={methods.register}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
handleSubmit={methods.handleSubmit}
|
||||
isToolAuthenticated={isAuthenticated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(WebSearch);
|
||||
|
|
@ -44,7 +44,7 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
{...props}
|
||||
className={cn(
|
||||
!parent &&
|
||||
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white',
|
||||
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary',
|
||||
menuStore.useState('open')
|
||||
? 'bg-surface-tertiary hover:bg-surface-tertiary'
|
||||
: 'bg-surface-secondary hover:bg-surface-tertiary',
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function ModelSelectorContent() {
|
|||
|
||||
const trigger = (
|
||||
<button
|
||||
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary"
|
||||
aria-label={localize('com_ui_select_model')}
|
||||
>
|
||||
{selectedIcon && React.isValidElement(selectedIcon) && (
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ import {
|
|||
mapEndpoints,
|
||||
getConvoSwitchLogic,
|
||||
} from '~/utils';
|
||||
import { Input, Label, SelectDropDown, Dialog, DialogClose, DialogButton } from '~/components';
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
SelectDropDown,
|
||||
OGDialogContent,
|
||||
} from '~/components';
|
||||
import { useSetIndexOptions, useLocalize, useDebouncedInput } from '~/hooks';
|
||||
import PopoverButtons from '~/components/Chat/Input/PopoverButtons';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { EndpointSettings } from '~/components/Endpoints';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
|
@ -117,111 +123,107 @@ const EditPresetDialog = ({
|
|||
[queryClient, setOptions],
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setPresetModalVisible(open);
|
||||
if (!open) {
|
||||
setPreset(null);
|
||||
}
|
||||
};
|
||||
|
||||
const { endpoint: _endpoint, endpointType, model } = preset || {};
|
||||
const endpoint = _endpoint ?? '';
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
} else if (isAgentsEndpoint(endpoint)) {
|
||||
}
|
||||
|
||||
if (isAgentsEndpoint(endpoint)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={presetModalVisible}
|
||||
onOpenChange={(open) => {
|
||||
setPresetModalVisible(open);
|
||||
if (!open) {
|
||||
setPreset(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTemplate
|
||||
title={`${localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${
|
||||
preset?.title
|
||||
}`}
|
||||
className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[720px] md:w-[750px] md:overflow-y-hidden lg:w-[950px] xl:h-[720px]"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2 md:h-[550px] md:overflow-y-auto">
|
||||
<div className="grid w-full">
|
||||
<div className="col-span-4 flex flex-col items-start justify-start gap-6 pb-4 md:flex-row">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="preset-name" className="mb-1 text-left text-sm font-medium">
|
||||
{localize('com_endpoint_preset_name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
value={(title as string | undefined) ?? ''}
|
||||
onChange={onTitleChange}
|
||||
placeholder={localize('com_endpoint_set_custom_name')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="endpoint" className="mb-1 text-left text-sm font-medium">
|
||||
{localize('com_endpoint')}
|
||||
</Label>
|
||||
<SelectDropDown
|
||||
value={endpoint || ''}
|
||||
setValue={switchEndpoint}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
searchPlaceholder={localize('com_endpoint_search')}
|
||||
availableValues={availableEndpoints}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-start justify-between gap-4 sm:col-span-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label
|
||||
htmlFor="endpoint"
|
||||
className="mb-1 hidden text-left text-sm font-medium sm:block"
|
||||
>
|
||||
{'ㅤ'}
|
||||
</Label>
|
||||
<PopoverButtons
|
||||
buttonClass="ml-0 w-full border border-border-medium p-2 h-[40px] justify-center mt-0"
|
||||
iconClass="hidden lg:block w-4 "
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
model={model}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OGDialog open={presetModalVisible} onOpenChange={handleOpenChange}>
|
||||
<OGDialogContent className="h-[100dvh] max-h-[100dvh] w-full max-w-full overflow-y-auto bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:h-auto md:max-h-[90vh] md:max-w-[75vw] md:rounded-lg lg:max-w-[950px]">
|
||||
<OGDialogTitle>
|
||||
{`${localize('com_ui_edit')} ${localize('com_endpoint_preset')} - ${preset?.title}`}
|
||||
</OGDialogTitle>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 px-1 pb-4 md:gap-4">
|
||||
{/* Header section with preset name and endpoint */}
|
||||
<div className="grid w-full gap-2 md:grid-cols-2 md:gap-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="preset-name" className="mb-1 text-left text-sm font-medium">
|
||||
{localize('com_endpoint_preset_name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
value={(title as string | undefined) ?? ''}
|
||||
onChange={onTitleChange}
|
||||
placeholder={localize('com_endpoint_set_custom_name')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-4 w-full border-t border-border-medium" />
|
||||
<div className="w-full p-0">
|
||||
<EndpointSettings
|
||||
conversation={preset}
|
||||
setOption={setOption}
|
||||
isPreset={true}
|
||||
className="h-full text-text-primary md:mb-4 md:h-[440px]"
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="endpoint" className="mb-1 text-left text-sm font-medium">
|
||||
{localize('com_endpoint')}
|
||||
</Label>
|
||||
<SelectDropDown
|
||||
value={endpoint || ''}
|
||||
setValue={switchEndpoint}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
searchPlaceholder={localize('com_endpoint_search')}
|
||||
availableValues={availableEndpoints}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<div className="mb-6 md:mb-2">
|
||||
<DialogButton
|
||||
|
||||
{/* PopoverButtons section */}
|
||||
<div className="flex w-full">
|
||||
<PopoverButtons
|
||||
buttonClass="ml-0 w-full border border-border-medium p-2 h-[40px] justify-center mt-0"
|
||||
iconClass="hidden lg:block w-4"
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
model={model}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-full border-t border-border-medium" />
|
||||
|
||||
{/* Settings section */}
|
||||
<div className="w-full flex-1">
|
||||
<EndpointSettings
|
||||
conversation={preset}
|
||||
setOption={setOption}
|
||||
isPreset={true}
|
||||
className="text-text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-2 border-t border-border-medium pt-2 md:pt-4">
|
||||
<button
|
||||
onClick={exportPreset}
|
||||
className="border-gray-100 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 md:px-4"
|
||||
>
|
||||
{localize('com_endpoint_export')}
|
||||
</DialogButton>
|
||||
<DialogClose
|
||||
</button>
|
||||
<button
|
||||
onClick={submitPreset}
|
||||
className="ml-2 bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600"
|
||||
className="rounded-md bg-green-500 px-3 py-2 text-sm font-medium text-white hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 md:px-4"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</DialogClose>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
footerClassName="bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,9 @@
|
|||
import { X } from 'lucide-react';
|
||||
|
||||
export default function CancelledIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-gray-300 text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.32256 1.48447C7.59011 1.16827 7.55068 0.695034 7.23447 0.427476C6.91827 0.159918 6.44503 0.199354 6.17748 0.515559L4.00002 3.08892L1.82256 0.515559C1.555 0.199354 1.08176 0.159918 0.765559 0.427476C0.449355 0.695034 0.409918 1.16827 0.677476 1.48447L3.01755 4.25002L0.677476 7.01556C0.409918 7.33176 0.449354 7.805 0.765559 8.07256C1.08176 8.34011 1.555 8.30068 1.82256 7.98447L4.00002 5.41111L6.17748 7.98447C6.44503 8.30068 6.91827 8.34011 7.23447 8.07256C7.55068 7.805 7.59011 7.33176 7.32256 7.01556L4.98248 4.25002L7.32256 1.48447Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary">
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CodeInProgress } from './Parts/CodeProgress';
|
||||
import { useProgress, useLocalize } from '~/hooks';
|
||||
import ProgressText from './ProgressText';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import store from '~/store';
|
||||
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
export default function CodeAnalyze({
|
||||
initialProgress = 0.1,
|
||||
code,
|
||||
outputs = [],
|
||||
isSubmitting,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
code: string;
|
||||
outputs: Record<string, unknown>[];
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const progress = useProgress(initialProgress);
|
||||
const showAnalysisCode = useRecoilValue(store.showCode);
|
||||
const [showCode, setShowCode] = useState(showAnalysisCode);
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
const logs = outputs.reduce((acc, output) => {
|
||||
if (output['logs']) {
|
||||
|
|
@ -37,19 +29,6 @@ export default function CodeAnalyze({
|
|||
return (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress
|
||||
offset={offset}
|
||||
radius={radius}
|
||||
progress={progress}
|
||||
isSubmitting={isSubmitting}
|
||||
circumference={circumference}
|
||||
/>
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { useRecoilValue, useRecoilState } from 'recoil';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts, TAttachment, Agents } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
SearchResultData,
|
||||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import EditTextPart from './Parts/EditTextPart';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { MessageContext } from '~/Providers';
|
||||
import { EditTextPart } from './Parts';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
|
|
@ -15,6 +21,7 @@ type ContentPartsProps = {
|
|||
messageId: string;
|
||||
conversationId?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
isCreatedByUser: boolean;
|
||||
isLast: boolean;
|
||||
isSubmitting: boolean;
|
||||
|
|
@ -33,6 +40,7 @@ const ContentParts = memo(
|
|||
messageId,
|
||||
conversationId,
|
||||
attachments,
|
||||
searchResults,
|
||||
isCreatedByUser,
|
||||
isLast,
|
||||
isSubmitting,
|
||||
|
|
@ -44,11 +52,7 @@ const ContentParts = memo(
|
|||
const localize = useLocalize();
|
||||
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap);
|
||||
const attachmentMap = useMemo(
|
||||
() => mapAttachments(attachments ?? messageAttachmentsMap[messageId] ?? []),
|
||||
[attachments, messageAttachmentsMap, messageId],
|
||||
);
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
const hasReasoningParts = useMemo(() => {
|
||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||
|
|
@ -98,53 +102,56 @@ const ContentParts = memo(
|
|||
|
||||
return (
|
||||
<>
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{content
|
||||
.filter((part) => part)
|
||||
.map((part, idx) => {
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<Sources />
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{content
|
||||
.filter((part) => part)
|
||||
.map((part, idx) => {
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
isExpanded,
|
||||
nextType: content[idx + 1]?.type,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,42 +1,42 @@
|
|||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X, ArrowDownToLine } from 'lucide-react';
|
||||
import { Button, OGDialog, OGDialogContent } from '~/components';
|
||||
|
||||
export default function DialogImage({ src = '', width = 1920, height = 1080 }) {
|
||||
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage }) {
|
||||
return (
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className="radix-state-open:animate-show fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/90 dark:bg-black/80"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="h-full w-full rounded-none bg-transparent"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-surface-primary opacity-95 z-50"
|
||||
>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
|
||||
type="button"
|
||||
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Content
|
||||
className="radix-state-open:animate-contentShow relative max-h-[85vh] max-w-[90vw] shadow-xl focus:outline-none"
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'auto', aspectRatio: height > width ? 1 / 1.75 : 1.75 / 1 }}
|
||||
>
|
||||
<img src={src} alt="Uploaded image" className="h-full w-full object-contain" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Overlay>
|
||||
</Dialog.Portal>
|
||||
<X className="size-6" />
|
||||
</Button>
|
||||
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
|
||||
<ArrowDownToLine className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="w-11/12 overflow-x-auto rounded-none bg-transparent p-4 shadow-none sm:w-auto"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-transparent"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="Uploaded image"
|
||||
className="max-w-screen h-full max-h-screen w-full object-contain"
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
export default function FinishedIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
className="flex size-4 items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
data-projection-id="162"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="none" width="8" height="8">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
|
|
|||
|
|
@ -1,27 +1,8 @@
|
|||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const scaleImage = ({
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
containerRef,
|
||||
}: {
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}) => {
|
||||
const containerWidth = containerRef.current?.offsetWidth ?? 0;
|
||||
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
|
||||
return { width: 'auto', height: 'auto' };
|
||||
}
|
||||
const aspectRatio = originalWidth / originalHeight;
|
||||
const scaledWidth = Math.min(containerWidth, originalWidth);
|
||||
const scaledHeight = scaledWidth / aspectRatio;
|
||||
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
|
||||
};
|
||||
import { Skeleton } from '~/components';
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
|
|
@ -41,6 +22,7 @@ const Image = ({
|
|||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -56,39 +38,63 @@ const Image = ({
|
|||
[placeholderDimensions, height, width],
|
||||
);
|
||||
|
||||
const downloadImage = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = imagePath;
|
||||
link.download = altText;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<div ref={containerRef}>
|
||||
<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,
|
||||
)}
|
||||
<div ref={containerRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View ${altText} in dialog`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
width: scaledWidth,
|
||||
height: 'auto',
|
||||
color: 'transparent',
|
||||
}}
|
||||
placeholder={<div style={{ width: scaledWidth, height: scaledHeight }} />}
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
width: `${scaledWidth}`,
|
||||
height: 'auto',
|
||||
color: 'transparent',
|
||||
display: 'block',
|
||||
}}
|
||||
placeholder={
|
||||
<Skeleton
|
||||
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
downloadImage={downloadImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isLoaded && <DialogImage src={imagePath} height={height} width={width} />}
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ import {
|
|||
CodeBlockProvider,
|
||||
useCodeBlockContext,
|
||||
} from '~/Providers';
|
||||
import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation';
|
||||
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 { unicodeCitation } from '~/components/Web';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import store from '~/store';
|
||||
|
|
@ -197,16 +199,14 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
|||
[],
|
||||
);
|
||||
|
||||
const remarkPlugins: Pluggable[] = useMemo(
|
||||
() => [
|
||||
supersub,
|
||||
remarkGfm,
|
||||
remarkDirective,
|
||||
artifactPlugin,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
],
|
||||
[],
|
||||
);
|
||||
const remarkPlugins: Pluggable[] = [
|
||||
supersub,
|
||||
remarkGfm,
|
||||
remarkDirective,
|
||||
artifactPlugin,
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
unicodeCitation,
|
||||
];
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
|
|
@ -232,6 +232,9 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
|||
a,
|
||||
p,
|
||||
artifact: Artifact,
|
||||
citation: Citation,
|
||||
'highlighted-text': HighlightedText,
|
||||
'composite-citation': CompositeCitation,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,14 @@ import {
|
|||
} from 'librechat-data-provider';
|
||||
import { memo } from 'react';
|
||||
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
|
||||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||
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 WebSearch from './WebSearch';
|
||||
import ToolCall from './ToolCall';
|
||||
import ImageGen from './ImageGen';
|
||||
import Text from './Parts/Text';
|
||||
import Image from './Image';
|
||||
|
||||
type PartProps = {
|
||||
|
|
@ -92,10 +89,33 @@ const Part = memo(
|
|||
return (
|
||||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
isToolCall &&
|
||||
(toolCall.name === 'image_gen_oai' || toolCall.name === 'image_edit_oai')
|
||||
) {
|
||||
return (
|
||||
<OpenAIImageGen
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
toolName={toolCall.name}
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name === Tools.web_search) {
|
||||
return (
|
||||
<WebSearch
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
attachments={attachments}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall) {
|
||||
|
|
@ -118,7 +138,6 @@ const Part = memo(
|
|||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
|
|
|
|||
|
|
@ -1,25 +1,82 @@
|
|||
import { memo } from 'react';
|
||||
import { imageExtRegex } from 'librechat-data-provider';
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { imageExtRegex, Tools } from 'librechat-data-provider';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import { useAttachmentLink } from './LogLink';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { handleDownload } = useAttachmentLink({
|
||||
href: attachment.filepath,
|
||||
filename: attachment.filename,
|
||||
href: attachment.filepath ?? '',
|
||||
filename: attachment.filename ?? '',
|
||||
});
|
||||
const extension = attachment.filename.split('.').pop();
|
||||
const extension = attachment.filename?.split('.').pop();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!attachment.filepath) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'file-attachment-container',
|
||||
'transition-all duration-300 ease-out',
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
const timer = setTimeout(() => setIsLoaded(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [attachment]);
|
||||
|
||||
return (
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="hover:cursor-pointer hover:bg-surface-secondary active:bg-surface-secondary focus:bg-surface-secondary hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'image-attachment-container',
|
||||
'transition-all duration-500 ease-out',
|
||||
isLoaded ? 'scale-100 opacity-100' : 'scale-[0.98] opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath ?? ''}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -27,20 +84,63 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
|
|||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
if (attachment.type === Tools.web_search) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage =
|
||||
imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null;
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
return <ImageAttachment attachment={attachment} />;
|
||||
} else if (!attachment.filepath) {
|
||||
return null;
|
||||
}
|
||||
return <FileAttachment attachment={attachment} />;
|
||||
}
|
||||
|
||||
export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }) {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileAttachments: TAttachment[] = [];
|
||||
const imageAttachments: TAttachment[] = [];
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage =
|
||||
imageExtRegex.test(attachment.filename) &&
|
||||
width != null &&
|
||||
height != null &&
|
||||
filepath != null;
|
||||
|
||||
if (isImage) {
|
||||
imageAttachments.push(attachment);
|
||||
} else if (attachment.type !== Tools.web_search) {
|
||||
fileAttachments.push(attachment);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="my-2 flex flex-wrap items-center gap-2.5">
|
||||
{fileAttachments.map((attachment, index) =>
|
||||
attachment.filepath ? (
|
||||
<FileAttachment attachment={attachment} key={`file-${index}`} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap items-center">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<ImageAttachment attachment={attachment} key={`image-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
import ProgressCircle from '~/components/Chat/Messages/Content/ProgressCircle';
|
||||
import CancelledIcon from '~/components/Chat/Messages/Content/CancelledIcon';
|
||||
|
||||
export const CodeInProgress = ({
|
||||
offset,
|
||||
circumference,
|
||||
radius,
|
||||
isSubmitting,
|
||||
progress,
|
||||
}: {
|
||||
progress: number;
|
||||
offset: number;
|
||||
circumference: number;
|
||||
radius: number;
|
||||
isSubmitting: boolean;
|
||||
}) => {
|
||||
if (progress < 1 && !isSubmitting) {
|
||||
return <CancelledIcon />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="77"
|
||||
>
|
||||
<div className="absolute bottom-[1.5px] right-[1.5px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_11">
|
||||
<rect width="20" height="20" x="0" y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_11)">
|
||||
<g
|
||||
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
|
||||
className="slide-from-left"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,7.026679992675781,8.834091186523438)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.201031"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
|
||||
className="slide-to-down"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,11.79640007019043,13.512199401855469)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.100515"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
|
||||
import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import { useProgress, useLocalize } from '~/hooks';
|
||||
import { CodeInProgress } from './CodeProgress';
|
||||
import Attachment from './Attachment';
|
||||
import { AttachmentGroup } from './Attachment';
|
||||
import Stdout from './Stdout';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface ParsedArgs {
|
||||
|
|
@ -45,46 +44,101 @@ export function useParseArgs(args: string): ParsedArgs {
|
|||
}, [args]);
|
||||
}
|
||||
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
export default function ExecuteCode({
|
||||
initialProgress = 0.1,
|
||||
args,
|
||||
output = '',
|
||||
isSubmitting,
|
||||
attachments,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
args: string;
|
||||
output?: string;
|
||||
isSubmitting: boolean;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const showAnalysisCode = useRecoilValue(store.showCode);
|
||||
const [showCode, setShowCode] = useState(showAnalysisCode);
|
||||
const codeContentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const hasOutput = output.length > 0;
|
||||
const outputRef = useRef<string>(output);
|
||||
const prevShowCodeRef = useRef<boolean>(showCode);
|
||||
|
||||
const { lang, code } = useParseArgs(args);
|
||||
const progress = useProgress(initialProgress);
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
useEffect(() => {
|
||||
if (output !== outputRef.current) {
|
||||
outputRef.current = output;
|
||||
|
||||
if (showCode && codeContentRef.current) {
|
||||
setTimeout(() => {
|
||||
if (codeContentRef.current) {
|
||||
const newHeight = codeContentRef.current.scrollHeight;
|
||||
setContentHeight(newHeight);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}, [output, showCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCode !== prevShowCodeRef.current) {
|
||||
prevShowCodeRef.current = showCode;
|
||||
|
||||
if (showCode && codeContentRef.current) {
|
||||
setIsAnimating(true);
|
||||
requestAnimationFrame(() => {
|
||||
if (codeContentRef.current) {
|
||||
const height = codeContentRef.current.scrollHeight;
|
||||
setContentHeight(height);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
} else if (!showCode) {
|
||||
setIsAnimating(true);
|
||||
setContentHeight(0);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [showCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeContentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (showCode && !isAnimating) {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === codeContentRef.current) {
|
||||
setContentHeight(entry.contentRect.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(codeContentRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showCode, isAnimating]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress
|
||||
offset={offset}
|
||||
radius={radius}
|
||||
progress={progress}
|
||||
isSubmitting={isSubmitting}
|
||||
circumference={circumference}
|
||||
/>
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
|
|
@ -94,31 +148,71 @@ export default function ExecuteCode({
|
|||
isExpanded={showCode}
|
||||
/>
|
||||
</div>
|
||||
{showCode && (
|
||||
<div className="code-analyze-block mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
|
||||
<MarkdownLite
|
||||
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
|
||||
codeExecution={false}
|
||||
/>
|
||||
{output.length > 0 && (
|
||||
<div className="bg-gray-700 p-4 text-xs">
|
||||
<div
|
||||
className="prose flex flex-col-reverse text-white"
|
||||
style={{
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative mb-2"
|
||||
style={{
|
||||
height: showCode ? contentHeight : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transformOrigin: 'top',
|
||||
willChange: 'height, opacity',
|
||||
perspective: '1000px',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'code-analyze-block mt-0.5 overflow-hidden rounded-xl bg-surface-primary',
|
||||
showCode && 'shadow-lg',
|
||||
)}
|
||||
ref={codeContentRef}
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{showCode && (
|
||||
<div
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0)' : 'translateY(-4px)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<MarkdownLite
|
||||
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
|
||||
codeExecution={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasOutput && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-tertiary p-4 text-xs',
|
||||
showCode ? 'border-t border-surface-primary-contrast' : '',
|
||||
)}
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0)' : 'translateY(-6px)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s, opacity 0.45s cubic-bezier(0.19, 1, 0.22, 1) 0.05s',
|
||||
boxShadow: showCode ? '0 -1px 0 rgba(0,0,0,0.05)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="prose flex flex-col-reverse">
|
||||
<Stdout output={output} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2.5">
|
||||
{attachments?.map((attachment, index) => (
|
||||
<Attachment attachment={attachment} key={index} />
|
||||
))}
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import ProgressText from './ProgressText';
|
||||
import { PixelCard } from '~/components';
|
||||
import { scaleImage } from '~/utils';
|
||||
|
||||
export default function OpenAIImageGen({
|
||||
initialProgress = 0.1,
|
||||
isSubmitting,
|
||||
toolName,
|
||||
args: _args = '',
|
||||
output,
|
||||
attachments,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
toolName: string;
|
||||
args: string | Record<string, unknown>;
|
||||
output?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const [progress, setProgress] = useState(initialProgress);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const error =
|
||||
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
|
||||
|
||||
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
|
||||
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let quality: 'low' | 'medium' | 'high' = 'high';
|
||||
|
||||
try {
|
||||
const argsObj = typeof _args === 'string' ? JSON.parse(_args) : _args;
|
||||
|
||||
if (argsObj && typeof argsObj.size === 'string') {
|
||||
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
width = w;
|
||||
height = h;
|
||||
}
|
||||
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
|
||||
width = undefined;
|
||||
height = undefined;
|
||||
}
|
||||
|
||||
if (argsObj && typeof argsObj.quality === 'string') {
|
||||
const q = argsObj.quality.toLowerCase();
|
||||
if (q === 'low' || q === 'medium' || q === 'high') {
|
||||
quality = q;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
width = undefined;
|
||||
height = undefined;
|
||||
}
|
||||
|
||||
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
|
||||
const attachment = attachments?.[0];
|
||||
const {
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
filepath = null,
|
||||
filename = '',
|
||||
} = (attachment as TFile & TAttachmentMetadata) || {};
|
||||
|
||||
let origWidth = width ?? imageWidth;
|
||||
let origHeight = height ?? imageHeight;
|
||||
|
||||
if (origWidth === undefined || origHeight === undefined) {
|
||||
origWidth = 1024;
|
||||
origHeight = 1024;
|
||||
}
|
||||
|
||||
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateDimensions = useCallback(() => {
|
||||
if (origWidth && origHeight && containerRef.current) {
|
||||
const scaled = scaleImage({
|
||||
originalWidth: origWidth,
|
||||
originalHeight: origHeight,
|
||||
containerRef,
|
||||
});
|
||||
setDimensions(scaled);
|
||||
}
|
||||
}, [origWidth, origHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
setProgress(initialProgress);
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
let baseDuration = 20000;
|
||||
if (quality === 'low') {
|
||||
baseDuration = 10000;
|
||||
} else if (quality === 'high') {
|
||||
baseDuration = 50000;
|
||||
}
|
||||
// adding some jitter (±30% of base)
|
||||
const jitter = Math.floor(baseDuration * 0.3);
|
||||
const totalDuration = Math.floor(Math.random() * jitter) + baseDuration;
|
||||
const updateInterval = 200;
|
||||
const totalSteps = totalDuration / updateInterval;
|
||||
let currentStep = 0;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
currentStep++;
|
||||
|
||||
if (currentStep >= totalSteps) {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout);
|
||||
setProgress(0.9);
|
||||
} else {
|
||||
const progressRatio = currentStep / totalSteps;
|
||||
let mapRatio: number;
|
||||
if (progressRatio < 0.8) {
|
||||
mapRatio = Math.pow(progressRatio, 1.1);
|
||||
} else {
|
||||
const sub = (progressRatio - 0.8) / 0.2;
|
||||
mapRatio = 0.8 + (1 - Math.pow(1 - sub, 2)) * 0.2;
|
||||
}
|
||||
const scaledProgress = 0.1 + mapRatio * 0.8;
|
||||
|
||||
setProgress(scaledProgress);
|
||||
}
|
||||
}, updateInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialProgress, quality]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialProgress >= 1 || cancelled) {
|
||||
setProgress(initialProgress);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
}
|
||||
}, [initialProgress, cancelled]);
|
||||
|
||||
useEffect(() => {
|
||||
updateDimensions();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateDimensions();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [updateDimensions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
||||
</div>
|
||||
|
||||
{/* {showInfo && hasInfo && (
|
||||
<ToolCallInfo
|
||||
key="tool-call-info"
|
||||
input={args ?? ''}
|
||||
output={output}
|
||||
function_name={function_name}
|
||||
pendingAuth={authDomain.length > 0 && !cancelled && initialProgress < 1}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<div className="relative mb-2 flex w-full justify-start">
|
||||
<div ref={containerRef} className="w-full max-w-lg">
|
||||
{dimensions.width !== 'auto' && progress < 1 && (
|
||||
<PixelCard
|
||||
variant="default"
|
||||
progress={progress}
|
||||
randomness={0.6}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
altText={filename}
|
||||
imagePath={filepath ?? ''}
|
||||
width={Number(dimensions.width?.split('px')[0])}
|
||||
height={Number(dimensions.height?.split('px')[0])}
|
||||
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ProgressText({
|
||||
progress,
|
||||
error,
|
||||
toolName = 'image_gen_oai',
|
||||
}: {
|
||||
progress: number;
|
||||
error?: boolean;
|
||||
toolName: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getText = () => {
|
||||
if (error) {
|
||||
return localize('com_ui_error');
|
||||
}
|
||||
|
||||
if (toolName === 'image_edit_oai') {
|
||||
if (progress >= 1) {
|
||||
return localize('com_ui_image_edited');
|
||||
}
|
||||
if (progress >= 0.7) {
|
||||
return localize('com_ui_final_touch');
|
||||
}
|
||||
if (progress >= 0.5) {
|
||||
return localize('com_ui_adding_details');
|
||||
}
|
||||
if (progress >= 0.3) {
|
||||
return localize('com_ui_edit_editing_image');
|
||||
}
|
||||
return localize('com_ui_getting_started');
|
||||
}
|
||||
|
||||
if (progress >= 1) {
|
||||
return localize('com_ui_image_created');
|
||||
}
|
||||
if (progress >= 0.7) {
|
||||
return localize('com_ui_final_touch');
|
||||
}
|
||||
if (progress >= 0.5) {
|
||||
return localize('com_ui_adding_details');
|
||||
}
|
||||
if (progress >= 0.3) {
|
||||
return localize('com_ui_creating_image');
|
||||
}
|
||||
return localize('com_ui_getting_started');
|
||||
};
|
||||
|
||||
const text = getText();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'progress-text-content pointer-events-none absolute left-0 top-0 inline-flex w-full items-center gap-2 overflow-visible whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
<span className={`font-medium ${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as OpenAIImageGen } from './OpenAIImageGen';
|
||||
|
|
@ -17,7 +17,7 @@ const Stdout: React.FC<StdoutProps> = ({ output = '' }) => {
|
|||
return (
|
||||
processedContent && (
|
||||
<pre className="shrink-0">
|
||||
<div>{processedContent}</div>
|
||||
<div className="text-text-primary">{processedContent}</div>
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
10
client/src/components/Chat/Messages/Content/Parts/index.ts
Normal file
10
client/src/components/Chat/Messages/Content/Parts/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export * from './Attachment';
|
||||
export * from './OpenAIImageGen';
|
||||
|
||||
export { default as Text } from './Text';
|
||||
export { default as Reasoning } from './Reasoning';
|
||||
export { default as EmptyText } from './EmptyText';
|
||||
export { default as LogContent } from './LogContent';
|
||||
export { default as ExecuteCode } from './ExecuteCode';
|
||||
export { default as AgentUpdate } from './AgentUpdate';
|
||||
export { default as EditTextPart } from './EditTextPart';
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import CancelledIcon from './CancelledIcon';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import { Spinner } from '~/components';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const wrapperClass =
|
||||
|
|
@ -10,7 +14,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
|
|||
<div className={wrapperClass}>
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
|
|
@ -24,7 +28,7 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
|
|||
return (
|
||||
<div className={wrapperClass}>
|
||||
<div
|
||||
className="progress-text-content absolute left-0 top-0 line-clamp-1 overflow-visible"
|
||||
className="progress-text-content absolute left-0 top-0 overflow-visible whitespace-nowrap"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="78"
|
||||
>
|
||||
|
|
@ -43,6 +47,7 @@ export default function ProgressText({
|
|||
hasInput = true,
|
||||
popover = false,
|
||||
isExpanded = false,
|
||||
error = false,
|
||||
}: {
|
||||
progress: number;
|
||||
onClick?: () => void;
|
||||
|
|
@ -52,33 +57,28 @@ export default function ProgressText({
|
|||
hasInput?: boolean;
|
||||
popover?: boolean;
|
||||
isExpanded?: boolean;
|
||||
error?: boolean;
|
||||
}) {
|
||||
const text = progress < 1 ? (authText ?? inProgressText) : finishedText;
|
||||
return (
|
||||
<Wrapper popover={popover}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('inline-flex items-center gap-1', hasInput ? '' : 'pointer-events-none')}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center gap-2',
|
||||
hasInput ? '' : 'pointer-events-none',
|
||||
)}
|
||||
disabled={!hasInput}
|
||||
onClick={onClick}
|
||||
onClick={hasInput ? onClick : undefined}
|
||||
>
|
||||
{text}
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
className={isExpanded ? 'rotate-180' : 'rotate-0'}
|
||||
>
|
||||
<path
|
||||
className={hasInput ? '' : 'stroke-transparent'}
|
||||
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{progress < 1 ? <Spinner /> : error ? <CancelledIcon /> : <FinishedIcon />}
|
||||
<span className={`${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
|
||||
{hasInput &&
|
||||
(isExpanded ? (
|
||||
<ChevronUp className="size-4 translate-y-[1px]" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 translate-y-[1px]" />
|
||||
))}
|
||||
</button>
|
||||
</Wrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
import { Suspense, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { Agents, TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type {
|
||||
Agents,
|
||||
TMessage,
|
||||
TAttachment,
|
||||
SearchResultData,
|
||||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import { UnfinishedMessage } from './MessageContent';
|
||||
import { DelayedRender } from '~/components/ui';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { cn, mapAttachments } from '~/utils';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
const SearchContent = ({ message }: { message: TMessage }) => {
|
||||
const SearchContent = ({
|
||||
message,
|
||||
attachments,
|
||||
searchResults,
|
||||
}: {
|
||||
message: TMessage;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
}) => {
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const { messageId } = message;
|
||||
const messageAttachmentsMap = useRecoilValue(store.messageAttachmentsMap);
|
||||
const attachmentMap = useMemo(
|
||||
() => mapAttachments(message?.attachments ?? messageAttachmentsMap[messageId] ?? []),
|
||||
[message?.attachments, messageAttachmentsMap, messageId],
|
||||
);
|
||||
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<Sources />
|
||||
{message.content
|
||||
.filter((part: TMessageContentParts | undefined) => part)
|
||||
.map((part: TMessageContentParts | undefined, idx: number) => {
|
||||
|
|
@ -49,7 +63,7 @@ const SearchContent = ({ message }: { message: TMessage }) => {
|
|||
</DelayedRender>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
import { useMemo } from 'react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { ShieldCheck, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo, useState, useEffect, useRef, useLayoutEffect } from 'react';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import InProgressCall from './InProgressCall';
|
||||
import Attachment from './Parts/Attachment';
|
||||
import CancelledIcon from './CancelledIcon';
|
||||
import { useLocalize, useProgress } from '~/hooks';
|
||||
import { AttachmentGroup } from './Parts';
|
||||
import ToolCallInfo from './ToolCallInfo';
|
||||
import ProgressText from './ProgressText';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import ToolPopover from './ToolPopover';
|
||||
import WrenchIcon from './WrenchIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
import { Button } from '~/components';
|
||||
import { logger, cn } from '~/utils';
|
||||
|
||||
export default function ToolCall({
|
||||
initialProgress = 0.1,
|
||||
|
|
@ -37,11 +28,16 @@ export default function ToolCall({
|
|||
expires_at?: number;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const prevShowInfoRef = useRef<boolean>(showInfo);
|
||||
|
||||
const { function_name, domain, isMCPToolCall } = useMemo(() => {
|
||||
if (typeof name !== 'string') {
|
||||
return { function_name: '', domain: null, isMCPToolCall: false };
|
||||
}
|
||||
|
||||
if (name.includes(Constants.mcp_delimiter)) {
|
||||
const [func, server] = name.split(Constants.mcp_delimiter);
|
||||
return {
|
||||
|
|
@ -50,7 +46,6 @@ export default function ToolCall({
|
|||
isMCPToolCall: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [func, _domain] = name.includes(actionDelimiter)
|
||||
? name.split(actionDelimiter)
|
||||
: [name, ''];
|
||||
|
|
@ -68,7 +63,6 @@ export default function ToolCall({
|
|||
if (typeof _args === 'string') {
|
||||
return _args;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(_args, null, 2);
|
||||
} catch (e) {
|
||||
|
|
@ -98,42 +92,8 @@ export default function ToolCall({
|
|||
}
|
||||
}, [auth]);
|
||||
|
||||
const progress = useProgress(error === true ? 1 : initialProgress);
|
||||
const progress = useProgress(initialProgress);
|
||||
const cancelled = (!isSubmitting && progress < 1) || error === true;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (progress < 1 && authDomain.length > 0) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="849"
|
||||
>
|
||||
<div>
|
||||
<ShieldCheck />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (progress < 1) {
|
||||
return (
|
||||
<InProgressCall progress={progress} isSubmitting={isSubmitting} error={error}>
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="849"
|
||||
>
|
||||
<div>
|
||||
<WrenchIcon />
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
</InProgressCall>
|
||||
);
|
||||
}
|
||||
|
||||
return cancelled ? <CancelledIcon /> : <FinishedIcon />;
|
||||
};
|
||||
|
||||
const getFinishedText = () => {
|
||||
if (cancelled) {
|
||||
|
|
@ -148,51 +108,129 @@ export default function ToolCall({
|
|||
return localize('com_assistants_completed_function', { 0: function_name });
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (showInfo !== prevShowInfoRef.current) {
|
||||
prevShowInfoRef.current = showInfo;
|
||||
setIsAnimating(true);
|
||||
|
||||
if (showInfo && contentRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
if (contentRef.current) {
|
||||
const height = contentRef.current.scrollHeight;
|
||||
setContentHeight(height + 4);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setContentHeight(0);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (showInfo && !isAnimating) {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === contentRef.current) {
|
||||
setContentHeight(entry.contentRect.height + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(contentRef.current);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showInfo, isAnimating]);
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<div className="my-2.5 flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex w-full items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">{renderIcon()}</div>
|
||||
<ProgressText
|
||||
progress={cancelled ? 1 : progress}
|
||||
inProgressText={localize('com_assistants_running_action')}
|
||||
authText={
|
||||
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
|
||||
}
|
||||
finishedText={getFinishedText()}
|
||||
hasInput={hasInfo}
|
||||
popover={true}
|
||||
/>
|
||||
{hasInfo && (
|
||||
<ToolPopover
|
||||
input={args ?? ''}
|
||||
output={output}
|
||||
domain={authDomain || (domain ?? '')}
|
||||
function_name={function_name}
|
||||
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{auth != null && auth && progress < 1 && !cancelled && (
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<div className="mb-1 mt-2">
|
||||
<a
|
||||
className="inline-flex items-center justify-center gap-2 rounded-3xl bg-surface-tertiary px-4 py-2 text-sm font-medium hover:bg-surface-hover"
|
||||
href={auth}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{localize('com_ui_sign_in_to_domain', { 0: authDomain })}
|
||||
</a>
|
||||
</div>
|
||||
<p className="flex items-center text-xs text-text-secondary">
|
||||
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" />
|
||||
{localize('com_assistants_allow_sites_you_trust')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowInfo((prev) => !prev)}
|
||||
inProgressText={
|
||||
function_name
|
||||
? localize('com_assistants_running_var', { 0: function_name })
|
||||
: localize('com_assistants_running_action')
|
||||
}
|
||||
authText={
|
||||
!cancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined
|
||||
}
|
||||
finishedText={getFinishedText()}
|
||||
hasInput={hasInfo}
|
||||
isExpanded={showInfo}
|
||||
error={cancelled}
|
||||
/>
|
||||
</div>
|
||||
{attachments?.map((attachment, index) => <Attachment attachment={attachment} key={index} />)}
|
||||
</Popover.Root>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: showInfo ? contentHeight : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: showInfo ? 1 : 0,
|
||||
transformOrigin: 'top',
|
||||
willChange: 'height, opacity',
|
||||
perspective: '1000px',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-md',
|
||||
showInfo && 'shadow-lg',
|
||||
)}
|
||||
style={{
|
||||
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
|
||||
opacity: showInfo ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{showInfo && hasInfo && (
|
||||
<ToolCallInfo
|
||||
key="tool-call-info"
|
||||
input={args ?? ''}
|
||||
output={output}
|
||||
domain={authDomain || (domain ?? '')}
|
||||
function_name={function_name}
|
||||
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{auth != null && auth && progress < 1 && !cancelled && (
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<div className="mb-1 mt-2">
|
||||
<Button
|
||||
className="font-mediu inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm"
|
||||
variant="default"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => window.open(auth, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{localize('com_ui_sign_in_to_domain', { 0: authDomain })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="flex items-center text-xs text-text-warning">
|
||||
<TriangleAlert className="mr-1.5 inline-block h-4 w-4" />
|
||||
{localize('com_assistants_allow_sites_you_trust')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
74
client/src/components/Chat/Messages/Content/ToolCallInfo.tsx
Normal file
74
client/src/components/Chat/Messages/Content/ToolCallInfo.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-surface-tertiary p-2 text-xs text-text-primary"
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxHeight,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 whitespace-pre-wrap break-words" style={{ overflowWrap: 'break-word' }}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolCallInfo({
|
||||
input,
|
||||
output,
|
||||
domain,
|
||||
function_name,
|
||||
pendingAuth,
|
||||
}: {
|
||||
input: string;
|
||||
function_name: string;
|
||||
output?: string | null;
|
||||
domain?: string;
|
||||
pendingAuth?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const formatText = (text: string) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
let title =
|
||||
domain != null && domain
|
||||
? localize('com_assistants_domain_info', { 0: domain })
|
||||
: localize('com_assistants_function_use', { 0: function_name });
|
||||
if (pendingAuth === true) {
|
||||
title =
|
||||
domain != null && domain
|
||||
? localize('com_assistants_action_attempt', { 0: domain })
|
||||
: localize('com_assistants_attempt_info');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-2">
|
||||
<div style={{ opacity: 1 }}>
|
||||
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
|
||||
<div>
|
||||
<OptimizedCodeBlock text={formatText(input)} maxHeight={250} />
|
||||
</div>
|
||||
{output && (
|
||||
<>
|
||||
<div className="my-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_result')}
|
||||
</div>
|
||||
<div>
|
||||
<OptimizedCodeBlock text={formatText(output)} maxHeight={250} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import * as Popover from '@radix-ui/react-popover';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
||||
export default function ToolPopover({
|
||||
input,
|
||||
output,
|
||||
domain,
|
||||
function_name,
|
||||
pendingAuth,
|
||||
}: {
|
||||
input: string;
|
||||
function_name: string;
|
||||
output?: string | null;
|
||||
domain?: string;
|
||||
pendingAuth?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const formatText = (text: string) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
let title =
|
||||
domain != null && domain
|
||||
? localize('com_assistants_domain_info', { 0: domain })
|
||||
: localize('com_assistants_function_use', { 0: function_name });
|
||||
if (pendingAuth === true) {
|
||||
title =
|
||||
domain != null && domain
|
||||
? localize('com_assistants_action_attempt', { 0: domain })
|
||||
: localize('com_assistants_attempt_info');
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
alignOffset={-5}
|
||||
className="w-18 min-w-[180px] max-w-sm rounded-lg bg-surface-primary px-1"
|
||||
>
|
||||
<div tabIndex={-1}>
|
||||
<div className="bg-token-surface-primary max-w-sm rounded-md p-2 shadow-[0_0_24px_0_rgba(0,0,0,0.05),inset_0_0.5px_0_0_rgba(0,0,0,0.05),0_2px_8px_0_rgba(0,0,0,0.05)]">
|
||||
<div className="mb-2 text-sm font-medium text-text-primary">{title}</div>
|
||||
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md bg-surface-tertiary p-2">
|
||||
<code className="!whitespace-pre-wrap ">{formatText(input)}</code>
|
||||
</div>
|
||||
</div>
|
||||
{output != null && output && (
|
||||
<>
|
||||
<div className="mb-2 mt-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_result')}
|
||||
</div>
|
||||
<div className="bg-token-surface-secondary text-token-text-primary dark rounded-md text-xs">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md bg-surface-tertiary p-2">
|
||||
<code className="!whitespace-pre-wrap ">{formatText(output)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
}
|
||||
91
client/src/components/Chat/Messages/Content/WebSearch.tsx
Normal file
91
client/src/components/Chat/Messages/Content/WebSearch.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import { StackedFavicons } from '~/components/Web/Sources';
|
||||
import { useSearchContext } from '~/Providers';
|
||||
import ProgressText from './ProgressText';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type ProgressKeys =
|
||||
| 'com_ui_web_searching'
|
||||
| 'com_ui_web_searching_again'
|
||||
| 'com_ui_web_search_processing'
|
||||
| 'com_ui_web_search_reading';
|
||||
|
||||
export default function WebSearch({
|
||||
initialProgress: progress = 0.1,
|
||||
isSubmitting,
|
||||
isLast,
|
||||
output,
|
||||
}: {
|
||||
isLast?: boolean;
|
||||
isSubmitting: boolean;
|
||||
output?: string | null;
|
||||
initialProgress: number;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { searchResults } = useSearchContext();
|
||||
const error = typeof output === 'string' && output.toLowerCase().includes('error processing');
|
||||
const cancelled = (!isSubmitting && progress < 1) || error === true;
|
||||
|
||||
const complete = !isLast && progress === 1;
|
||||
const finalizing = isSubmitting && isLast && progress === 1;
|
||||
const processedSources = useMemo(() => {
|
||||
if (complete && !finalizing) {
|
||||
return [];
|
||||
}
|
||||
if (!searchResults) return [];
|
||||
const values = Object.values(searchResults);
|
||||
const result = values[values.length - 1];
|
||||
if (!result) return [];
|
||||
if (finalizing) {
|
||||
return [...(result.organic || []), ...(result.topStories || [])];
|
||||
}
|
||||
return [...(result.organic || []), ...(result.topStories || [])].filter(
|
||||
(source) => source.processed === true,
|
||||
);
|
||||
}, [searchResults, complete, finalizing]);
|
||||
const turns = useMemo(() => {
|
||||
if (!searchResults) return 0;
|
||||
return Object.values(searchResults).length;
|
||||
}, [searchResults]);
|
||||
|
||||
const clampedProgress = useMemo(() => {
|
||||
return Math.min(progress, 0.99);
|
||||
}, [progress]);
|
||||
|
||||
const showSources = processedSources.length > 0;
|
||||
const progressText = useMemo(() => {
|
||||
let text: ProgressKeys = turns > 1 ? 'com_ui_web_searching_again' : 'com_ui_web_searching';
|
||||
if (showSources) {
|
||||
text = 'com_ui_web_search_processing';
|
||||
}
|
||||
if (finalizing) {
|
||||
text = 'com_ui_web_search_reading';
|
||||
}
|
||||
return localize(text);
|
||||
}, [turns, localize, showSources, finalizing]);
|
||||
|
||||
if (complete || cancelled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
{showSources && (
|
||||
<div className="mr-2">
|
||||
<StackedFavicons sources={processedSources} start={-5} />
|
||||
</div>
|
||||
)}
|
||||
<ProgressText
|
||||
finishedText=""
|
||||
hasInput={false}
|
||||
error={cancelled}
|
||||
isExpanded={false}
|
||||
progress={clampedProgress}
|
||||
inProgressText={progressText}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ import React, { useMemo } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useMessageHelpers, useLocalize } from '~/hooks';
|
||||
import ContentParts from './Content/ContentParts';
|
||||
import SiblingSwitch from './SiblingSwitch';
|
||||
|
||||
|
|
@ -17,7 +17,10 @@ export default function Message(props: TMessageProps) {
|
|||
const localize = useLocalize();
|
||||
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
|
||||
props;
|
||||
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
messageId: message?.messageId,
|
||||
attachments: message?.attachments,
|
||||
});
|
||||
const {
|
||||
edit,
|
||||
index,
|
||||
|
|
@ -91,7 +94,7 @@ export default function Message(props: TMessageProps) {
|
|||
>
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<div
|
||||
id={messageId}
|
||||
id={messageId ?? ''}
|
||||
aria-label={`message-${message.depth}-${messageId}`}
|
||||
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
|
||||
>
|
||||
|
|
@ -116,10 +119,11 @@ export default function Message(props: TMessageProps) {
|
|||
isLast={isLast}
|
||||
enterEdit={enterEdit}
|
||||
siblingIdx={siblingIdx}
|
||||
messageId={message.messageId}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
searchResults={searchResults}
|
||||
messageId={message.messageId}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
attachments={message.attachments}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={message.content as Array<TMessageContentParts | undefined>}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import { useState } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessage, TAttachment, SearchResultData } from 'librechat-data-provider';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { Clipboard, CheckMark } from '~/components/svg';
|
||||
|
||||
type THoverButtons = {
|
||||
message: TMessage;
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
};
|
||||
|
||||
export default function MinimalHoverButtons({ message }: THoverButtons) {
|
||||
export default function MinimalHoverButtons({ message, searchResults }: THoverButtons) {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyToClipboard = useCopyToClipboard({ text: message.text, content: message.content });
|
||||
const copyToClipboard = useCopyToClipboard({
|
||||
text: message.text,
|
||||
content: message.content,
|
||||
searchResults,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
|
|
|
|||
|
|
@ -94,10 +94,10 @@ const MessageRender = memo(
|
|||
() =>
|
||||
showCardRender && !isLatestMessage
|
||||
? () => {
|
||||
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg!);
|
||||
}
|
||||
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg!);
|
||||
}
|
||||
: undefined,
|
||||
[showCardRender, isLatestMessage, msg, setLatestMessage],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const LoadingSpinner = memo(() => {
|
|||
|
||||
return (
|
||||
<div className="mx-auto mt-2 flex items-center justify-center gap-2">
|
||||
<Spinner className="h-4 w-4 text-text-primary" />
|
||||
<Spinner className="text-text-primary" />
|
||||
<span className="animate-pulse text-text-primary">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider';
|
|||
import type { SettingDefinition } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
import { presetSettings } from 'librechat-data-provider';
|
||||
|
||||
export default function AnthropicSettings({
|
||||
conversation,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider';
|
|||
import type { SettingDefinition } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
import { presetSettings } from 'librechat-data-provider';
|
||||
|
||||
export default function BedrockSettings({
|
||||
conversation,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useMemo } from 'react';
|
||||
import { getSettingsKeys } from 'librechat-data-provider';
|
||||
import type { SettingDefinition, DynamicSettingProps } from 'librechat-data-provider';
|
||||
import type { SettingDefinition } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { presetSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
import { presetSettings } from 'librechat-data-provider';
|
||||
|
||||
export default function OpenAISettings({
|
||||
conversation,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import React from 'react';
|
||||
import { CrossIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type ActionButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function ActionButton({ onClick }: ActionButtonProps) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="w-32">
|
||||
<Button
|
||||
className="w-full rounded-md border border-black bg-white p-0 text-black hover:bg-black hover:text-white"
|
||||
onClick={onClick}
|
||||
>
|
||||
Action Button
|
||||
{/* Action Button */}
|
||||
{localize('com_ui_action_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
|
|
@ -18,24 +19,25 @@ import { FileContext } from 'librechat-data-provider';
|
|||
import type { AugmentedColumnDef } from '~/common';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Button,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from '~/components/ui';
|
||||
import ActionButton from '~/components/Files/ActionButton';
|
||||
import { useDeleteFilesFromTable } from '~/hooks/Files';
|
||||
import { TrashIcon, Spinner } from '~/components/svg';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import ActionButton from '../ActionButton';
|
||||
import UploadFileButton from './UploadFileButton';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import store from '~/store';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
|
@ -57,12 +59,14 @@ export default function DataTableFile<TData, TValue>({
|
|||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const setFiles = useSetRecoilState(store.filesByIndex(0));
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
|
|
@ -87,7 +91,7 @@ export default function DataTableFile<TData, TValue>({
|
|||
<>
|
||||
<div className="mt-2 flex flex-col items-start">
|
||||
<h2 className="text-lg">
|
||||
<strong>Files</strong>
|
||||
<strong>{localize('com_ui_files')}</strong>
|
||||
</h2>
|
||||
<div className="mt-3 flex w-full flex-col-reverse justify-between md:flex-row">
|
||||
<div className="mt-3 flex w-full flex-row justify-center gap-x-3 md:m-0 md:justify-start">
|
||||
|
|
@ -103,7 +107,7 @@ export default function DataTableFile<TData, TValue>({
|
|||
const filesToDelete = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
deleteFiles({ files: filesToDelete as TFile[], setFiles });
|
||||
setRowSelection({});
|
||||
}}
|
||||
className="ml-1 gap-2 dark:hover:bg-gray-850/25 sm:ml-0"
|
||||
|
|
@ -242,13 +246,11 @@ export default function DataTableFile<TData, TValue>({
|
|||
</Table>
|
||||
</div>
|
||||
<div className="ml-4 mr-4 mt-4 flex h-auto items-center justify-end space-x-2 py-4 sm:ml-0 sm:mr-0 sm:h-0">
|
||||
<div className="text-muted-foreground ml-2 flex-1 text-sm">
|
||||
{localize(
|
||||
'com_files_number_selected', {
|
||||
0: `${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
1: `${table.getFilteredRowModel().rows.length}`,
|
||||
},
|
||||
)}
|
||||
<div className="ml-2 flex-1 text-sm text-muted-foreground">
|
||||
{localize('com_files_number_selected', {
|
||||
0: `${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
1: `${table.getFilteredRowModel().rows.length}`,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
className="dark:border-gray-500 dark:hover:bg-gray-600"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { InfoIcon } from 'lucide-react';
|
|||
import { Tools } from 'librechat-data-provider';
|
||||
import React, { useRef, useState, useMemo, useEffect } from 'react';
|
||||
import type { CodeBarProps } from '~/common';
|
||||
import LogContent from '~/components/Chat/Messages/Content/Parts/LogContent';
|
||||
import ResultSwitcher from '~/components/Messages/Content/ResultSwitcher';
|
||||
import { useToolCallsMapContext, useMessageContext } from '~/Providers';
|
||||
import { LogContent } from '~/components/Chat/Messages/Content/Parts';
|
||||
import RunCode from '~/components/Messages/Content/RunCode';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { Tools, AuthType } from 'librechat-data-provider';
|
||||
import { TerminalSquareIcon, Loader } from 'lucide-react';
|
||||
import { TerminalSquareIcon } from 'lucide-react';
|
||||
import React, { useMemo, useCallback, useEffect } from 'react';
|
||||
import type { CodeBarProps } from '~/common';
|
||||
import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider';
|
||||
|
|
@ -9,6 +9,7 @@ import { useLocalize, useCodeApiKeyForm } from '~/hooks';
|
|||
import { useMessageContext } from '~/Providers';
|
||||
import { cn, normalizeLanguage } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components';
|
||||
|
||||
const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -91,7 +92,7 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
|
|||
disabled={execute.isLoading}
|
||||
>
|
||||
{execute.isLoading ? (
|
||||
<Loader className="animate-spin" size={18} />
|
||||
<Spinner className="animate-spin" size={18} />
|
||||
) : (
|
||||
<TerminalSquareIcon size={18} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
|||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useAttachments, useMessageActions } from '~/hooks';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { useMessageActions } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -34,6 +34,10 @@ const ContentRender = memo(
|
|||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
}: ContentRenderProps) => {
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
messageId: msg?.messageId,
|
||||
attachments: msg?.attachments,
|
||||
});
|
||||
const {
|
||||
edit,
|
||||
index,
|
||||
|
|
@ -50,6 +54,7 @@ const ContentRender = memo(
|
|||
regenerateMessage,
|
||||
} = useMessageActions({
|
||||
message: msg,
|
||||
searchResults,
|
||||
currentEditId,
|
||||
isMultiMessage,
|
||||
setCurrentEditId,
|
||||
|
|
@ -91,10 +96,10 @@ const ContentRender = memo(
|
|||
() =>
|
||||
showCardRender && !isLatestMessage
|
||||
? () => {
|
||||
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg!);
|
||||
}
|
||||
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg!);
|
||||
}
|
||||
: undefined,
|
||||
[showCardRender, isLatestMessage, msg, setLatestMessage],
|
||||
);
|
||||
|
|
@ -164,9 +169,10 @@ const ContentRender = memo(
|
|||
enterEdit={enterEdit}
|
||||
siblingIdx={siblingIdx}
|
||||
messageId={msg.messageId}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
searchResults={searchResults}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
attachments={msg.attachments}
|
||||
isCreatedByUser={msg.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={msg.content as Array<TMessageContentParts | undefined>}
|
||||
|
|
|
|||
|
|
@ -75,12 +75,10 @@ function AccountSettings() {
|
|||
{user?.email ?? localize('com_nav_user')}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{startupConfig?.balance?.enabled === true &&
|
||||
balanceQuery.data != null &&
|
||||
!isNaN(parseFloat(balanceQuery.data)) && (
|
||||
{startupConfig?.balance?.enabled === true && balanceQuery.data != null && (
|
||||
<>
|
||||
<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')}: {balanceQuery.data.tokenCredits.toFixed(2)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,31 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { MessageSquare, Command } from 'lucide-react';
|
||||
import { MessageSquare, Command, DollarSign } from 'lucide-react';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
|
||||
import { General, Chat, Speech, Beta, Commands, Data, Account } from './SettingsTabs';
|
||||
import { General, Chat, Speech, Beta, Commands, Data, Account, Balance } from './SettingsTabs';
|
||||
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 767px)');
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const localize = useLocalize();
|
||||
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
|
||||
const tabRefs = useRef({});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const tabs = [
|
||||
const tabs: SettingsTabValues[] = [
|
||||
SettingsTabValues.GENERAL,
|
||||
SettingsTabValues.CHAT,
|
||||
SettingsTabValues.BETA,
|
||||
SettingsTabValues.COMMANDS,
|
||||
SettingsTabValues.SPEECH,
|
||||
SettingsTabValues.DATA,
|
||||
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
|
||||
SettingsTabValues.ACCOUNT,
|
||||
];
|
||||
const currentIndex = tabs.indexOf(activeTab);
|
||||
|
|
@ -82,6 +85,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
icon: <DataIcon />,
|
||||
label: 'com_nav_setting_data',
|
||||
},
|
||||
...(startupConfig?.balance?.enabled
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.BALANCE,
|
||||
icon: <DollarSign size={18} />,
|
||||
label: 'com_nav_setting_balance' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
|
||||
{
|
||||
value: SettingsTabValues.ACCOUNT,
|
||||
icon: <UserIcon />,
|
||||
|
|
@ -204,6 +216,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<Tabs.Content value={SettingsTabValues.DATA}>
|
||||
<Data />
|
||||
</Tabs.Content>
|
||||
{startupConfig?.balance?.enabled && (
|
||||
<Tabs.Content value={SettingsTabValues.BALANCE}>
|
||||
<Balance />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
|
||||
<Account />
|
||||
</Tabs.Content>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import React from 'react';
|
||||
import { TranslationKeys, useLocalize } from '~/hooks';
|
||||
import { Label } from '~/components';
|
||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
||||
|
||||
interface AutoRefillSettingsProps {
|
||||
lastRefill: Date;
|
||||
refillAmount: number;
|
||||
refillIntervalUnit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
|
||||
refillIntervalValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a time interval to a given date.
|
||||
* @param {Date} date - The starting date.
|
||||
* @param {number} value - The numeric value of the interval.
|
||||
* @param {'seconds'|'minutes'|'hours'|'days'|'weeks'|'months'} unit - The unit of time.
|
||||
* @returns {Date} A new Date representing the starting date plus the interval.
|
||||
*/
|
||||
const addIntervalToDate = (
|
||||
date: Date,
|
||||
value: number,
|
||||
unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months',
|
||||
): Date => {
|
||||
const result = new Date(date);
|
||||
switch (unit) {
|
||||
case 'seconds':
|
||||
result.setSeconds(result.getSeconds() + value);
|
||||
break;
|
||||
case 'minutes':
|
||||
result.setMinutes(result.getMinutes() + value);
|
||||
break;
|
||||
case 'hours':
|
||||
result.setHours(result.getHours() + value);
|
||||
break;
|
||||
case 'days':
|
||||
result.setDate(result.getDate() + value);
|
||||
break;
|
||||
case 'weeks':
|
||||
result.setDate(result.getDate() + value * 7);
|
||||
break;
|
||||
case 'months':
|
||||
result.setMonth(result.getMonth() + value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
|
||||
lastRefill,
|
||||
refillAmount,
|
||||
refillIntervalUnit,
|
||||
refillIntervalValue,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
const lastRefillDate = lastRefill ? new Date(lastRefill) : null;
|
||||
const nextRefill = lastRefillDate
|
||||
? addIntervalToDate(lastRefillDate, refillIntervalValue, refillIntervalUnit)
|
||||
: null;
|
||||
|
||||
// Return the localized unit based on singular/plural values
|
||||
const getLocalizedIntervalUnit = (
|
||||
value: number,
|
||||
unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months',
|
||||
): string => {
|
||||
let key: TranslationKeys;
|
||||
switch (unit) {
|
||||
case 'seconds':
|
||||
key = value === 1 ? 'com_nav_balance_second' : 'com_nav_balance_seconds';
|
||||
break;
|
||||
case 'minutes':
|
||||
key = value === 1 ? 'com_nav_balance_minute' : 'com_nav_balance_minutes';
|
||||
break;
|
||||
case 'hours':
|
||||
key = value === 1 ? 'com_nav_balance_hour' : 'com_nav_balance_hours';
|
||||
break;
|
||||
case 'days':
|
||||
key = value === 1 ? 'com_nav_balance_day' : 'com_nav_balance_days';
|
||||
break;
|
||||
case 'weeks':
|
||||
key = value === 1 ? 'com_nav_balance_week' : 'com_nav_balance_weeks';
|
||||
break;
|
||||
case 'months':
|
||||
key = value === 1 ? 'com_nav_balance_month' : 'com_nav_balance_months';
|
||||
break;
|
||||
default:
|
||||
key = 'com_nav_balance_seconds';
|
||||
}
|
||||
return localize(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">{localize('com_nav_balance_auto_refill_settings')}</h3>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>{localize('com_nav_balance_last_refill')}</span>
|
||||
<span>{lastRefillDate ? lastRefillDate.toLocaleString() : '-'}</span>
|
||||
</div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>{localize('com_nav_balance_refill_amount')}</span>
|
||||
<span>{refillAmount !== undefined ? refillAmount : '-'}</span>
|
||||
</div>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>{localize('com_nav_balance_interval')}</span>
|
||||
<span>
|
||||
{localize('com_nav_balance_every')} {refillIntervalValue}{' '}
|
||||
{getLocalizedIntervalUnit(refillIntervalValue, refillIntervalUnit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left Section: Label */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
|
||||
<HoverCardSettings side="bottom" text="com_nav_balance_next_refill_info" />
|
||||
</div>
|
||||
|
||||
{/* Right Section: tokenCredits Value */}
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200" role="note">
|
||||
{nextRefill ? nextRefill.toLocaleString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoRefillSettings;
|
||||
62
client/src/components/Nav/SettingsTabs/Balance/Balance.tsx
Normal file
62
client/src/components/Nav/SettingsTabs/Balance/Balance.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import TokenCreditsItem from './TokenCreditsItem';
|
||||
import AutoRefillSettings from './AutoRefillSettings';
|
||||
|
||||
function Balance() {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const balanceQuery = useGetUserBalance({
|
||||
enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled,
|
||||
});
|
||||
const balanceData = balanceQuery.data;
|
||||
|
||||
// Pull out all the fields we need, with safe defaults
|
||||
const {
|
||||
tokenCredits = 0,
|
||||
autoRefillEnabled = false,
|
||||
lastRefill,
|
||||
refillAmount,
|
||||
refillIntervalUnit,
|
||||
refillIntervalValue,
|
||||
} = balanceData ?? {};
|
||||
|
||||
// Check that all auto-refill props are present
|
||||
const hasValidRefillSettings =
|
||||
lastRefill !== undefined &&
|
||||
refillAmount !== undefined &&
|
||||
refillIntervalUnit !== undefined &&
|
||||
refillIntervalValue !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 text-sm text-text-primary">
|
||||
{/* Token credits display */}
|
||||
<TokenCreditsItem tokenCredits={tokenCredits} />
|
||||
|
||||
{/* Auto-refill display */}
|
||||
{autoRefillEnabled ? (
|
||||
hasValidRefillSettings ? (
|
||||
<AutoRefillSettings
|
||||
lastRefill={lastRefill}
|
||||
refillAmount={refillAmount}
|
||||
refillIntervalUnit={refillIntervalUnit}
|
||||
refillIntervalValue={refillIntervalValue}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-red-600">
|
||||
{localize('com_nav_balance_auto_refill_error')}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-gray-600">
|
||||
{localize('com_nav_balance_auto_refill_disabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Balance);
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Label } from '~/components';
|
||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
||||
|
||||
interface TokenCreditsItemProps {
|
||||
tokenCredits?: number;
|
||||
}
|
||||
|
||||
const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left Section: Label */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light">{localize('com_nav_balance')}</Label>
|
||||
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
|
||||
</div>
|
||||
|
||||
{/* Right Section: tokenCredits Value */}
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200" role="note">
|
||||
{tokenCredits !== undefined ? tokenCredits.toFixed(2) : '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenCreditsItem;
|
||||
|
|
@ -5,4 +5,5 @@ export { default as Beta } from './Beta/Beta';
|
|||
export { default as Commands } from './Commands/Commands';
|
||||
export { RevokeKeysButton } from './Data/RevokeKeysButton';
|
||||
export { default as Account } from './Account/Account';
|
||||
export { default as Balance } from './Balance/Balance';
|
||||
export { default as Speech } from './Speech/Speech';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
|||
import { Plugin } from '~/components/Messages/Content';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { MessageContext } from '~/Providers';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { useAttachments } from '~/hooks';
|
||||
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
|
@ -25,6 +26,11 @@ export default function Message(props: TMessageProps) {
|
|||
setCurrentEditId,
|
||||
} = props;
|
||||
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
messageId: message?.messageId,
|
||||
attachments: message?.attachments,
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -48,8 +54,8 @@ export default function Message(props: TMessageProps) {
|
|||
return (
|
||||
<>
|
||||
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-[47rem] md:px-5 lg:px-1 xl:max-w-[55rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
|
|
@ -68,13 +74,18 @@ export default function Message(props: TMessageProps) {
|
|||
<MessageContext.Provider
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: false,
|
||||
conversationId: conversation?.conversationId,
|
||||
}}
|
||||
>
|
||||
{/* Legacy Plugins */}
|
||||
{message.plugin && <Plugin plugin={message.plugin} />}
|
||||
{message.content ? (
|
||||
<SearchContent message={message} />
|
||||
<SearchContent
|
||||
message={message}
|
||||
attachments={attachments}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
) : (
|
||||
<MessageContent
|
||||
edit={false}
|
||||
|
|
@ -100,7 +111,7 @@ export default function Message(props: TMessageProps) {
|
|||
siblingCount={siblingCount}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
/>
|
||||
<MinimalHoverButtons message={message} />
|
||||
<MinimalHoverButtons message={message} searchResults={searchResults} />
|
||||
</SubRow>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function SharedView() {
|
|||
const { shareId } = useParams();
|
||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
const messagesTree = dataTree?.length === 0 ? null : dataTree ?? null;
|
||||
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||
|
||||
// configure document title
|
||||
let docTitle = '';
|
||||
|
|
@ -37,7 +37,7 @@ function SharedView() {
|
|||
} else if (data && messagesTree && messagesTree.length !== 0) {
|
||||
content = (
|
||||
<>
|
||||
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-[47rem] md:px-5 lg:px-1 xl:max-w-[55rem] xl:px-5">
|
||||
<h1 className="text-4xl font-bold">{data.title}</h1>
|
||||
<div className="border-b border-border-medium pb-6 text-base text-text-secondary">
|
||||
{new Date(data.createdAt).toLocaleDateString('en-US', {
|
||||
|
|
@ -53,7 +53,7 @@ function SharedView() {
|
|||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="flex h-screen items-center justify-center ">
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
{localize('com_ui_shared_link_not_found')}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { processAgentOption } from '~/utils';
|
|||
import Instructions from './Instructions';
|
||||
import AgentAvatar from './AgentAvatar';
|
||||
import FileContext from './FileContext';
|
||||
import SearchForm from './Search/Form';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import FileSearch from './FileSearch';
|
||||
import Artifacts from './Artifacts';
|
||||
|
|
@ -73,6 +74,10 @@ export default function AgentConfig({
|
|||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const webSearchEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.web_search) ?? false,
|
||||
[agentsConfig],
|
||||
);
|
||||
const codeEnabled = useMemo(
|
||||
() => agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) ?? false,
|
||||
[agentsConfig],
|
||||
|
|
@ -257,13 +262,19 @@ export default function AgentConfig({
|
|||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{(codeEnabled || fileSearchEnabled || artifactsEnabled || ocrEnabled) && (
|
||||
{(codeEnabled ||
|
||||
fileSearchEnabled ||
|
||||
artifactsEnabled ||
|
||||
ocrEnabled ||
|
||||
webSearchEnabled) && (
|
||||
<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} />}
|
||||
{/* Web Search */}
|
||||
{webSearchEnabled && <SearchForm />}
|
||||
{/* File Context (OCR) */}
|
||||
{ocrEnabled && <FileContext agent_id={agent_id} files={context_files} />}
|
||||
{/* Artifacts */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
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';
|
||||
|
|
@ -11,6 +10,7 @@ import DeleteButton from './DeleteButton';
|
|||
import { Spinner } from '~/components';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import { Panel } from '~/common';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
|
||||
export default function AgentFooter({
|
||||
activePanel,
|
||||
|
|
@ -53,8 +53,9 @@ export default function AgentFooter({
|
|||
const showButtons = activePanel === Panel.builder;
|
||||
|
||||
return (
|
||||
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
|
||||
<div className="mb-1 flex w-full flex-col gap-2">
|
||||
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{showButtons && agent_id && <VersionButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -87,7 +87,42 @@ export default function AgentPanel({
|
|||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
const error = err as Error & {
|
||||
statusCode?: number;
|
||||
details?: { duplicateVersion?: any; versionIndex?: number };
|
||||
response?: { status?: number; data?: any };
|
||||
};
|
||||
|
||||
const isDuplicateVersionError =
|
||||
(error.statusCode === 409 && error.details?.duplicateVersion) ||
|
||||
(error.response?.status === 409 && error.response?.data?.details?.duplicateVersion);
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
let versionIndex: number | undefined = undefined;
|
||||
|
||||
if (error.details?.versionIndex !== undefined) {
|
||||
versionIndex = error.details.versionIndex;
|
||||
} else if (error.response?.data?.details?.versionIndex !== undefined) {
|
||||
versionIndex = error.response.data.details.versionIndex;
|
||||
}
|
||||
|
||||
if (versionIndex === undefined || versionIndex < 0) {
|
||||
showToast({
|
||||
message: localize('com_agents_update_error'),
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
|
||||
status: 'error',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
|
|
@ -127,6 +162,9 @@ export default function AgentPanel({
|
|||
if (data.file_search === true) {
|
||||
tools.push(Tools.file_search);
|
||||
}
|
||||
if (data.web_search === true) {
|
||||
tools.push(Tools.web_search);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
|
|
@ -220,7 +258,7 @@ 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="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="w-full">
|
||||
<AgentSelect
|
||||
createMutation={create}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { ActionsEndpoint } from '~/common';
|
||||
import type { Action, TConfig, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery } from '~/data-provider';
|
||||
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import ActionsPanel from './ActionsPanel';
|
||||
import AgentPanel from './AgentPanel';
|
||||
import VersionPanel from './Version/VersionPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanelSwitch() {
|
||||
|
|
@ -15,11 +16,19 @@ export default function AgentPanelSwitch() {
|
|||
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
|
||||
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const createMutation = useCreateAgentMutation();
|
||||
|
||||
const agentsConfig = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents] ?? ({} as TConfig | null),
|
||||
[endpointsConfig],
|
||||
);
|
||||
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
...(config as TConfig),
|
||||
capabilities: Array.isArray(config.capabilities)
|
||||
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
|
||||
: ([] as AgentCapabilities[]),
|
||||
} as TAgentsEndpoint;
|
||||
}, [endpointsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const agent_id = conversation?.agent_id ?? '';
|
||||
|
|
@ -41,12 +50,23 @@ export default function AgentPanelSwitch() {
|
|||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id: currentAgentId,
|
||||
createMutation,
|
||||
};
|
||||
|
||||
if (activePanel === Panel.actions) {
|
||||
return <ActionsPanel {...commonProps} />;
|
||||
}
|
||||
|
||||
if (activePanel === Panel.version) {
|
||||
return (
|
||||
<VersionPanel
|
||||
setActivePanel={setActivePanel}
|
||||
agentsConfig={agentsConfig}
|
||||
selectedAgentId={currentAgentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
|
||||
);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export default function AgentSelect({
|
|||
};
|
||||
|
||||
const capabilities: TAgentCapabilities = {
|
||||
[AgentCapabilities.web_search]: false,
|
||||
[AgentCapabilities.file_search]: false,
|
||||
[AgentCapabilities.execute_code]: false,
|
||||
[AgentCapabilities.end_after_tools]: false,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ApiKeyFormData } from '~/common';
|
|||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { Input, Button, OGDialog } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
export default function ApiKeyDialog({
|
||||
isOpen,
|
||||
|
|
@ -13,6 +14,7 @@ export default function ApiKeyDialog({
|
|||
isToolAuthenticated,
|
||||
register,
|
||||
handleSubmit,
|
||||
triggerRef,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -22,6 +24,7 @@ export default function ApiKeyDialog({
|
|||
isToolAuthenticated: boolean;
|
||||
register: UseFormRegister<ApiKeyFormData>;
|
||||
handleSubmit: UseFormHandleSubmit<ApiKeyFormData>;
|
||||
triggerRef?: RefObject<HTMLInputElement>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const languageIcons = [
|
||||
|
|
@ -38,7 +41,7 @@ export default function ApiKeyDialog({
|
|||
];
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
<OGDialogTemplate
|
||||
className="w-11/12 sm:w-[450px]"
|
||||
title=""
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function AvatarMenu({
|
|||
>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
data-orientation="vertical"
|
||||
onClick={onItemClick}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
import React, { useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft, RotateCcw } from 'lucide-react';
|
||||
import { useFormContext, useWatch, Controller } from 'react-hook-form';
|
||||
import { getSettingsKeys, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
alternateName,
|
||||
getSettingsKeys,
|
||||
SettingDefinition,
|
||||
agentParamSettings,
|
||||
} from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { agentSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getEndpointField, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
import keyBy from 'lodash/keyBy';
|
||||
|
||||
export default function ModelPanel({
|
||||
setActivePanel,
|
||||
|
|
@ -52,7 +57,7 @@ export default function ModelPanel({
|
|||
}
|
||||
}, [provider, models, modelsData, setValue, model]);
|
||||
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { data: endpointsConfig = {} } = useGetEndpointsQuery();
|
||||
|
||||
const bedrockRegions = useMemo(() => {
|
||||
return endpointsConfig?.[provider]?.availableRegions ?? [];
|
||||
|
|
@ -63,10 +68,18 @@ export default function ModelPanel({
|
|||
[provider, endpointsConfig],
|
||||
);
|
||||
|
||||
const parameters = useMemo(() => {
|
||||
const parameters = useMemo((): SettingDefinition[] => {
|
||||
const customParams = endpointsConfig[provider]?.customParams ?? {};
|
||||
const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? '');
|
||||
return agentSettings[combinedKey] ?? agentSettings[endpointKey];
|
||||
}, [endpointType, model, provider]);
|
||||
const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey;
|
||||
const defaultParams =
|
||||
agentParamSettings[combinedKey] ?? agentParamSettings[overriddenEndpointKey] ?? [];
|
||||
const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? [];
|
||||
const overriddenParamsMap = keyBy(overriddenParams, 'key');
|
||||
return defaultParams.map(
|
||||
(param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param,
|
||||
);
|
||||
}, [endpointType, endpointsConfig, model, provider]);
|
||||
|
||||
const setOption = (optionKey: keyof t.AgentModelParameters) => (value: t.AgentParameterValue) => {
|
||||
setValue(`model_parameters.${optionKey}`, value);
|
||||
|
|
@ -198,7 +211,7 @@ export default function ModelPanel({
|
|||
{/* Model Parameters */}
|
||||
{parameters && (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-2">
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 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) => {
|
||||
|
|
|
|||
121
client/src/components/SidePanel/Agents/Search/Action.tsx
Normal file
121
client/src/components/SidePanel/Agents/Search/Action.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { AuthType, AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller, useWatch } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import {
|
||||
Checkbox,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
HoverCardTrigger,
|
||||
} from '~/components/ui';
|
||||
import { useLocalize, useSearchApiKeyForm } from '~/hooks';
|
||||
import { CircleHelpIcon } from '~/components/svg';
|
||||
import ApiKeyDialog from './ApiKeyDialog';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
export default function Action({
|
||||
authTypes = [],
|
||||
isToolAuthenticated = false,
|
||||
}: {
|
||||
authTypes?: [string, AuthType][];
|
||||
isToolAuthenticated?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { control, setValue, getValues } = methods;
|
||||
const {
|
||||
onSubmit,
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
handleRevokeApiKey,
|
||||
methods: keyFormMethods,
|
||||
} = useSearchApiKeyForm({
|
||||
onSubmit: () => {
|
||||
setValue(AgentCapabilities.web_search, true, { shouldDirty: true });
|
||||
},
|
||||
onRevoke: () => {
|
||||
setValue(AgentCapabilities.web_search, false, { shouldDirty: true });
|
||||
},
|
||||
});
|
||||
|
||||
const webSearchIsEnabled = useWatch({ control, name: AgentCapabilities.web_search });
|
||||
const isUserProvided = authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED);
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
if (isToolAuthenticated) {
|
||||
setValue(AgentCapabilities.web_search, checked, { shouldDirty: true });
|
||||
} else if (webSearchIsEnabled) {
|
||||
setValue(AgentCapabilities.web_search, false, { shouldDirty: true });
|
||||
} else {
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
name={AgentCapabilities.web_search}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={
|
||||
webSearchIsEnabled ? webSearchIsEnabled : isToolAuthenticated && field.value
|
||||
}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value.toString()}
|
||||
disabled={webSearchIsEnabled ? false : !isToolAuthenticated}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
const value = !getValues(AgentCapabilities.web_search);
|
||||
handleCheckboxChange(value);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
className="form-check-label text-token-text-primary w-full cursor-pointer"
|
||||
htmlFor={AgentCapabilities.web_search}
|
||||
>
|
||||
{localize('com_ui_web_search')}
|
||||
</label>
|
||||
</button>
|
||||
<div className="ml-2 flex gap-2">
|
||||
{isUserProvided && (isToolAuthenticated || webSearchIsEnabled) && (
|
||||
<button type="button" onClick={() => setIsDialogOpen(true)}>
|
||||
<KeyRoundIcon className="h-5 w-5 text-text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_agents_search_info')}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</div>
|
||||
</HoverCard>
|
||||
<ApiKeyDialog
|
||||
onSubmit={onSubmit}
|
||||
authTypes={authTypes}
|
||||
isOpen={isDialogOpen}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
register={keyFormMethods.register}
|
||||
isToolAuthenticated={isToolAuthenticated}
|
||||
handleSubmit={keyFormMethods.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ApiKeyDialog from './ApiKeyDialog';
|
||||
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
// Mock useLocalize to just return the key
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockRegister = (name: string) => ({
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
ref: jest.fn(),
|
||||
name,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onOpenChange: jest.fn(),
|
||||
onSubmit: jest.fn(),
|
||||
onRevoke: jest.fn(),
|
||||
authTypes: [
|
||||
[SearchCategories.PROVIDERS, AuthType.USER_PROVIDED] as [string, AuthType],
|
||||
[SearchCategories.SCRAPERS, AuthType.USER_PROVIDED] as [string, AuthType],
|
||||
[SearchCategories.RERANKERS, AuthType.USER_PROVIDED] as [string, AuthType],
|
||||
],
|
||||
isToolAuthenticated: false,
|
||||
register: mockRegister as any,
|
||||
handleSubmit: (fn: any) => (e: any) => fn(e),
|
||||
};
|
||||
|
||||
describe('ApiKeyDialog', () => {
|
||||
const mockUseGetStartupConfig = useGetStartupConfig as jest.Mock;
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('shows all dropdowns and both reranker fields when no config is set', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
render(<ApiKeyDialog {...defaultProps} />);
|
||||
// Provider dropdown button
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'com_ui_web_search_provider_serper' }),
|
||||
).toBeInTheDocument();
|
||||
// Scraper dropdown button
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'com_ui_web_search_scraper_firecrawl' }),
|
||||
).toBeInTheDocument();
|
||||
// Reranker dropdown button
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'com_ui_web_search_reranker_jina' }),
|
||||
).toBeInTheDocument();
|
||||
// Reranker fields (default is Jina)
|
||||
expect(screen.getByPlaceholderText('com_ui_web_search_jina_key')).toBeInTheDocument();
|
||||
// Switch to Cohere
|
||||
fireEvent.click(screen.getByText('com_ui_web_search_reranker_cohere'));
|
||||
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows static text for provider and only provider input if provider is set', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { webSearch: { searchProvider: 'serper' } } });
|
||||
render(<ApiKeyDialog {...defaultProps} />);
|
||||
expect(screen.getByText('com_ui_web_search_provider_serper')).toBeInTheDocument();
|
||||
// Should not find a dropdown button for provider
|
||||
expect(screen.queryByRole('button', { name: /provider/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only Jina reranker field if rerankerType is set to jina', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: { webSearch: { rerankerType: RerankerTypes.JINA } },
|
||||
});
|
||||
render(<ApiKeyDialog {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText('com_ui_web_search_jina_key')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('com_ui_web_search_cohere_key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only Cohere reranker field if rerankerType is set to cohere', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: { webSearch: { rerankerType: RerankerTypes.COHERE } },
|
||||
});
|
||||
render(<ApiKeyDialog {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_key')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('com_ui_web_search_jina_key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows documentation link for the visible reranker', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
render(<ApiKeyDialog {...defaultProps} />);
|
||||
// Default is Jina
|
||||
expect(screen.getByText('com_ui_web_search_reranker_jina_key')).toBeInTheDocument();
|
||||
// Switch to Cohere
|
||||
fireEvent.click(screen.getByText('com_ui_web_search_reranker_cohere'));
|
||||
expect(screen.getByText('com_ui_web_search_reranker_cohere_key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render provider section if SYSTEM_DEFINED', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
const props = {
|
||||
...defaultProps,
|
||||
authTypes: [
|
||||
[SearchCategories.PROVIDERS, AuthType.SYSTEM_DEFINED],
|
||||
[SearchCategories.SCRAPERS, AuthType.USER_PROVIDED],
|
||||
[SearchCategories.RERANKERS, AuthType.USER_PROVIDED],
|
||||
] as [string, AuthType][],
|
||||
};
|
||||
render(<ApiKeyDialog {...props} />);
|
||||
expect(screen.queryByText('com_ui_web_search_provider')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_web_search_scraper')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_web_search_reranker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render scraper section if SYSTEM_DEFINED', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
const props = {
|
||||
...defaultProps,
|
||||
authTypes: [
|
||||
[SearchCategories.PROVIDERS, AuthType.USER_PROVIDED],
|
||||
[SearchCategories.SCRAPERS, AuthType.SYSTEM_DEFINED],
|
||||
[SearchCategories.RERANKERS, AuthType.USER_PROVIDED],
|
||||
] as [string, AuthType][],
|
||||
};
|
||||
render(<ApiKeyDialog {...props} />);
|
||||
expect(screen.getByText('com_ui_web_search_provider')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_web_search_scraper')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_web_search_reranker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render reranker section if SYSTEM_DEFINED', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
const props = {
|
||||
...defaultProps,
|
||||
authTypes: [
|
||||
[SearchCategories.PROVIDERS, AuthType.USER_PROVIDED],
|
||||
[SearchCategories.SCRAPERS, AuthType.USER_PROVIDED],
|
||||
[SearchCategories.RERANKERS, AuthType.SYSTEM_DEFINED],
|
||||
] as [string, AuthType][],
|
||||
};
|
||||
render(<ApiKeyDialog {...props} />);
|
||||
expect(screen.getByText('com_ui_web_search_provider')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_web_search_scraper')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_web_search_reranker')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
361
client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx
Normal file
361
client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider';
|
||||
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
|
||||
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
import { Input, Button, OGDialog, Label } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import DropdownPopup from '~/components/ui/DropdownPopup';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ApiKeyDialog({
|
||||
isOpen,
|
||||
onSubmit,
|
||||
onRevoke,
|
||||
onOpenChange,
|
||||
authTypes,
|
||||
isToolAuthenticated,
|
||||
register,
|
||||
handleSubmit,
|
||||
triggerRef,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: SearchApiKeyFormData) => void;
|
||||
onRevoke: () => void;
|
||||
authTypes: [string, AuthType][];
|
||||
isToolAuthenticated: boolean;
|
||||
register: UseFormRegister<SearchApiKeyFormData>;
|
||||
handleSubmit: UseFormHandleSubmit<SearchApiKeyFormData>;
|
||||
triggerRef?: React.RefObject<HTMLInputElement>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const [selectedReranker, setSelectedReranker] = useState<
|
||||
RerankerTypes.JINA | RerankerTypes.COHERE
|
||||
>(
|
||||
config?.webSearch?.rerankerType === RerankerTypes.COHERE
|
||||
? RerankerTypes.COHERE
|
||||
: RerankerTypes.JINA,
|
||||
);
|
||||
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
|
||||
const [scraperDropdownOpen, setScraperDropdownOpen] = useState(false);
|
||||
const [rerankerDropdownOpen, setRerankerDropdownOpen] = useState(false);
|
||||
|
||||
const providerItems: MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_ui_web_search_provider_serper'),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
const scraperItems: MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_ui_web_search_scraper_firecrawl'),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
const rerankerItems: MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_ui_web_search_reranker_jina'),
|
||||
onClick: () => setSelectedReranker(RerankerTypes.JINA),
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_web_search_reranker_cohere'),
|
||||
onClick: () => setSelectedReranker(RerankerTypes.COHERE),
|
||||
},
|
||||
];
|
||||
|
||||
const showProviderDropdown = !config?.webSearch?.searchProvider;
|
||||
const showScraperDropdown = !config?.webSearch?.scraperType;
|
||||
const showRerankerDropdown = !config?.webSearch?.rerankerType;
|
||||
|
||||
// Determine which categories are SYSTEM_DEFINED
|
||||
const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1];
|
||||
const scraperAuthType = authTypes.find(([cat]) => cat === SearchCategories.SCRAPERS)?.[1];
|
||||
const rerankerAuthType = authTypes.find(([cat]) => cat === SearchCategories.RERANKERS)?.[1];
|
||||
|
||||
function renderRerankerInput() {
|
||||
if (config?.webSearch?.rerankerType === RerankerTypes.JINA) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_jina_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('jinaApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://jina.ai/api-dashboard/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_jina_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (config?.webSearch?.rerankerType === RerankerTypes.COHERE) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_cohere_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('cohereApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://dashboard.cohere.com/welcome/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_cohere_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.JINA) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_jina_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('jinaApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://jina.ai/api-dashboard/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_jina_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.COHERE) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_web_search_cohere_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('cohereApiKey')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://dashboard.cohere.com/welcome/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_reranker_cohere_key')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
<OGDialogTemplate
|
||||
className="w-11/12 sm:w-[500px]"
|
||||
title=""
|
||||
main={
|
||||
<>
|
||||
<div className="mb-4 text-center font-medium">{localize('com_ui_web_search')}</div>
|
||||
<div className="mb-4 text-center text-sm">
|
||||
{localize('com_ui_web_search_api_subtitle')}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Search Provider Section */}
|
||||
{providerAuthType !== AuthType.SYSTEM_DEFINED && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">
|
||||
{localize('com_ui_web_search_provider')}
|
||||
</Label>
|
||||
{showProviderDropdown ? (
|
||||
<DropdownPopup
|
||||
menuId="search-provider-dropdown"
|
||||
items={providerItems}
|
||||
isOpen={providerDropdownOpen}
|
||||
setIsOpen={setProviderDropdownOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_web_search_provider_serper')}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_ui_web_search_provider_serper')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`${localize('com_ui_enter_api_key')}`}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('serperApiKey', { required: true })}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://serper.dev/api-key"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_provider_serper_key')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scraper Section */}
|
||||
{scraperAuthType !== AuthType.SYSTEM_DEFINED && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">
|
||||
{localize('com_ui_web_search_scraper')}
|
||||
</Label>
|
||||
{showScraperDropdown ? (
|
||||
<DropdownPopup
|
||||
menuId="scraper-dropdown"
|
||||
items={scraperItems}
|
||||
isOpen={scraperDropdownOpen}
|
||||
setIsOpen={setScraperDropdownOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setScraperDropdownOpen(!scraperDropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_web_search_scraper_firecrawl')}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{localize('com_ui_web_search_scraper_firecrawl')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`${localize('com_ui_enter_api_key')}`}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
className="mb-2"
|
||||
{...register('firecrawlApiKey')}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={localize('com_ui_web_search_firecrawl_url')}
|
||||
className="mb-1"
|
||||
{...register('firecrawlApiUrl')}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-text-secondary">
|
||||
<a
|
||||
href="https://docs.firecrawl.dev/introduction#api-key"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_web_search_scraper_firecrawl_key')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reranker Section */}
|
||||
{rerankerAuthType !== AuthType.SYSTEM_DEFINED && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-md w-fit font-medium">
|
||||
{localize('com_ui_web_search_reranker')}
|
||||
</Label>
|
||||
{showRerankerDropdown && (
|
||||
<DropdownPopup
|
||||
menuId="reranker-dropdown"
|
||||
isOpen={rerankerDropdownOpen}
|
||||
setIsOpen={setRerankerDropdownOpen}
|
||||
items={rerankerItems}
|
||||
trigger={
|
||||
<Menu.MenuButton
|
||||
onClick={() => setRerankerDropdownOpen(!rerankerDropdownOpen)}
|
||||
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
|
||||
>
|
||||
{selectedReranker === RerankerTypes.JINA
|
||||
? localize('com_ui_web_search_reranker_jina')
|
||||
: localize('com_ui_web_search_reranker_cohere')}
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!showRerankerDropdown && (
|
||||
<div className="text-sm text-text-secondary">
|
||||
{config?.webSearch?.rerankerType === RerankerTypes.COHERE
|
||||
? localize('com_ui_web_search_reranker_cohere')
|
||||
: localize('com_ui_web_search_reranker_jina')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderRerankerInput()}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
isToolAuthenticated && (
|
||||
<Button
|
||||
onClick={onRevoke}
|
||||
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
31
client/src/components/SidePanel/Agents/Search/Form.tsx
Normal file
31
client/src/components/SidePanel/Agents/Search/Form.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Tools } from 'librechat-data-provider';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Action from './Action';
|
||||
|
||||
export default function SearchForm() {
|
||||
const localize = useLocalize();
|
||||
const { data } = useVerifyAgentToolAuth(
|
||||
{ toolId: Tools.web_search },
|
||||
{
|
||||
retry: 1,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-token-text-primary block font-medium">
|
||||
{localize('com_ui_web_search')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Action authTypes={data?.authTypes} isToolAuthenticated={data?.authenticated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { History } from 'lucide-react';
|
||||
import { Panel } from '~/common';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VersionButtonProps {
|
||||
setActivePanel: (panel: Panel) => void;
|
||||
}
|
||||
|
||||
const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
|
||||
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.version)}
|
||||
>
|
||||
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_agent_version')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default VersionButton;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import VersionItem from './VersionItem';
|
||||
import { VersionContext } from './VersionPanel';
|
||||
|
||||
type VersionContentProps = {
|
||||
selectedAgentId: string;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
versionContext: VersionContext;
|
||||
onRestore: (index: number) => void;
|
||||
};
|
||||
|
||||
export default function VersionContent({
|
||||
selectedAgentId,
|
||||
isLoading,
|
||||
error,
|
||||
versionContext,
|
||||
onRestore,
|
||||
}: VersionContentProps) {
|
||||
const { versions, versionIds } = versionContext;
|
||||
const localize = useLocalize();
|
||||
|
||||
if (!selectedAgentId) {
|
||||
return (
|
||||
<div className="py-8 text-center text-text-secondary">
|
||||
{localize('com_ui_agent_version_no_agent')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center text-red-500">{localize('com_ui_agent_version_error')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versionIds.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{versionIds.map(({ id, version, isActive }) => (
|
||||
<VersionItem
|
||||
key={id}
|
||||
version={version}
|
||||
index={id}
|
||||
isActive={isActive}
|
||||
versionsLength={versions.length}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center text-text-secondary">
|
||||
{localize('com_ui_agent_version_empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { VersionRecord } from './VersionPanel';
|
||||
|
||||
type VersionItemProps = {
|
||||
version: VersionRecord;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
versionsLength: number;
|
||||
onRestore: (index: number) => void;
|
||||
};
|
||||
|
||||
export default function VersionItem({
|
||||
version,
|
||||
index,
|
||||
isActive,
|
||||
versionsLength,
|
||||
onRestore,
|
||||
}: VersionItemProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getVersionTimestamp = (version: VersionRecord): string => {
|
||||
const timestamp = version.updatedAt || version.createdAt;
|
||||
|
||||
if (timestamp) {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime()) || date.toString() === 'Invalid Date') {
|
||||
return localize('com_ui_agent_version_unknown_date');
|
||||
}
|
||||
return date.toLocaleString();
|
||||
} catch (error) {
|
||||
return localize('com_ui_agent_version_unknown_date');
|
||||
}
|
||||
}
|
||||
|
||||
return localize('com_ui_agent_version_no_date');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border-light p-3">
|
||||
<div className="flex items-center justify-between font-medium">
|
||||
<span>
|
||||
{localize('com_ui_agent_version_title', { versionNumber: versionsLength - index })}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full border border-green-600 bg-green-600/20 px-2 py-0.5 text-xs font-medium text-green-700 dark:border-green-500 dark:bg-green-500/30 dark:text-green-300">
|
||||
{localize('com_ui_agent_version_active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">{getVersionTimestamp(version)}</div>
|
||||
{!isActive && (
|
||||
<button
|
||||
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={() => {
|
||||
if (window.confirm(localize('com_ui_agent_version_restore_confirm'))) {
|
||||
onRestore(index);
|
||||
}
|
||||
}}
|
||||
aria-label={localize('com_ui_agent_version_restore')}
|
||||
>
|
||||
{localize('com_ui_agent_version_restore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
client/src/components/SidePanel/Agents/Version/VersionPanel.tsx
Normal file
189
client/src/components/SidePanel/Agents/Version/VersionPanel.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type { Agent, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AgentPanelProps } from '~/common';
|
||||
import { Panel } from '~/common';
|
||||
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
|
||||
import { useLocalize, useToast } from '~/hooks';
|
||||
import VersionContent from './VersionContent';
|
||||
import { isActiveVersion } from './isActiveVersion';
|
||||
|
||||
export type VersionRecord = Record<string, any>;
|
||||
|
||||
export type AgentState = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
artifacts?: string | null;
|
||||
capabilities?: string[];
|
||||
tools?: string[];
|
||||
} | null;
|
||||
|
||||
export type VersionWithId = {
|
||||
id: number;
|
||||
originalIndex: number;
|
||||
version: VersionRecord;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type VersionContext = {
|
||||
versions: VersionRecord[];
|
||||
versionIds: VersionWithId[];
|
||||
currentAgent: AgentState;
|
||||
selectedAgentId: string;
|
||||
activeVersion: VersionRecord | null;
|
||||
};
|
||||
|
||||
export interface AgentWithVersions extends Agent {
|
||||
capabilities?: string[];
|
||||
versions?: Array<VersionRecord>;
|
||||
}
|
||||
|
||||
export type VersionPanelProps = {
|
||||
agentsConfig: TAgentsEndpoint | null;
|
||||
setActivePanel: AgentPanelProps['setActivePanel'];
|
||||
selectedAgentId?: string;
|
||||
};
|
||||
|
||||
export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
data: agent,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetAgentByIdQuery(selectedAgentId, {
|
||||
enabled: !!selectedAgentId && selectedAgentId !== '',
|
||||
});
|
||||
|
||||
const revertAgentVersion = useRevertAgentVersionMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_restore_success'),
|
||||
status: 'success',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_restore_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const agentWithVersions = agent as AgentWithVersions;
|
||||
|
||||
const currentAgent = useMemo(() => {
|
||||
if (!agentWithVersions) return null;
|
||||
return {
|
||||
name: agentWithVersions.name,
|
||||
description: agentWithVersions.description,
|
||||
instructions: agentWithVersions.instructions,
|
||||
artifacts: agentWithVersions.artifacts,
|
||||
capabilities: agentWithVersions.capabilities,
|
||||
tools: agentWithVersions.tools,
|
||||
};
|
||||
}, [agentWithVersions]);
|
||||
|
||||
const versions = useMemo(() => {
|
||||
const versionsCopy = [...(agentWithVersions?.versions || [])];
|
||||
return versionsCopy.sort((a, b) => {
|
||||
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [agentWithVersions?.versions]);
|
||||
|
||||
const activeVersion = useMemo(() => {
|
||||
return versions.length > 0
|
||||
? versions.find((v) => isActiveVersion(v, currentAgent, versions)) || null
|
||||
: null;
|
||||
}, [versions, currentAgent]);
|
||||
|
||||
const versionIds = useMemo(() => {
|
||||
if (versions.length === 0) return [];
|
||||
|
||||
const matchingVersions = versions.filter((v) => isActiveVersion(v, currentAgent, versions));
|
||||
|
||||
const activeVersionId =
|
||||
matchingVersions.length > 0 ? versions.findIndex((v) => v === matchingVersions[0]) : -1;
|
||||
|
||||
return versions.map((version, displayIndex) => {
|
||||
const originalIndex =
|
||||
agentWithVersions?.versions?.findIndex(
|
||||
(v) =>
|
||||
v.updatedAt === version.updatedAt &&
|
||||
v.createdAt === version.createdAt &&
|
||||
v.name === version.name,
|
||||
) ?? displayIndex;
|
||||
|
||||
return {
|
||||
id: displayIndex,
|
||||
originalIndex,
|
||||
version,
|
||||
isActive: displayIndex === activeVersionId,
|
||||
};
|
||||
});
|
||||
}, [versions, currentAgent, agentWithVersions?.versions]);
|
||||
|
||||
const versionContext: VersionContext = useMemo(
|
||||
() => ({
|
||||
versions,
|
||||
versionIds,
|
||||
currentAgent,
|
||||
selectedAgentId,
|
||||
activeVersion,
|
||||
}),
|
||||
[versions, versionIds, currentAgent, selectedAgentId, activeVersion],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
(displayIndex: number) => {
|
||||
const versionWithId = versionIds.find((v) => v.id === displayIndex);
|
||||
|
||||
if (versionWithId) {
|
||||
const originalIndex = versionWithId.originalIndex;
|
||||
|
||||
revertAgentVersion.mutate({
|
||||
agent_id: selectedAgentId,
|
||||
version_index: originalIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[revertAgentVersion, selectedAgentId, versionIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-full min-h-[40vh] overflow-auto pb-12 text-sm">
|
||||
<div className="version-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="version-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_agent_version_history')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-2">
|
||||
<VersionContent
|
||||
selectedAgentId={selectedAgentId}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
versionContext={versionContext}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import VersionContent from '../VersionContent';
|
||||
import { VersionContext } from '../VersionPanel';
|
||||
|
||||
const mockRestore = 'Restore';
|
||||
|
||||
jest.mock('../VersionItem', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ version, isActive, onRestore, index }) => (
|
||||
<div data-testid="version-item">
|
||||
<div>{version.name}</div>
|
||||
{!isActive && (
|
||||
<button data-testid={`restore-button-${index}`} onClick={() => onRestore(index)}>
|
||||
{mockRestore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key) => {
|
||||
const translations = {
|
||||
com_ui_agent_version_no_agent: 'No agent selected',
|
||||
com_ui_agent_version_error: 'Error loading versions',
|
||||
com_ui_agent_version_empty: 'No versions available',
|
||||
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||
com_ui_agent_version_restore: 'Restore',
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/svg', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
const mockVersionItem = jest.requireMock('../VersionItem').default;
|
||||
|
||||
describe('VersionContent', () => {
|
||||
const mockVersionIds = [
|
||||
{ id: 0, version: { name: 'First' }, isActive: true, originalIndex: 2 },
|
||||
{ id: 1, version: { name: 'Second' }, isActive: false, originalIndex: 1 },
|
||||
{ id: 2, version: { name: 'Third' }, isActive: false, originalIndex: 0 },
|
||||
];
|
||||
|
||||
const mockContext: VersionContext = {
|
||||
versions: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }],
|
||||
versionIds: mockVersionIds,
|
||||
currentAgent: { name: 'Test Agent', description: null, instructions: null },
|
||||
selectedAgentId: 'agent-123',
|
||||
activeVersion: { name: 'First' },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
selectedAgentId: 'agent-123',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
versionContext: mockContext,
|
||||
onRestore: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.confirm = jest.fn(() => true);
|
||||
});
|
||||
|
||||
test('renders different UI states correctly', () => {
|
||||
const renderTest = (props) => {
|
||||
const result = render(<VersionContent {...defaultProps} {...props} />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const { getByTestId, unmount: unmount1 } = renderTest({ isLoading: true });
|
||||
expect(getByTestId('spinner')).toBeInTheDocument();
|
||||
unmount1();
|
||||
|
||||
const { getByText: getText1, unmount: unmount2 } = renderTest({
|
||||
error: new Error('Test error'),
|
||||
});
|
||||
expect(getText1('Error loading versions')).toBeInTheDocument();
|
||||
unmount2();
|
||||
|
||||
const { getByText: getText2, unmount: unmount3 } = renderTest({ selectedAgentId: '' });
|
||||
expect(getText2('No agent selected')).toBeInTheDocument();
|
||||
unmount3();
|
||||
|
||||
const emptyContext = { ...mockContext, versions: [], versionIds: [] };
|
||||
const { getByText: getText3, unmount: unmount4 } = renderTest({ versionContext: emptyContext });
|
||||
expect(getText3('No versions available')).toBeInTheDocument();
|
||||
unmount4();
|
||||
|
||||
mockVersionItem.mockClear();
|
||||
|
||||
const { getAllByTestId } = renderTest({});
|
||||
expect(getAllByTestId('version-item')).toHaveLength(3);
|
||||
expect(mockVersionItem).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('restore functionality works correctly', () => {
|
||||
const onRestoreMock = jest.fn();
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<VersionContent {...defaultProps} onRestore={onRestoreMock} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('restore-button-1'));
|
||||
expect(onRestoreMock).toHaveBeenCalledWith(1);
|
||||
|
||||
expect(queryByTestId('restore-button-0')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('restore-button-1')).toBeInTheDocument();
|
||||
expect(queryByTestId('restore-button-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles edge cases in data', () => {
|
||||
const { getAllByTestId, getByText, queryByTestId, queryByText, rerender } = render(
|
||||
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versions: [] }} />,
|
||||
);
|
||||
expect(getAllByTestId('version-item')).toHaveLength(mockVersionIds.length);
|
||||
|
||||
rerender(
|
||||
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versionIds: [] }} />,
|
||||
);
|
||||
expect(getByText('No versions available')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<VersionContent
|
||||
{...defaultProps}
|
||||
selectedAgentId=""
|
||||
isLoading={true}
|
||||
error={new Error('Test')}
|
||||
/>,
|
||||
);
|
||||
expect(getByText('No agent selected')).toBeInTheDocument();
|
||||
expect(queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<VersionContent {...defaultProps} isLoading={true} error={new Error('Test')} />);
|
||||
expect(queryByTestId('spinner')).toBeInTheDocument();
|
||||
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import VersionItem from '../VersionItem';
|
||||
import { VersionRecord } from '../VersionPanel';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key, params) => {
|
||||
const translations = {
|
||||
com_ui_agent_version_title: params?.versionNumber
|
||||
? `Version ${params.versionNumber}`
|
||||
: 'Version',
|
||||
com_ui_agent_version_active: 'Active Version',
|
||||
com_ui_agent_version_restore: 'Restore',
|
||||
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||
com_ui_agent_version_unknown_date: 'Unknown date',
|
||||
com_ui_agent_version_no_date: 'No date',
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('VersionItem', () => {
|
||||
const mockVersion: VersionRecord = {
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
version: mockVersion,
|
||||
index: 1,
|
||||
isActive: false,
|
||||
versionsLength: 3,
|
||||
onRestore: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.confirm = jest.fn().mockImplementation(() => true);
|
||||
});
|
||||
|
||||
test('renders version number and timestamp', () => {
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
expect(screen.getByText('Version 2')).toBeInTheDocument();
|
||||
const date = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(date)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('active version badge and no restore button when active', () => {
|
||||
render(<VersionItem {...defaultProps} isActive={true} />);
|
||||
expect(screen.getByText('Active Version')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restore button and no active badge when not active', () => {
|
||||
render(<VersionItem {...defaultProps} isActive={false} />);
|
||||
expect(screen.queryByText('Active Version')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Restore')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restore confirmation flow - confirmed', () => {
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to restore this version?');
|
||||
expect(defaultProps.onRestore).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test('restore confirmation flow - canceled', () => {
|
||||
window.confirm = jest.fn().mockImplementation(() => false);
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(defaultProps.onRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles invalid timestamp', () => {
|
||||
render(
|
||||
<VersionItem {...defaultProps} version={{ ...mockVersion, updatedAt: 'invalid-date' }} />,
|
||||
);
|
||||
expect(screen.getByText('Unknown date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles missing timestamps', () => {
|
||||
render(
|
||||
<VersionItem
|
||||
{...defaultProps}
|
||||
version={{ ...mockVersion, updatedAt: undefined, createdAt: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('prefers updatedAt over createdAt when both exist', () => {
|
||||
const versionWithBothDates = {
|
||||
...mockVersion,
|
||||
updatedAt: '2023-01-02T00:00:00Z',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
render(<VersionItem {...defaultProps} version={versionWithBothDates} />);
|
||||
const updatedDate = new Date('2023-01-02T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(updatedDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('falls back to createdAt when updatedAt is missing', () => {
|
||||
render(
|
||||
<VersionItem
|
||||
{...defaultProps}
|
||||
version={{
|
||||
...mockVersion,
|
||||
updatedAt: undefined,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const createdDate = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(createdDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles empty version object', () => {
|
||||
render(<VersionItem {...defaultProps} version={{}} />);
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Panel } from '~/common/types';
|
||||
import VersionContent from '../VersionContent';
|
||||
import VersionPanel from '../VersionPanel';
|
||||
|
||||
const mockAgentData = {
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
versions: [
|
||||
{
|
||||
name: 'Version 1',
|
||||
description: 'Description 1',
|
||||
instructions: 'Instructions 1',
|
||||
tools: ['tool1'],
|
||||
capabilities: ['capability1'],
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'Version 2',
|
||||
description: 'Description 2',
|
||||
instructions: 'Instructions 2',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
createdAt: '2023-01-02T00:00:00Z',
|
||||
updatedAt: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetAgentByIdQuery: jest.fn(() => ({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
})),
|
||||
useRevertAgentVersionMutation: jest.fn(() => ({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../VersionContent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="version-content" />),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key) => key),
|
||||
useToast: jest.fn(() => ({ showToast: jest.fn() })),
|
||||
}));
|
||||
|
||||
describe('VersionPanel', () => {
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const defaultProps = {
|
||||
agentsConfig: null,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
selectedAgentId: 'agent-123',
|
||||
};
|
||||
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('renders panel UI and handles navigation', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-content')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(mockSetActivePanel).toHaveBeenCalledWith(Panel.builder);
|
||||
});
|
||||
|
||||
test('VersionContent receives correct props', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selectedAgentId: 'agent-123',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
versionContext: expect.objectContaining({
|
||||
currentAgent: expect.any(Object),
|
||||
versions: expect.any(Array),
|
||||
versionIds: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles data state variations', () => {
|
||||
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selectedAgentId: '' }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
versions: [],
|
||||
versionIds: [],
|
||||
currentAgent: null,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: { ...mockAgentData, versions: undefined },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({ versions: [] }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isLoading: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const testError = new Error('Test error');
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: testError,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: testError }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('memoizes agent data correctly', () => {
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
currentAgent: expect.objectContaining({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
}),
|
||||
versions: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Version 2' }),
|
||||
expect.objectContaining({ name: 'Version 1' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { isActiveVersion } from '../isActiveVersion';
|
||||
import type { AgentState, VersionRecord } from '../VersionPanel';
|
||||
|
||||
describe('isActiveVersion', () => {
|
||||
const createVersion = (overrides = {}): VersionRecord => ({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
artifacts: 'default',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAgentState = (overrides = {}): AgentState => ({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
artifacts: 'default',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('returns true for the first version in versions array when currentAgent is null', () => {
|
||||
const versions = [
|
||||
createVersion({ name: 'First Version' }),
|
||||
createVersion({ name: 'Second Version' }),
|
||||
];
|
||||
|
||||
expect(isActiveVersion(versions[0], null, versions)).toBe(true);
|
||||
expect(isActiveVersion(versions[1], null, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when all fields match exactly', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when names do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ name: 'Different Name' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when descriptions do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ description: 'Different Description' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when instructions do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ instructions: 'Different Instructions' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when artifacts do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ artifacts: 'different_artifacts' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches tools regardless of order', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when tools arrays have different lengths', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'tool2', 'tool3'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when tools do not match', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'different'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches capabilities regardless of order', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability2', 'capability1'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when capabilities arrays have different lengths', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({
|
||||
capabilities: ['capability1', 'capability2', 'capability3'],
|
||||
});
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when capabilities do not match', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability1', 'different'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('handles missing tools arrays', () => {
|
||||
const version = createVersion({ tools: undefined });
|
||||
const currentAgent = createAgentState({ tools: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has tools but agent does not', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has tools but version does not', () => {
|
||||
const version = createVersion({ tools: undefined });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'tool2'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles missing capabilities arrays', () => {
|
||||
const version = createVersion({ capabilities: undefined });
|
||||
const currentAgent = createAgentState({ capabilities: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has capabilities but agent does not', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has capabilities but version does not', () => {
|
||||
const version = createVersion({ capabilities: undefined });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability1', 'capability2'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles null values in fields', () => {
|
||||
const version = createVersion({ name: null });
|
||||
const currentAgent = createAgentState({ name: null });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles empty versions array', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles empty arrays for tools', () => {
|
||||
const version = createVersion({ tools: [] });
|
||||
const currentAgent = createAgentState({ tools: [] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles empty arrays for capabilities', () => {
|
||||
const version = createVersion({ capabilities: [] });
|
||||
const currentAgent = createAgentState({ capabilities: [] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles missing artifacts field', () => {
|
||||
const version = createVersion({ artifacts: undefined });
|
||||
const currentAgent = createAgentState({ artifacts: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has artifacts but agent does not', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ artifacts: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has artifacts but version does not', () => {
|
||||
const version = createVersion({ artifacts: undefined });
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles empty string for artifacts', () => {
|
||||
const version = createVersion({ artifacts: '' });
|
||||
const currentAgent = createAgentState({ artifacts: '' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { AgentState, VersionRecord } from './VersionPanel';
|
||||
|
||||
export const isActiveVersion = (
|
||||
version: VersionRecord,
|
||||
currentAgent: AgentState,
|
||||
versions: VersionRecord[],
|
||||
): boolean => {
|
||||
if (!versions || versions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentAgent) {
|
||||
const versionIndex = versions.findIndex(
|
||||
(v) =>
|
||||
v.name === version.name &&
|
||||
v.instructions === version.instructions &&
|
||||
v.artifacts === version.artifacts,
|
||||
);
|
||||
return versionIndex === 0;
|
||||
}
|
||||
|
||||
const matchesName = version.name === currentAgent.name;
|
||||
const matchesDescription = version.description === currentAgent.description;
|
||||
const matchesInstructions = version.instructions === currentAgent.instructions;
|
||||
const matchesArtifacts = version.artifacts === currentAgent.artifacts;
|
||||
|
||||
const toolsMatch = () => {
|
||||
if (!version.tools && !currentAgent.tools) return true;
|
||||
if (!version.tools || !currentAgent.tools) return false;
|
||||
if (version.tools.length !== currentAgent.tools.length) return false;
|
||||
|
||||
const sortedVersionTools = [...version.tools].sort();
|
||||
const sortedCurrentTools = [...currentAgent.tools].sort();
|
||||
|
||||
return sortedVersionTools.every((tool, i) => tool === sortedCurrentTools[i]);
|
||||
};
|
||||
|
||||
const capabilitiesMatch = () => {
|
||||
if (!version.capabilities && !currentAgent.capabilities) return true;
|
||||
if (!version.capabilities || !currentAgent.capabilities) return false;
|
||||
if (version.capabilities.length !== currentAgent.capabilities.length) return false;
|
||||
|
||||
const sortedVersionCapabilities = [...version.capabilities].sort();
|
||||
const sortedCurrentCapabilities = [...currentAgent.capabilities].sort();
|
||||
|
||||
return sortedVersionCapabilities.every(
|
||||
(capability, i) => capability === sortedCurrentCapabilities[i],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
matchesName &&
|
||||
matchesDescription &&
|
||||
matchesInstructions &&
|
||||
matchesArtifacts &&
|
||||
toolsMatch() &&
|
||||
capabilitiesMatch()
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import AgentFooter from '../AgentFooter';
|
||||
import { Panel } from '~/common';
|
||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import * as reactHookForm from 'react-hook-form';
|
||||
import * as hooks from '~/hooks';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({
|
||||
control: {},
|
||||
}),
|
||||
useWatch: () => {
|
||||
return {
|
||||
agent: {
|
||||
name: 'Test Agent',
|
||||
author: 'user-123',
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
},
|
||||
id: 'agent-123',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: '',
|
||||
role: 'USER',
|
||||
provider: 'local',
|
||||
emailVerified: true,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
} as TUser;
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key) => {
|
||||
const translations = {
|
||||
com_ui_save: 'Save',
|
||||
com_ui_create: 'Create',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
useAuthContext: () => ({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
}),
|
||||
useHasAccess: () => true,
|
||||
}));
|
||||
|
||||
const createBaseMutation = <T = Agent, P = any>(
|
||||
isLoading = false,
|
||||
): UseMutationResult<T, Error, P> => {
|
||||
if (isLoading) {
|
||||
return {
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: false as const,
|
||||
status: 'loading' as const,
|
||||
error: null,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
reset: jest.fn(),
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
isPaused: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: true as const,
|
||||
status: 'idle' as const,
|
||||
error: null,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
reset: jest.fn(),
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
isPaused: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useUpdateAgentMutation: () => createBaseMutation<Agent, any>(),
|
||||
}));
|
||||
|
||||
jest.mock('../Advanced/AdvancedButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="advanced-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../Version/VersionButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="version-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../AdminSettings', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="admin-settings" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DeleteButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="delete-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../ShareAgent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="share-agent" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DuplicateAgent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="duplicate-agent" />),
|
||||
}));
|
||||
|
||||
jest.mock('~/components', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
describe('AgentFooter', () => {
|
||||
const mockUsers = {
|
||||
regular: mockUser,
|
||||
admin: {
|
||||
...mockUser,
|
||||
id: 'admin-123',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: SystemRoles.ADMIN,
|
||||
} as TUser,
|
||||
different: {
|
||||
...mockUser,
|
||||
id: 'different-user',
|
||||
username: 'different',
|
||||
email: 'different@example.com',
|
||||
name: 'Different User',
|
||||
} as TUser,
|
||||
};
|
||||
|
||||
const createAuthContext = (user: TUser) => ({
|
||||
user,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
});
|
||||
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const mockSetCurrentAgentId = jest.fn();
|
||||
const mockCreateMutation = createBaseMutation<Agent, AgentCreateParams>();
|
||||
const mockUpdateMutation = createBaseMutation<Agent, any>();
|
||||
|
||||
const defaultProps = {
|
||||
activePanel: Panel.builder,
|
||||
createMutation: mockCreateMutation,
|
||||
updateMutation: mockUpdateMutation,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
setCurrentAgentId: mockSetCurrentAgentId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Main Functionality', () => {
|
||||
test('renders with standard components based on default state', () => {
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('advanced-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles loading states for createMutation', () => {
|
||||
const { unmount } = render(
|
||||
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
|
||||
);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('handles loading states for updateMutation', () => {
|
||||
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
test('adjusts UI based on activePanel state', () => {
|
||||
render(<AgentFooter {...defaultProps} activePanel={Panel.advanced} />);
|
||||
expect(screen.queryByTestId('advanced-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on agent ID existence', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: { name: 'Test Agent', author: 'user-123' },
|
||||
id: undefined,
|
||||
}));
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on user role', () => {
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on permissions', () => {
|
||||
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles null agent data', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: null,
|
||||
id: 'agent-123',
|
||||
}));
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { TranslationKeys, useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -23,23 +23,20 @@ function DynamicCheckbox({
|
|||
}: DynamicSettingProps) {
|
||||
const localize = useLocalize();
|
||||
const { preset } = useChatContext();
|
||||
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<boolean>({
|
||||
optionKey: settingKey,
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
});
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
return inputValue;
|
||||
}
|
||||
|
||||
return conversation?.[settingKey] ?? defaultValue;
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
}, [conversation, defaultValue, settingKey]);
|
||||
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setInputValue(checked);
|
||||
return;
|
||||
}
|
||||
setInputValue(checked);
|
||||
setOption(settingKey)(checked);
|
||||
};
|
||||
|
||||
|
|
@ -49,8 +46,7 @@ function DynamicCheckbox({
|
|||
defaultValue,
|
||||
conversation,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
preventDelayedUpdate: true,
|
||||
setInputValue: setLocalValue,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMemo, useState, useCallback } from 'react';
|
||||
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';
|
||||
|
|
@ -16,7 +15,6 @@ function DynamicCombobox({
|
|||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
options: _options,
|
||||
items: _items,
|
||||
showLabel = true,
|
||||
|
|
@ -36,11 +34,8 @@ function DynamicCombobox({
|
|||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
return inputValue;
|
||||
}
|
||||
return conversation?.[settingKey] ?? defaultValue;
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
}, [conversation, defaultValue, settingKey]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (_items != null) {
|
||||
|
|
@ -54,13 +49,10 @@ function DynamicCombobox({
|
|||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
setInputValue(value);
|
||||
} else {
|
||||
setOption(settingKey)(value);
|
||||
}
|
||||
setInputValue(value);
|
||||
setOption(settingKey)(value);
|
||||
},
|
||||
[optionType, setOption, settingKey],
|
||||
[setOption, settingKey],
|
||||
);
|
||||
|
||||
useParameterEffects({
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ function DynamicInput({
|
|||
settingKey,
|
||||
defaultValue,
|
||||
description = '',
|
||||
type = 'string',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
|
|
@ -28,7 +27,7 @@ function DynamicInput({
|
|||
const { preset } = useChatContext();
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
optionKey: settingKey,
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
|
|
@ -44,17 +43,7 @@ function DynamicInput({
|
|||
});
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (type !== 'number') {
|
||||
setInputValue(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
setInputValue(e);
|
||||
} else if (!isNaN(Number(value))) {
|
||||
setInputValue(e, true);
|
||||
}
|
||||
setInputValue(e, !isNaN(Number(e.target.value)));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function DynamicSlider({
|
|||
);
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
optionKey: settingKey,
|
||||
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
|
||||
setter: () => ({}),
|
||||
setOption,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import { useState } from 'react';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
|
|
@ -14,7 +13,6 @@ function DynamicSwitch({
|
|||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
readonly = false,
|
||||
showDefault = false,
|
||||
labelCode = false,
|
||||
|
|
@ -34,21 +32,10 @@ function DynamicSwitch({
|
|||
preventDelayedUpdate: true,
|
||||
});
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
return inputValue;
|
||||
}
|
||||
|
||||
return conversation?.[settingKey] ?? defaultValue;
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
const selectedValue = conversation?.[settingKey] ?? defaultValue;
|
||||
|
||||
const handleCheckedChange = (checked: boolean) => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setInputValue(checked);
|
||||
return;
|
||||
}
|
||||
setInputValue(checked);
|
||||
setOption(settingKey)(checked);
|
||||
};
|
||||
|
||||
|
|
@ -65,7 +52,7 @@ function DynamicSwitch({
|
|||
htmlFor={`${settingKey}-dynamic-switch`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}:{' '}
|
||||
|
|
@ -84,7 +71,11 @@ function DynamicSwitch({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
description={
|
||||
descriptionCode
|
||||
? (localize(description as TranslationKeys) ?? description)
|
||||
: description
|
||||
}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui';
|
||||
import { useChatContext, useToastContext } from '~/Providers';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { cn, defaultTextProps } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
|
|
@ -15,7 +14,6 @@ function DynamicTags({
|
|||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
placeholder = '',
|
||||
readonly = false,
|
||||
showDefault = false,
|
||||
|
|
@ -38,14 +36,10 @@ function DynamicTags({
|
|||
|
||||
const updateState = useCallback(
|
||||
(update: string[]) => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setTags(update);
|
||||
return;
|
||||
}
|
||||
setTags(update);
|
||||
setOption(settingKey)(update);
|
||||
},
|
||||
[optionType, setOption, settingKey],
|
||||
[setOption, settingKey],
|
||||
);
|
||||
|
||||
const onTagClick = useCallback(() => {
|
||||
|
|
@ -54,18 +48,10 @@ function DynamicTags({
|
|||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const currentTags: string[] | undefined = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
return tags;
|
||||
}
|
||||
|
||||
if (!conversation?.[settingKey]) {
|
||||
return defaultValue ?? [];
|
||||
}
|
||||
|
||||
return conversation[settingKey];
|
||||
}, [conversation, defaultValue, optionType, settingKey, tags]);
|
||||
const currentValue = conversation?.[settingKey];
|
||||
const currentTags = useMemo(() => {
|
||||
return currentValue ?? defaultValue ?? [];
|
||||
}, [currentValue, defaultValue]);
|
||||
|
||||
const onTagRemove = useCallback(
|
||||
(indexToRemove: number) => {
|
||||
|
|
@ -75,7 +61,7 @@ function DynamicTags({
|
|||
|
||||
if (minTags != null && currentTags.length <= minTags) {
|
||||
showToast({
|
||||
message: localize('com_ui_min_tags',{ 0: minTags + '' }),
|
||||
message: localize('com_ui_min_tags', { 0: minTags + '' }),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
|
|
@ -126,7 +112,7 @@ function DynamicTags({
|
|||
htmlFor={`${settingKey}-dynamic-input`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
|
||||
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
(
|
||||
|
|
@ -174,7 +160,11 @@ function DynamicTags({
|
|||
}
|
||||
}}
|
||||
onChange={(e) => setTagText(e.target.value)}
|
||||
placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder}
|
||||
placeholder={
|
||||
placeholderCode
|
||||
? (localize(placeholder as TranslationKeys) ?? placeholder)
|
||||
: placeholder
|
||||
}
|
||||
className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -182,7 +172,11 @@ function DynamicTags({
|
|||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
|
||||
description={
|
||||
descriptionCode
|
||||
? (localize(description as TranslationKeys) ?? description)
|
||||
: description
|
||||
}
|
||||
side={descriptionSide as ESide}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,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, TranslationKeys } from '~/hooks';
|
||||
import { cn, defaultTextProps } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -27,7 +27,7 @@ function DynamicTextarea({
|
|||
const { preset } = useChatContext();
|
||||
|
||||
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
|
||||
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
|
||||
optionKey: settingKey,
|
||||
initialValue:
|
||||
optionType !== OptionTypes.Custom
|
||||
? (conversation?.[settingKey] as string)
|
||||
|
|
|
|||
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