Merge branch 'main' into aron/data-retention-upstream

This commit is contained in:
Aron 2026-04-09 12:53:06 +01:00 committed by GitHub
commit f01dd6e172
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 1350 additions and 1448 deletions

21
.fly/gitnexus/Caddyfile Normal file
View file

@ -0,0 +1,21 @@
:8080 {
# Health check — unauthenticated so Fly.io can probe it
@health path /health
handle @health {
reverse_proxy localhost:4747 {
rewrite /api/info
}
}
# All other routes require bearer token
@authed {
header Authorization "Bearer {env.API_TOKEN}"
}
handle @authed {
reverse_proxy localhost:4747
}
# Reject unauthenticated requests
respond "Unauthorized" 401
}

29
.fly/gitnexus/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:24-slim
ARG GITNEXUS_VERSION=1.5.3
# Build tools for native addons (LadybugDB, tree-sitter), cleaned up after install
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ caddy \
&& npm install -g gitnexus@${GITNEXUS_VERSION} \
&& apt-get purge -y --auto-remove python3 make g++ \
&& rm -rf /var/lib/apt/lists/* /root/.npm
WORKDIR /repo
# Copy the pre-built GitNexus index (from CI artifact)
COPY .gitnexus/ .gitnexus/
# Register the index so `gitnexus serve` can discover it
RUN gitnexus index /repo --allow-non-git
# Caddy reverse proxy: bearer token auth in front of gitnexus serve
# Token is set via FLY_API_SECRET (flyctl secrets set API_TOKEN=...)
COPY Caddyfile /etc/caddy/Caddyfile
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,14 @@
#!/bin/sh
set -e
if [ -z "$API_TOKEN" ]; then
echo "ERROR: API_TOKEN secret is not set."
echo "Run: flyctl secrets set API_TOKEN=<your-token>"
exit 1
fi
# Start gitnexus serve in background
gitnexus serve --host 127.0.0.1 --port 4747 &
# Start caddy auth proxy in foreground
exec caddy run --config /etc/caddy/Caddyfile

24
.fly/gitnexus/fly.toml Normal file
View file

@ -0,0 +1,24 @@
app = 'librechat-gitnexus'
primary_region = 'iad'
[build]
dockerfile = 'Dockerfile'
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1
[[vm]]
size = 'shared-cpu-1x'
memory = '512mb'
[checks]
[checks.health]
type = 'http'
port = 8080
path = '/health'
interval = '30s'
timeout = '5s'

104
.github/workflows/gitnexus-deploy.yml vendored Normal file
View file

@ -0,0 +1,104 @@
# Deploys the GitNexus index to Fly.io as a persistent MCP + REST server.
#
# Endpoints available after deploy:
# /api/mcp — MCP-over-HTTP (StreamableHTTP transport)
# /api/query — Search execution flows
# /api/search — Hybrid BM25 + semantic search
# /api/repos — List indexed repositories
# /api/info — Server version and status
#
# First-time setup:
# 1. flyctl apps create librechat-gitnexus
# 2. flyctl tokens create deploy -x 999999h
# 3. flyctl secrets set API_TOKEN=$(openssl rand -hex 32)
# 4. Add FLY_API_TOKEN as a GitHub repo secret
#
# All requests (except /health) require: Authorization: Bearer <API_TOKEN>
name: GitNexus Deploy
on:
workflow_run:
workflows: ['GitNexus Index']
branches: [main]
types: [completed]
workflow_dispatch:
permissions:
actions: read
concurrency:
group: gitnexus-deploy
cancel-in-progress: true
env:
GITNEXUS_VERSION: '1.5.3'
jobs:
deploy:
if: |
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout deploy config
uses: actions/checkout@v4
with:
sparse-checkout: .fly/gitnexus
fetch-depth: 1
- name: Resolve index run
id: resolve
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run?.id;
if (runId) {
core.setOutput('run_id', String(runId));
return;
}
// workflow_dispatch: find latest successful index run on main
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'gitnexus-index.yml',
branch: 'main',
status: 'success',
per_page: 1,
});
if (!data.workflow_runs.length) {
core.setFailed('No successful GitNexus Index runs found on main');
return;
}
core.setOutput('run_id', String(data.workflow_runs[0].id));
- name: Download GitNexus index
uses: actions/download-artifact@v4
with:
name: gitnexus-index-main
path: deploy/.gitnexus
run-id: ${{ steps.resolve.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare deploy context
run: |
cp .fly/gitnexus/Dockerfile deploy/Dockerfile
cp .fly/gitnexus/Caddyfile deploy/Caddyfile
cp .fly/gitnexus/entrypoint.sh deploy/entrypoint.sh
echo "Deploy context:"
ls -la deploy/
ls -la deploy/.gitnexus/
- name: Setup Fly
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
working-directory: deploy
run: |
flyctl deploy \
--config ../.fly/gitnexus/fly.toml \
--build-arg GITNEXUS_VERSION=${{ env.GITNEXUS_VERSION }} \
--remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

91
.github/workflows/gitnexus-index.yml vendored Normal file
View file

@ -0,0 +1,91 @@
name: GitNexus Index
on:
push:
branches: [main, dev]
paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**']
pull_request:
branches: [main, dev]
paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**']
workflow_dispatch:
inputs:
embeddings:
description: 'Enable embedding generation (slow, increases index size)'
type: boolean
default: false
force:
description: 'Force full re-index'
type: boolean
default: false
permissions:
contents: read
concurrency:
group: gitnexus-${{ github.ref }}
cancel-in-progress: true
env:
GITNEXUS_VERSION: '1.5.3'
jobs:
index:
# Allow push + dispatch unconditionally; filter PRs to contributors only
if: |
github.event_name != 'pull_request' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Cache npm store
uses: actions/cache@v4
with:
path: ~/.npm
key: gitnexus-npm-${{ runner.os }}-${{ env.GITNEXUS_VERSION }}
restore-keys: gitnexus-npm-${{ runner.os }}-
- name: Run GitNexus Analyze
run: |
FLAGS="--skip-agents-md --verbose"
if [ "${{ inputs.embeddings }}" = "true" ]; then
FLAGS="$FLAGS --embeddings"
fi
if [ "${{ inputs.force }}" = "true" ]; then
FLAGS="$FLAGS --force"
fi
npx --yes gitnexus@${{ env.GITNEXUS_VERSION }} analyze . $FLAGS
- name: Verify index
run: |
if [ ! -d ".gitnexus" ] || [ ! -f ".gitnexus/meta.json" ]; then
echo "::error::GitNexus index was not created"
exit 1
fi
echo "::group::Index metadata"
cat .gitnexus/meta.json
echo ""
echo "::endgroup::"
- name: Upload GitNexus index
uses: actions/upload-artifact@v4
with:
name: >-
gitnexus-index-${{
github.event_name == 'pull_request'
&& format('pr-{0}', github.event.pull_request.number)
|| github.ref_name
}}
path: .gitnexus/
retention-days: 30

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 LibreChat
Copyright (c) 2026 LibreChat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -8,6 +8,7 @@ const {
exchangeAdminCode,
createSetBalanceConfig,
storeAndStripChallenge,
tenantContextMiddleware,
} = require('@librechat/api');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
@ -56,6 +57,7 @@ router.post(
middleware.loginLimiter,
middleware.checkBan,
middleware.requireLocalAuth,
tenantContextMiddleware,
requireAdminAccess,
setBalanceConfig,
loginController,
@ -152,6 +154,7 @@ router.get(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('openid'),
requireAdminAccess,
setBalanceConfig,
@ -190,6 +193,7 @@ router.post(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('saml'),
requireAdminAccess,
setBalanceConfig,
@ -229,6 +233,7 @@ router.get(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('google'),
requireAdminAccess,
setBalanceConfig,
@ -268,6 +273,7 @@ router.get(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('github'),
requireAdminAccess,
setBalanceConfig,
@ -307,6 +313,7 @@ router.get(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('discord'),
requireAdminAccess,
setBalanceConfig,
@ -346,6 +353,7 @@ router.get(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('facebook'),
requireAdminAccess,
setBalanceConfig,
@ -384,6 +392,7 @@ router.post(
failureMessage: true,
session: false,
}),
tenantContextMiddleware,
retrievePkceChallenge('apple'),
requireAdminAccess,
setBalanceConfig,

View file

@ -14,6 +14,7 @@ module.exports = {
*/
plugins: [
'@babel/plugin-transform-runtime',
'./test/babel-plugin-transform-import-meta-hot.cjs',
'babel-plugin-transform-import-meta',
'babel-plugin-transform-vite-meta-env',
'babel-plugin-replace-ts-export-assignment',

View file

@ -29,11 +29,7 @@ interface BadgeRowContextType {
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
export function useBadgeRowContext() {
const context = useContext(BadgeRowContext);
if (context === undefined) {
throw new Error('useBadgeRowContext must be used within a BadgeRowProvider');
}
return context;
return useContext(BadgeRowContext);
}
interface BadgeRowProviderProps {

View file

@ -1,7 +0,0 @@
import { createContext, useContext } from 'react';
type TDashboardContext = {
prevLocationPath: string;
};
export const DashboardContext = createContext<TDashboardContext>({} as TDashboardContext);
export const useDashboardContext = () => useContext(DashboardContext);

View file

@ -83,10 +83,14 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
return <DragDropContext.Provider value={contextValue}>{children}</DragDropContext.Provider>;
}
const defaultDragDropValue: DragDropContextValue = {
conversationId: undefined,
agentId: undefined,
endpoint: undefined,
endpointType: undefined,
useResponsesApi: undefined,
};
export function useDragDropContext() {
const context = useContext(DragDropContext);
if (!context) {
throw new Error('useDragDropContext must be used within DragDropProvider');
}
return context;
return useContext(DragDropContext) ?? defaultDragDropValue;
}

View file

@ -77,9 +77,5 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
};
export const usePromptGroupsContext = () => {
const context = useContext(PromptGroupsContext);
if (!context) {
throw new Error('usePromptGroupsContext must be used within a PromptGroupsProvider');
}
return context;
return useContext(PromptGroupsContext);
};

View file

@ -10,7 +10,6 @@ export * from './EditorContext';
export * from './ChatFormContext';
export * from './BookmarkContext';
export * from './MessageContext';
export * from './DashboardContext';
export * from './AssistantsContext';
export * from './AgentsContext';
export * from './AssistantsMapContext';

View file

@ -1,11 +1,12 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import type t from 'librechat-data-provider';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import { SidePanelGroup } from '~/components/SidePanel';
import CategoryTabs from './CategoryTabs';
import SearchBar from './SearchBar';
@ -215,14 +216,19 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
</div>
)}
{/* Sticky wrapper for search bar and categories */}
<div className="sticky top-0 z-10 bg-presentation pb-4">
<div className="sticky top-0 z-10 mt-4 bg-presentation pb-4 md:mt-0">
<div className="container mx-auto max-w-4xl px-4">
<div className="mx-auto mb-3 flex max-w-2xl items-center justify-between gap-2 md:hidden">
<OpenSidebar />
<MarketplaceAdminSettings compact />
</div>
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
{/* Admin Settings */}
<MarketplaceAdminSettings />
<div className="hidden md:block">
<MarketplaceAdminSettings />
</div>
</div>
{/* Category tabs */}

View file

@ -10,7 +10,7 @@ const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
];
const MarketplaceAdminSettings = () => {
const MarketplaceAdminSettings = ({ compact = false }: { compact?: boolean }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
@ -23,7 +23,16 @@ const MarketplaceAdminSettings = () => {
},
});
const trigger = (
const trigger = compact ? (
<Button
size="icon"
variant="outline"
className="rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="icon-md" aria-hidden="true" />
</Button>
) : (
<Button
variant="outline"
className="relative h-12 rounded-xl border-border-medium font-medium"

View file

@ -14,8 +14,8 @@ interface ArtifactsToggleState {
function Artifacts() {
const localize = useLocalize();
const { artifacts } = useBadgeRowContext();
const { toggleState, debouncedChange, isPinned } = artifacts;
const context = useBadgeRowContext();
const { toggleState, debouncedChange, isPinned } = context?.artifacts ?? {};
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isButtonExpanded, setIsButtonExpanded] = useState(false);
@ -32,6 +32,7 @@ function Artifacts() {
const isCustomEnabled = currentState.mode === ArtifactModes.CUSTOM;
const handleToggle = useCallback(() => {
if (!debouncedChange) return;
if (isEnabled) {
debouncedChange({ value: '' });
setIsButtonExpanded(false);
@ -55,6 +56,7 @@ function Artifacts() {
}, [isPopoverOpen]);
const handleShadcnToggle = useCallback(() => {
if (!debouncedChange) return;
if (isShadcnEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
@ -63,6 +65,7 @@ function Artifacts() {
}, [isShadcnEnabled, debouncedChange]);
const handleCustomToggle = useCallback(() => {
if (!debouncedChange) return;
if (isCustomEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {

View file

@ -7,9 +7,9 @@ import { useBadgeRowContext } from '~/Providers';
function CodeInterpreter() {
const localize = useLocalize();
const { codeInterpreter, codeApiKeyForm } = useBadgeRowContext();
const { toggleState: runCode, debouncedChange, isPinned } = codeInterpreter;
const { badgeTriggerRef } = codeApiKeyForm;
const context = useBadgeRowContext();
const { toggleState: runCode, debouncedChange, isPinned } = context?.codeInterpreter ?? {};
const { badgeTriggerRef } = context?.codeApiKeyForm ?? {};
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,

View file

@ -6,8 +6,8 @@ import { useBadgeRowContext } from '~/Providers';
function FileSearch() {
const localize = useLocalize();
const { fileSearch } = useBadgeRowContext();
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = fileSearch;
const context = useBadgeRowContext();
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = context?.fileSearch ?? {};
const canUseFileSearch = useHasAccess({
permissionType: PermissionTypes.FILE_SEARCH,

View file

@ -6,47 +6,55 @@ import { TooltipAnchor } from '@librechat/client';
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import StackedMCPIcons from '~/components/MCP/StackedMCPIcons';
import { useHasAccess, useLocalize } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
import { useHasAccess } from '~/hooks';
import { cn } from '~/utils';
function MCPSelectContent() {
const { conversationId, storageContextKey, mcpServerManager } = useBadgeRowContext();
const {
localize,
isPinned,
mcpValues,
placeholderText,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
const localize = useLocalize();
const context = useBadgeRowContext();
const { conversationId, storageContextKey, mcpServerManager: manager } = context ?? {};
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
const isOpen = menuStore.useState('open');
const selectedCount = mcpValues?.length ?? 0;
const selectedServers = useMemo(() => {
if (!mcpValues || mcpValues.length === 0) {
if (!manager?.mcpValues || manager.mcpValues.length === 0) {
return [];
}
return selectableServers.filter((s) => mcpValues.includes(s.serverName));
}, [selectableServers, mcpValues]);
const selectedSet = new Set(manager.mcpValues);
return manager.selectableServers?.filter((s) => selectedSet.has(s.serverName));
}, [manager?.selectableServers, manager?.mcpValues]);
const displayText = useMemo(() => {
const selectedCount = manager?.mcpValues?.length ?? 0;
if (selectedCount === 0) {
return null;
}
if (selectedCount === 1) {
const server = selectableServers.find((s) => s.serverName === mcpValues?.[0]);
return server?.config?.title || mcpValues?.[0];
const server = manager?.selectableServers?.find(
(s) => s.serverName === manager?.mcpValues?.[0],
);
return server?.config?.title || manager?.mcpValues?.[0];
}
return localize('com_ui_x_selected', { 0: selectedCount });
}, [selectedCount, selectableServers, mcpValues, localize]);
}, [manager?.selectableServers, manager?.mcpValues, localize]);
if (!manager) {
return null;
}
const {
isPinned,
mcpValues,
isInitializing,
placeholderText,
connectionStatus,
selectableServers,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = manager;
if (!isPinned && mcpValues?.length === 0) {
return null;
@ -126,8 +134,8 @@ function MCPSelectContent() {
}
function MCPSelect() {
const { mcpServerManager } = useBadgeRowContext();
const { selectableServers } = mcpServerManager;
const context = useBadgeRowContext();
const { selectableServers } = context?.mcpServerManager ?? {};
const canUseMcp = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.USE,

View file

@ -15,19 +15,8 @@ interface MCPSubMenuProps {
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
const localize = useLocalize();
const { storageContextKey, mcpServerManager } = useBadgeRowContext();
const {
isPinned,
mcpValues,
setIsPinned,
placeholderText,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
const context = useBadgeRowContext();
const { storageContextKey, mcpServerManager } = context ?? {};
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
@ -35,6 +24,23 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
placement: 'right',
});
if (!mcpServerManager) {
return null;
}
const {
isPinned,
mcpValues,
setIsPinned,
isInitializing,
placeholderText,
connectionStatus,
selectableServers,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
if (!selectableServers || selectableServers.length === 0) {
return null;
}

View file

@ -62,8 +62,9 @@ function PromptsCommand({
}) {
const localize = useLocalize();
const { mutate: recordUsage } = useRecordPromptUsage();
const { allPromptGroups, hasAccess } = usePromptGroupsContext();
const { data, isLoading } = allPromptGroups;
const promptGroupsContext = usePromptGroupsContext();
const { allPromptGroups, hasAccess } = promptGroupsContext ?? {};
const { data, isLoading } = allPromptGroups ?? {};
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

View file

@ -5,9 +5,19 @@ import CodeApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useBadgeRowContext } from '~/Providers';
function ToolDialogs() {
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = useBadgeRowContext();
const { authData: webSearchAuthData } = webSearch;
const { authData: codeAuthData } = codeInterpreter;
const context = useBadgeRowContext();
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = context ?? {};
const { authData: webSearchAuthData } = webSearch ?? {};
const { authData: codeAuthData } = codeInterpreter ?? {};
const searchAuthTypes = useMemo(
() => webSearchAuthData?.authTypes ?? [],
[webSearchAuthData?.authTypes],
);
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
if (!searchApiKeyForm || !codeApiKeyForm) {
return null;
}
const {
methods: searchMethods,
@ -29,12 +39,6 @@ function ToolDialogs() {
menuTriggerRef: codeMenuTriggerRef,
} = codeApiKeyForm;
const searchAuthTypes = useMemo(
() => webSearchAuthData?.authTypes ?? [],
[webSearchAuthData?.authTypes],
);
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
return (
<>
<SearchApiKeyDialog

View file

@ -23,39 +23,11 @@ interface ToolsDropdownProps {
const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const localize = useLocalize();
const isDisabled = disabled ?? false;
const [isPopoverActive, setIsPopoverActive] = useState(false);
const {
webSearch,
artifacts,
fileSearch,
agentsConfig,
mcpServerManager,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = useBadgeRowContext();
const context = useBadgeRowContext();
const { data: startupConfig } = useGetStartupConfig();
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm;
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
searchApiKeyForm;
const {
isPinned: isSearchPinned,
setIsPinned: setIsSearchPinned,
authData: webSearchAuthData,
} = webSearch;
const {
isPinned: isCodePinned,
setIsPinned: setIsCodePinned,
authData: codeAuthData,
} = codeInterpreter;
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
useAgentCapabilities(context?.agentsConfig?.capabilities ?? defaultAgentCapabilities);
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
@ -77,6 +49,35 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
permission: Permissions.USE,
});
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isDisabled = disabled ?? false;
const {
webSearch,
artifacts,
fileSearch,
mcpServerManager,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = context ?? {};
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm ?? {};
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
searchApiKeyForm ?? {};
const {
isPinned: isSearchPinned,
setIsPinned: setIsSearchPinned,
authData: webSearchAuthData,
} = webSearch ?? {};
const {
isPinned: isCodePinned,
setIsPinned: setIsCodePinned,
authData: codeAuthData,
} = codeInterpreter ?? {};
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch ?? {};
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts ?? {};
const showWebSearchSettings = useMemo(() => {
const authTypes = webSearchAuthData?.authTypes ?? [];
if (authTypes.length === 0) return true;
@ -89,44 +90,44 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
);
const handleWebSearchToggle = useCallback(() => {
const newValue = !webSearch.toggleState;
webSearch.debouncedChange({ value: newValue });
const newValue = !webSearch?.toggleState;
webSearch?.debouncedChange({ value: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ value: newValue });
const newValue = !codeInterpreter?.toggleState;
codeInterpreter?.debouncedChange({ value: newValue });
}, [codeInterpreter]);
const handleFileSearchToggle = useCallback(() => {
const newValue = !fileSearch.toggleState;
fileSearch.debouncedChange({ value: newValue });
const newValue = !fileSearch?.toggleState;
fileSearch?.debouncedChange({ value: newValue });
}, [fileSearch]);
const handleArtifactsToggle = useCallback(() => {
const currentState = artifacts.toggleState;
const currentState = artifacts?.toggleState;
if (!currentState || currentState === '') {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
artifacts?.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: '' });
artifacts?.debouncedChange({ value: '' });
}
}, [artifacts]);
const handleShadcnToggle = useCallback(() => {
const currentState = artifacts.toggleState;
const currentState = artifacts?.toggleState;
if (currentState === ArtifactModes.SHADCNUI) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
artifacts?.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.SHADCNUI });
artifacts?.debouncedChange({ value: ArtifactModes.SHADCNUI });
}
}, [artifacts]);
const handleCustomToggle = useCallback(() => {
const currentState = artifacts.toggleState;
const currentState = artifacts?.toggleState;
if (currentState === ArtifactModes.CUSTOM) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
artifacts?.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.CUSTOM });
artifacts?.debouncedChange({ value: ArtifactModes.CUSTOM });
}
}, [artifacts]);
@ -148,7 +149,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsFileSearchPinned(!isFileSearchPinned);
setIsFileSearchPinned?.(!isFileSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -182,7 +183,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchDialogOpen(true);
setIsSearchDialogOpen?.(true);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -201,7 +202,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchPinned(!isSearchPinned);
setIsSearchPinned?.(!isSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -236,7 +237,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodeDialogOpen(true);
setIsCodeDialogOpen?.(true);
}}
ref={codeMenuTriggerRef}
className={cn(
@ -255,7 +256,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodePinned(!isCodePinned);
setIsCodePinned?.(!isCodePinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
@ -274,15 +275,15 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
if (artifactsEnabled) {
if (artifactsEnabled && setIsArtifactsPinned != null) {
dropdownItems.push({
hideOnClick: false,
render: (props) => (
<ArtifactsSubMenu
{...props}
isArtifactsPinned={isArtifactsPinned}
isArtifactsPinned={isArtifactsPinned ?? false}
setIsArtifactsPinned={setIsArtifactsPinned}
artifactsMode={artifacts.toggleState as string}
artifactsMode={artifacts?.toggleState as string}
handleArtifactsToggle={handleArtifactsToggle}
handleShadcnToggle={handleShadcnToggle}
handleCustomToggle={handleCustomToggle}
@ -291,7 +292,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
const { availableMCPServers } = mcpServerManager;
const { availableMCPServers } = mcpServerManager ?? {};
if (canUseMcp && availableMCPServers && availableMCPServers.length > 0) {
dropdownItems.push({
hideOnClick: false,

View file

@ -7,18 +7,20 @@ import { useBadgeRowContext } from '~/Providers';
function WebSearch() {
const localize = useLocalize();
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const context = useBadgeRowContext();
if (!canUseWebSearch) {
return null;
}
if (!context) {
return null;
}
const { webSearch: webSearchData, searchApiKeyForm } = context;
const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
return (
(isPinned || (webSearch && authData?.authenticated)) && (

View file

@ -266,6 +266,16 @@ const Conversations: FC<ConversationsProps> = ({
return () => cancelAnimationFrame(frameId);
}, [favorites.length, isFavoritesLoading, showAgentMarketplace, clearFavoritesCache]);
useEffect(() => {
const frameId = requestAnimationFrame(() => {
cache.clearAll();
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
containerRef.current.recomputeRowHeights(0);
}
});
return () => cancelAnimationFrame(frameId);
}, [search.query, cache, containerRef]);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
const item = flattenedItems[index];

View file

@ -0,0 +1,46 @@
import { useCallback, useId } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { PromptsEditorMode } from '~/common';
import { useLocalize } from '~/hooks';
import store from '~/store';
const { promptsEditorMode, alwaysMakeProd } = store;
export default function AdvancedPrompts() {
const localize = useLocalize();
const [mode, setMode] = useRecoilState(promptsEditorMode);
const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
const isAdvanced = mode === PromptsEditorMode.ADVANCED;
const handleChange = useCallback(
(checked: boolean) => {
if (!checked) {
setAlwaysMakeProd(true);
}
setMode(checked ? PromptsEditorMode.ADVANCED : PromptsEditorMode.SIMPLE);
},
[setMode, setAlwaysMakeProd],
);
const rootId = useId();
const labelId = `${rootId}-label`;
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div id={labelId}>{localize('com_nav_advanced_prompts')}</div>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_advanced_prompts_desc')} />
</div>
<Switch
id={rootId}
checked={isAdvanced}
onCheckedChange={handleChange}
className="ml-4"
data-testid="advancedPrompts"
aria-labelledby={labelId}
/>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { memo } from 'react';
import { showThinkingAtom } from '~/store/showThinking';
import AdvancedPrompts from './AdvancedPrompts';
import FontSizeSelector from './FontSizeSelector';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
@ -119,6 +120,9 @@ function Chat() {
/>
</div>
))}
<div className="pb-3">
<AdvancedPrompts />
</div>
<ForkSettings />
</div>
);

View file

@ -1,7 +1,6 @@
import { useState } from 'react';
import { ShieldEllipsis } from 'lucide-react';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { OGDialog, OGDialogTemplate, Button, useToastContext } from '@librechat/client';
import { OGDialog, OGDialogTemplate, useToastContext } from '@librechat/client';
import type { PermissionConfig } from '~/components/ui';
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
import { AdminSettingsDialog } from '~/components/ui';
@ -39,18 +38,6 @@ const AdminSettings = () => {
setConfirmAdminUseChange({ newValue, callback: onChange });
};
const trigger = (
<Button
size="sm"
variant="outline"
aria-label={localize('com_ui_admin')}
className="mr-2 h-10 w-fit sm:m-0"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
);
const confirmDialog = (
<OGDialog
open={confirmAdminUseChange !== null}
@ -88,7 +75,6 @@ const AdminSettings = () => {
permissions={permissions}
menuId="prompt-role-dropdown"
mutation={mutation}
trigger={trigger}
onPermissionConfirm={handlePermissionConfirm}
confirmPermissions={[Permissions.USE]}
extraContent={confirmDialog}

View file

@ -1,52 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Sparkles, Layers } from 'lucide-react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { PromptsEditorMode } from '~/common';
import { Radio } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
const { promptsEditorMode, alwaysMakeProd } = store;
const AdvancedSwitch = () => {
const localize = useLocalize();
const [mode, setMode] = useRecoilState(promptsEditorMode);
const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
const options = useMemo(
() => [
{
value: PromptsEditorMode.SIMPLE,
label: localize('com_ui_simple'),
icon: <Sparkles className="size-3.5" />,
},
{
value: PromptsEditorMode.ADVANCED,
label: localize('com_ui_advanced'),
icon: <Layers className="size-3.5" />,
},
],
[localize],
);
const handleChange = useCallback(
(value: string) => {
if (value === PromptsEditorMode.SIMPLE) {
setAlwaysMakeProd(true);
}
setMode(value as PromptsEditorMode);
},
[setMode, setAlwaysMakeProd],
);
return (
<Radio
options={options}
value={mode}
onChange={handleChange}
className="border border-border-light"
/>
);
};
export default AdvancedSwitch;

View file

@ -25,13 +25,13 @@ export default function AutoSendPrompt({
onClick={() => handleCheckedChange(!autoSendPrompts)}
aria-label={localize('com_nav_auto_send_prompts')}
aria-pressed={autoSendPrompts}
className={autoSendPrompts ? 'bg-surface-hover hover:bg-surface-hover' : ''}
className={`relative h-9 w-full gap-2 rounded-lg border-border-light font-medium ${autoSendPrompts ? 'bg-surface-hover hover:bg-surface-hover' : ''}`}
>
<Checkbox
checked={autoSendPrompts}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2"
className="pointer-events-none"
/>
{localize('com_nav_auto_send_prompts')}
</Button>

View file

@ -1,33 +0,0 @@
import { useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { buttonVariants } from '@librechat/client';
import { useDashboardContext } from '~/Providers';
import { useLocalize, useCustomLink } from '~/hooks';
import { cn } from '~/utils';
export default function BackToChat({ className }: { className?: string }) {
const localize = useLocalize();
const { prevLocationPath } = useDashboardContext();
const conversationId = useMemo(() => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) {
return 'new';
}
const parts = prevLocationPath.split('/');
return parts[parts.length - 1];
}, [prevLocationPath]);
const href = `/c/${conversationId}`;
const clickHandler = useCustomLink(href);
return (
<a
className={cn(buttonVariants({ variant: 'outline' }), className)}
href={href}
onClick={clickHandler}
>
<ArrowLeft className="icon-xs mr-2" aria-hidden="true" />
{localize('com_ui_back_to_chat')}
</a>
);
}

View file

@ -27,7 +27,7 @@ export default function CreatePromptButton() {
className="size-9 shrink-0 bg-transparent"
aria-label={localize('com_ui_create_prompt')}
>
<Link to="/d/prompts/new">
<Link to="/prompts/new">
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>

View file

@ -1,32 +0,0 @@
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { Button } from '@librechat/client';
import { useLocalize, useCustomLink } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export default function ManagePrompts({ className }: { className?: string }) {
const localize = useLocalize();
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const customLink = useCustomLink('/d/prompts', clickCallback);
const clickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
customLink(e as unknown as React.MouseEvent<HTMLAnchorElement>);
};
return (
<Button
variant="outline"
className={cn(className, 'bg-transparent')}
onClick={clickHandler}
aria-label={localize('com_ui_manage')}
>
{localize('com_ui_manage')}
</Button>
);
}

View file

@ -1,7 +1,4 @@
export { default as BackToChat } from './BackToChat';
export { default as ManagePrompts } from './ManagePrompts';
export { default as AdminSettings } from './AdminSettings';
export { default as AdvancedSwitch } from './AdvancedSwitch';
export { default as AlwaysMakeProd } from './AlwaysMakeProd';
export { default as AutoSendPrompt } from './AutoSendPrompt';
export { default as CreatePromptButton } from './CreatePromptButton';

View file

@ -31,13 +31,14 @@ const DeleteConfirmDialog = ({
<Button
variant="destructive"
size="icon"
className="size-9"
aria-label={localize('com_ui_delete')}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
}}
>
<Trash2 className="size-4" aria-hidden="true" />
<Trash2 className="size-5" aria-hidden="true" />
</Button>
}
/>

View file

@ -61,10 +61,11 @@ const SharePrompt = React.memo(
<Button
variant="outline"
size="icon"
className="size-9 border-border-medium"
aria-label={localize('com_ui_share')}
disabled={disabled}
>
<Share2Icon className="size-4" aria-hidden="true" />
<Share2Icon className="size-5" aria-hidden="true" />
</Button>
}
/>

View file

@ -42,7 +42,7 @@ const PromptDetails = ({ group, showActions = true, onUsePrompt }: PromptDetails
<PromptVariables promptText={mainText} />
{group.command && (
<div className="flex items-center gap-2 rounded-xl border border-border-light bg-surface-secondary p-3">
<div className="flex items-center gap-2 rounded-xl border border-border-medium bg-transparent p-3">
<SquareSlash className="h-4 w-4 text-text-secondary" aria-hidden="true" />
<span className="font-mono text-sm text-text-primary">/{group.command}</span>
</div>

View file

@ -52,8 +52,8 @@ const PromptTextCard = ({ mainText }: PromptTextCardProps) => {
}, [mainText, showToast, localize, isCopied]);
return (
<div className="flex h-full flex-col rounded-xl border border-border-light bg-transparent shadow-md">
<header className="flex shrink-0 items-center justify-between border-b border-border-light p-3">
<div className="flex h-full flex-col rounded-xl border border-border-medium bg-transparent">
<header className="flex shrink-0 items-center justify-between border-b border-border-medium p-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-text-secondary" aria-hidden="true" />
<h3 className="text-base font-semibold text-text-primary">
@ -74,9 +74,9 @@ const PromptTextCard = ({ mainText }: PromptTextCardProps) => {
aria-live="polite"
>
{isCopied ? (
<Check className="size-4" aria-hidden="true" />
<Check className="size-4 text-text-secondary" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
<Copy className="size-4 text-text-secondary" aria-hidden="true" />
)}
</Button>
}

View file

@ -37,7 +37,7 @@ const DropdownVariableCard = ({ parsed }: { parsed: ParsedVariable }) => {
return (
<div
className="bg-surface-secondary/50 rounded-lg border border-border-light p-2.5 hover:bg-surface-secondary"
className="bg-surface-secondary/50 rounded-lg border border-border-medium p-2.5 hover:bg-surface-secondary"
role="listitem"
aria-label={localize('com_ui_variable_with_options', {
name: parsed.name,
@ -61,7 +61,7 @@ const DropdownVariableCard = ({ parsed }: { parsed: ParsedVariable }) => {
{parsed.options.map((option, index) => (
<span
key={index}
className="rounded-md border border-border-light bg-surface-primary px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-secondary"
className="rounded-md border border-border-medium bg-transparent px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-secondary"
role="listitem"
>
{option}
@ -82,7 +82,7 @@ const SpecialVariableChip = ({ parsed }: { parsed: ParsedVariable }) => {
return (
<div
className="group flex items-start gap-2 rounded-lg border border-border-light bg-transparent p-2 hover:bg-surface-secondary"
className="group flex items-start gap-2 rounded-lg border border-border-medium bg-transparent p-2 hover:bg-surface-secondary"
role="listitem"
aria-label={displayLabel}
>
@ -99,7 +99,7 @@ const SpecialVariableChip = ({ parsed }: { parsed: ParsedVariable }) => {
const SimpleVariableChip = ({ parsed }: { parsed: ParsedVariable }) => (
<span
className="bg-surface-secondary/50 inline-flex items-center gap-1.5 rounded-lg border border-border-light px-2.5 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
className="bg-surface-secondary/50 inline-flex items-center gap-1.5 rounded-lg border border-border-medium px-2.5 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
role="listitem"
>
<Variable className="size-3 text-text-secondary" aria-hidden="true" />
@ -138,8 +138,8 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
}
return (
<div className="overflow-hidden rounded-xl border border-border-light">
<header className="flex items-center justify-between border-b border-border-light p-3">
<div className="overflow-hidden rounded-xl border border-border-medium">
<header className="flex items-center justify-between border-b border-border-medium p-3">
<div className="flex items-center gap-2">
<Variable className="size-4 text-text-secondary" aria-hidden="true" />
<h4 className="text-sm font-semibold text-text-primary">

View file

@ -52,9 +52,9 @@ const getTimelineConnectorClasses = (isSelected: boolean, isProduction: boolean)
return 'border-green-500 bg-green-500 text-white';
}
if (isProduction) {
return 'border-green-400 bg-surface-primary text-green-500';
return 'border-green-400 bg-transparent text-green-500';
}
return 'border-border-medium bg-surface-primary text-text-secondary';
return 'border-border-medium bg-transparent text-text-secondary';
};
const VersionCard = ({
@ -107,7 +107,7 @@ const VersionCard = ({
'group mb-2 ml-2 flex flex-1 flex-col rounded-lg border p-3 text-left',
isSelected
? 'border-green-500/50 bg-green-50/50 dark:bg-green-950/20'
: 'border-border-light bg-surface-primary hover:border-border-medium hover:bg-surface-hover',
: 'border-border-medium bg-transparent hover:border-border-heavy hover:bg-surface-hover',
)}
onClick={onClick}
aria-label={localize('com_ui_version_var', { 0: `${versionNumber}` })}

View file

@ -44,7 +44,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
return (
<div className="flex max-h-[85vh] flex-col sm:max-h-[85vh]">
<h2 className="sr-only">{localize('com_ui_control_bar')}</h2>
<header className="flex items-center justify-between rounded-t-xl border border-border-light bg-transparent p-2">
<header className="flex items-center justify-between rounded-t-xl border border-border-medium bg-transparent px-2 py-1.5">
<div className="ml-1 flex items-center gap-2">
<FileText className="size-4 text-text-secondary" aria-hidden="true" />
<h3 className="text-sm font-semibold text-text-primary">
@ -73,10 +73,8 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
</header>
<div
className={cn(
'relative w-full flex-1 overflow-auto rounded-b-xl border border-t-0 border-border-light p-3 text-left transition-all duration-200 sm:p-4',
isEditing
? 'bg-surface-primary'
: 'cursor-pointer bg-surface-primary hover:bg-surface-secondary',
'relative w-full flex-1 overflow-auto rounded-b-xl border border-t-0 border-border-medium p-3 text-left transition-all duration-200 sm:p-4',
isEditing ? '' : 'cursor-pointer hover:bg-surface-tertiary',
)}
>
{!isEditing && (
@ -134,10 +132,10 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
{field.value}
</ReactMarkdown>
)}
<div className="bg-surface-secondary/0 group-hover/preview:bg-surface-secondary/50 pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-200 group-hover/preview:opacity-100">
<div className="flex items-center gap-2 rounded-lg bg-surface-primary px-3 py-1.5 shadow-md">
<div className="pointer-events-none sticky bottom-1/2 z-10 flex translate-y-1/2 items-center justify-center opacity-0 transition-all duration-200 group-hover/preview:opacity-100">
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-1.5 shadow-md">
<EditIcon className="size-4 text-text-secondary" aria-hidden="true" />
<span className="text-sm font-medium text-text-secondary">
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_click_to_edit')}
</span>
</div>

View file

@ -24,7 +24,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
const { t } = useTranslation();
const formContext = useFormContext();
const [isOpen, setIsOpen] = useState(false);
const { hasAccess } = usePromptGroupsContext();
const { hasAccess } = usePromptGroupsContext() ?? {};
const { categories, emptyCategory } = useCategories({ hasAccess });
const control = formContext?.control;
@ -74,7 +74,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
const trigger = (
<Ariakit.MenuButton
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'focus:ring-offset-ring-offset relative inline-flex h-9 items-center justify-between rounded-xl border border-border-medium bg-transparent px-3 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'gap-2 sm:w-fit',
className,
)}

View file

@ -44,7 +44,7 @@ const Command = ({
}
return (
<div className="rounded-xl border border-border-light shadow-md">
<div className="rounded-xl border border-border-medium">
<label
htmlFor="prompt-command"
className="block px-4 pt-2 text-sm text-text-secondary md:hidden"
@ -67,7 +67,7 @@ const Command = ({
/>
<label
htmlFor="prompt-command"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-surface-primary px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-presentation px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
>
{localize('com_ui_command_placeholder')}
</label>

View file

@ -41,7 +41,7 @@ const Description = ({
}
return (
<div className="rounded-xl border border-border-light shadow-md">
<div className="rounded-xl border border-border-medium">
<label
htmlFor="prompt-description"
className="block px-4 pt-2 text-sm text-text-secondary md:hidden"
@ -64,7 +64,7 @@ const Description = ({
/>
<label
htmlFor="prompt-description"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-surface-primary px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-presentation px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
>
{localize('com_ui_description_placeholder')}
</label>

View file

@ -1,70 +1,67 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Check, X, Pencil } from 'lucide-react';
import { Button, Input, Spinner, TooltipAnchor } from '@librechat/client';
import { Pencil, Check, Loader2, X } from 'lucide-react';
import { useLocalize } from '~/hooks';
type Props = {
name?: string;
isLoading?: boolean;
isError?: boolean;
onSave: (newName: string) => void;
};
const PromptName: React.FC<Props> = ({ name, isLoading = false, onSave }) => {
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
const PromptName: React.FC<Props> = ({ name, isLoading = false, isError = false, onSave }) => {
const localize = useLocalize();
const inputRef = useRef<HTMLInputElement>(null);
const wasLoadingRef = useRef(false);
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>();
/** Prevents duplicate saves when Enter/Escape already called commitName before blur fires */
const skipBlurRef = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewName(e.target.value);
}, []);
const handleCancel = useCallback(() => {
if (isLoading) {
return;
}
setIsEditing(false);
setNewName(name);
}, [name, isLoading]);
const saveName = useCallback(() => {
if (isLoading) {
return;
}
const commitName = useCallback(() => {
const savedName = newName?.trim();
if (savedName && savedName !== name) {
setSaveStatus('saving');
onSave(savedName);
} else {
setNewName(name);
setIsEditing(false);
}
}, [newName, name, onSave, isLoading]);
setIsEditing(false);
}, [newName, name, onSave]);
const saveName = useCallback(() => {
if (skipBlurRef.current) {
skipBlurRef.current = false;
return;
}
commitName();
}, [commitName]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
e.preventDefault();
skipBlurRef.current = true;
setNewName(name);
setIsEditing(false);
}
if (e.key === 'Enter') {
e.preventDefault();
saveName();
skipBlurRef.current = true;
commitName();
}
},
[handleCancel, saveName],
[name, commitName],
);
const handleTitleClick = useCallback(() => {
setIsEditing(true);
}, []);
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsEditing(true);
}
}, []);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
@ -72,90 +69,89 @@ const PromptName: React.FC<Props> = ({ name, isLoading = false, onSave }) => {
}
}, [isEditing]);
// Track loading state for detecting save completion
useEffect(() => {
if (isLoading) {
setSaveStatus('saving');
} else if (wasLoadingRef.current && !isLoading) {
setSaveStatus(isError ? 'error' : 'saved');
if (isError) {
setNewName(name);
}
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
}
savedTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
}
wasLoadingRef.current = isLoading;
}, [isLoading]);
}, [isLoading, isError, name]);
// Close editing when name updates after save (loading finished)
useEffect(() => {
setNewName(name);
if (wasLoadingRef.current) {
setIsEditing(false);
wasLoadingRef.current = false;
}
}, [name]);
useEffect(() => {
return () => {
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
}
};
}, []);
return (
<div className="flex min-w-0 flex-1 items-center">
<div className="group/title relative mr-2 flex h-8 min-w-0 flex-1 items-center">
{isEditing ? (
<div className="mr-3 flex min-w-0 flex-1 items-center gap-2">
<Input
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
ref={inputRef}
disabled={isLoading}
className="h-10 min-w-0 flex-1 rounded-lg border border-border-medium bg-surface-primary px-3 text-xl font-semibold text-text-primary transition-colors focus:border-border-heavy disabled:opacity-60 sm:text-2xl"
aria-label={localize('com_ui_name')}
/>
<div className="flex shrink-0 items-center gap-1">
<TooltipAnchor
description={isLoading ? localize('com_ui_loading') : localize('com_ui_save')}
side="bottom"
render={
<Button
type="button"
onClick={saveName}
variant="submit"
size="icon"
disabled={isLoading}
aria-label={isLoading ? localize('com_ui_loading') : localize('com_ui_save')}
>
{isLoading ? (
<Spinner size={16} className="text-white" />
) : (
<Check className="size-4" aria-hidden="true" />
)}
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_cancel')}
side="bottom"
render={
<Button
type="button"
onClick={handleCancel}
variant="outline"
size="icon"
disabled={isLoading}
aria-label={localize('com_ui_cancel')}
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</div>
</div>
<input
ref={inputRef}
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={saveName}
disabled={isLoading}
className="h-8 min-w-0 flex-1 rounded-md border border-transparent bg-transparent pl-2 pr-0 text-base font-semibold text-text-primary outline-none focus:border-border-medium focus:outline-none disabled:opacity-60"
aria-label={localize('com_ui_name')}
/>
) : (
<button
type="button"
onClick={handleTitleClick}
onKeyDown={handleTitleKeyDown}
className="group mr-3 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_ui_edit') + ' ' + localize('com_ui_name')}
onClick={() => {
if (!isLoading && saveStatus !== 'saving') {
setIsEditing(true);
}
}}
className="h-8 min-w-0 flex-1 cursor-text truncate pl-2 text-left text-base font-semibold text-text-primary transition-colors hover:text-text-secondary focus:outline-none"
title={newName}
aria-label={localize('com_ui_edit') + ': ' + (newName ?? '')}
>
<span className="block truncate text-xl font-semibold text-text-primary sm:text-2xl">
{newName}
</span>
<Pencil
className="size-4 shrink-0 text-text-tertiary opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
{newName}
</button>
)}
<div className="ml-1.5 flex shrink-0 items-center justify-center">
{saveStatus === 'saving' && (
<Loader2
className="size-4 animate-spin text-text-secondary"
aria-label={localize('com_ui_saving')}
/>
)}
{saveStatus === 'saved' && (
<Check
className="size-4 text-green-500 transition-opacity duration-300"
aria-label={localize('com_ui_saved')}
/>
)}
{saveStatus === 'error' && (
<X
className="size-4 text-red-500 transition-opacity duration-300"
aria-label={localize('com_ui_error')}
/>
)}
{saveStatus === 'idle' && !isEditing && (
<Pencil
className="size-3.5 text-text-secondary opacity-0 transition-opacity group-hover/title:opacity-100"
aria-hidden="true"
/>
)}
</div>
</div>
);
};

View file

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import { Button, TextareaAutosize, Input } from '@librechat/client';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import CategorySelector from '../fields/CategorySelector';
import VariablesDropdown from '../editor/VariablesDropdown';
import PromptVariables from '../display/PromptVariables';
@ -34,12 +35,14 @@ const defaultPrompt: CreateFormValues = {
const CreatePromptForm = ({
defaultValues = defaultPrompt,
onSuccess,
}: {
defaultValues?: CreateFormValues;
onSuccess?: (groupId: string) => void;
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const { hasAccess: hasUseAccess } = usePromptGroupsContext();
const { hasAccess: hasUseAccess } = usePromptGroupsContext() ?? {};
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
@ -48,7 +51,7 @@ const CreatePromptForm = ({
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccess) {
if (!hasAccess && !onSuccess) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
@ -56,7 +59,7 @@ const CreatePromptForm = ({
return () => {
clearTimeout(timeoutId);
};
}, [hasAccess, navigate]);
}, [hasAccess, navigate, onSuccess]);
const methods = useForm({
defaultValues: {
@ -74,7 +77,12 @@ const CreatePromptForm = ({
const createPromptMutation = useCreatePrompt({
onSuccess: (response) => {
navigate(`/d/prompts/${response.prompt.groupId}`, { replace: true });
const groupId = response.prompt.groupId;
if (onSuccess && groupId) {
onSuccess(groupId);
} else {
navigate(`/prompts/${groupId}`, { replace: true });
}
},
});
@ -106,6 +114,10 @@ const CreatePromptForm = ({
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
<h1 className="sr-only">{localize('com_ui_create_prompt_page')}</h1>
<div className="mb-2 flex items-center justify-between gap-2 sm:hidden">
<OpenSidebar />
<CategorySelector />
</div>
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
<div className="flex w-full flex-col items-center justify-between sm:flex-row">
<Controller
@ -118,7 +130,7 @@ const CreatePromptForm = ({
{...field}
id="prompt-name"
type="text"
className="peer mr-2 w-full border border-border-light p-2 text-2xl text-text-primary"
className="peer mr-2 w-full border border-border-medium p-2 text-2xl text-text-primary"
placeholder=" "
tabIndex={0}
aria-label={localize('com_ui_prompt_name')}
@ -126,7 +138,7 @@ const CreatePromptForm = ({
/>
<label
htmlFor="prompt-name"
className="pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded bg-surface-primary px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
className="pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded bg-presentation px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
>
{localize('com_ui_prompt_name')}*
</label>
@ -141,12 +153,14 @@ const CreatePromptForm = ({
</div>
)}
/>
<CategorySelector />
<div className="hidden sm:block">
<CategorySelector />
</div>
</div>
</div>
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div className="flex flex-col">
<header className="flex items-center justify-between rounded-t-xl border border-border-light bg-transparent p-2">
<header className="flex items-center justify-between rounded-t-xl border border-border-medium bg-transparent p-2">
<div className="ml-1 flex items-center gap-2">
<FileText className="size-4 text-text-secondary" aria-hidden="true" />
<h2 className="text-sm font-semibold text-text-primary">
@ -157,7 +171,7 @@ const CreatePromptForm = ({
<VariablesDropdown fieldName="prompt" />
</div>
</header>
<div className="min-h-32 rounded-b-xl border border-t-0 border-border-light p-3 sm:p-4">
<div className="min-h-32 rounded-b-xl border border-t-0 border-border-medium p-3 sm:p-4">
<Controller
name="prompt"
control={control}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Menu, Rocket } from 'lucide-react';
import { Menu, Rocket, X } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { useForm, FormProvider } from 'react-hook-form';
import { Button, Skeleton, useToastContext } from '@librechat/client';
@ -20,6 +20,7 @@ import {
useMakePromptProduction,
} from '~/data-provider';
import { useResourcePermissions, useHasAccess, useLocalize, useFocusTrap } from '~/hooks';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import CategorySelector from '../fields/CategorySelector';
import PromptVariables from '../display/PromptVariables';
import PromptVersions from '../display/PromptVersions';
@ -65,18 +66,15 @@ const VersionsPanel = React.memo(
const isProductionVersion = selectedPrompt?._id === group?.productionId;
return (
<div
className="flex h-full w-full flex-col overflow-hidden bg-surface-primary"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="flex h-full w-full flex-col overflow-hidden">
{canEdit && (
<div className="shrink-0 border-b border-border-light px-4 py-3">
<div className="shrink-0 px-4 py-2">
<Button
variant="submit"
size="sm"
aria-label={localize('com_ui_make_production')}
className={cn(
'w-full gap-2 transition-all duration-200',
'w-full gap-1.5 transition-all duration-200',
isProductionVersion &&
'border border-green-500/30 bg-green-50 text-green-700 hover:bg-green-100 dark:bg-green-950/30 dark:text-green-400 dark:hover:bg-green-950/50',
)}
@ -106,7 +104,7 @@ const VersionsPanel = React.memo(
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 py-3">
<div className="flex-1 overflow-y-auto px-4 py-2">
{isLoadingPrompts &&
Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
@ -115,7 +113,7 @@ const VersionsPanel = React.memo(
))}
{!isLoadingPrompts && prompts.length > 0 && (
<>
<div className="mb-3 flex items-center justify-between">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-medium text-text-secondary">
{localize('com_ui_versions')}
</h2>
@ -180,13 +178,13 @@ const HeaderActions = React.memo(
HeaderActions.displayName = 'HeaderActions';
const PromptForm = () => {
const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
const { hasAccess } = usePromptGroupsContext();
const { hasAccess, groupsQuery } = usePromptGroupsContext() ?? {};
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const promptId = params.promptId || '';
const promptId = promptIdProp || params.promptId || '';
const editorMode = useRecoilValue(store.promptsEditorMode);
const [selectionIndex, setSelectionIndex] = useState<number>(0);
@ -197,7 +195,6 @@ const PromptForm = () => {
const [isEditing, setIsEditing] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
const [showSidePanel, setShowSidePanel] = useState(false);
const sidePanelWidth = '320px';
// Reset selection when navigating to a different prompt group
useEffect(() => {
@ -239,8 +236,6 @@ const PromptForm = () => {
const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
const { groupsQuery } = usePromptGroupsContext();
const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
showToast({
@ -335,7 +330,7 @@ const PromptForm = () => {
useEffect(() => {
handleLoadingComplete();
}, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
}, [promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
useEffect(() => {
setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false });
@ -435,11 +430,8 @@ const PromptForm = () => {
}
// Show read-only view if user doesn't have edit permission
if (!canEdit && !permissionsLoading && groupsQuery.data) {
const fetchedPrompt = findPromptGroup(
groupsQuery.data,
(group) => group._id === params.promptId,
);
if (!canEdit && !permissionsLoading && groupsQuery?.data) {
const fetchedPrompt = findPromptGroup(groupsQuery?.data, (group) => group._id === promptId);
if (!fetchedPrompt && !canView) {
return <NoPromptGroup />;
}
@ -457,22 +449,42 @@ const PromptForm = () => {
return (
<FormProvider {...methods}>
<form className="mt-4 flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<form className="flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<h1 className="sr-only">{localize('com_ui_edit_prompt_page')}</h1>
<div className="relative w-full">
<div className="h-full w-full">
<div className="relative w-full overflow-hidden">
<div
className="h-full w-full"
style={{
transform: showSidePanel ? 'translateX(max(-85vw, -380px))' : 'translateX(0)',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
>
<div className="flex h-full">
<div className="flex-1 overflow-hidden px-4">
{/* Mobile Actions Row */}
{!isLoadingGroup && group && (
<div className="mb-3 mt-2 flex items-center justify-between gap-2 sm:hidden">
<OpenSidebar />
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
)}
{/* Header: Title + Actions */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="mb-3 mt-2 flex items-center justify-between gap-2">
{isLoadingGroup ? (
<Skeleton className="h-10 w-48 font-bold sm:text-xl md:h-12 md:text-2xl" />
<Skeleton className="h-9 w-48" />
) : (
<>
<div className="flex min-w-0 flex-1 items-center gap-2">
<PromptName
name={groupName}
isLoading={updateGroupMutation.isLoading}
isError={updateGroupMutation.isError}
onSave={(value) => {
if (!canEdit || !group._id) {
return;
@ -493,8 +505,8 @@ const PromptForm = () => {
onClick={() => setShowSidePanel(true)}
aria-label={localize('com_ui_versions')}
>
<Menu className="mr-1.5 size-4" aria-hidden="true" />
<span>{localize('com_ui_versions')}</span>
<Menu className="size-4 sm:mr-1.5" aria-hidden="true" />
<span className="hidden sm:inline">{localize('com_ui_versions')}</span>
</Button>
)}
</div>
@ -511,24 +523,11 @@ const PromptForm = () => {
)}
</div>
{/* Mobile Actions Row */}
{!isLoadingGroup && group && (
<div className="mb-4 sm:hidden">
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
)}
{/* Main Editor Content */}
{isLoadingPrompts ? (
<Skeleton className="h-96" aria-live="polite" />
) : (
<div className="mb-2 flex h-full flex-col gap-4">
<div className="mb-2 flex h-full flex-col gap-3">
<PromptEditor
name="prompt"
isEditing={isEditing}
@ -551,7 +550,7 @@ const PromptForm = () => {
{/* Versions Sidebar - Advanced Mode Only */}
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-72 shrink-0 border-l border-border-light lg:block xl:w-80">
<div className="hidden w-72 shrink-0 border-l border-border-medium lg:block xl:w-80">
<VersionsPanel
group={group}
prompts={prompts}
@ -567,56 +566,63 @@ const PromptForm = () => {
</div>
{/* Mobile Overlay */}
{showSidePanel && (
<div
aria-hidden={!showSidePanel}
className={cn(
'fixed inset-0 z-[100] bg-black/20 lg:hidden',
showSidePanel ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: 'opacity 300ms cubic-bezier(0.2, 0, 0, 1)' }}
>
<button
type="button"
className="absolute inset-0 z-40 cursor-default bg-black/20"
style={{ transition: 'opacity 0.3s ease-in-out' }}
className="h-full w-full cursor-default"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close_menu')}
tabIndex={showSidePanel ? 0 : -1}
/>
)}
</div>
{/* Mobile Versions Panel */}
<div
ref={sidePanelRef}
className="absolute inset-y-0 right-0 z-50 lg:hidden"
className={cn(
'fixed right-0 top-0 z-[110] flex h-full flex-col border-l border-border-medium bg-surface-primary-alt shadow-xl lg:hidden',
showSidePanel ? 'translate-x-0' : 'translate-x-full',
)}
style={{
width: sidePanelWidth,
transform: `translateX(${showSidePanel ? '0' : '100%'})`,
transition: 'transform 0.3s ease-in-out',
willChange: 'transform',
width: 'min(85vw, 380px)',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
role="dialog"
aria-modal="true"
aria-label={localize('com_ui_versions')}
inert={!showSidePanel ? '' : undefined}
>
<div className="h-full bg-surface-primary shadow-xl">
<div className="flex items-center justify-between border-b border-border-light px-4 py-3">
<h2 className="text-lg font-semibold text-text-primary">
{localize('com_ui_versions')}
</h2>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close')}
>
<span className="sr-only">{localize('com_ui_close')}</span>
&times;
</Button>
</div>
<VersionsPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
<div className="flex items-center justify-between px-4 py-2">
<h2 className="text-sm font-semibold text-text-primary">
{localize('com_ui_versions')}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close')}
className="size-8"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
<VersionsPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
</div>
</form>

View file

@ -38,10 +38,10 @@ const PromptLabelsForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
onKeyDown={handleKeyDown}
aria-label={localize('com_ui_add_labels')}
/>
<h3 className="rounded-t-lg border border-border-light px-4 text-base font-semibold text-text-primary">
<h3 className="rounded-t-lg border border-border-medium px-4 text-base font-semibold text-text-primary">
{localize('com_ui_labels')}
</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-light p-4">
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4">
{labels.length ? (
labels.map((label, index) => (
<span

View file

@ -1,4 +1,4 @@
export { PromptsView } from './layouts';
export { InlinePromptsView } from './layouts';
export { CategoryIcon, SkeletonForm } from './utils';
export { PromptName, Command, Description, CategorySelector } from './fields';
export { PreviewPrompt, DeleteVersion, VariableDialog, SharePrompt } from './dialogs';
@ -11,19 +11,5 @@ export {
FilterPrompts,
PanelNavigation,
} from './sidebar';
export {
List as PromptGroupsList,
DashGroupItem,
ChatGroupItem,
ListCard,
NoPromptGroup,
} from './lists';
export {
CreatePromptButton,
AdminSettings,
AdvancedSwitch,
AlwaysMakeProd,
AutoSendPrompt,
BackToChat,
ManagePrompts,
} from './buttons';
export { List as PromptGroupsList, ChatGroupItem, ListCard, NoPromptGroup } from './lists';
export { CreatePromptButton, AdminSettings, AlwaysMakeProd, AutoSendPrompt } from './buttons';

View file

@ -0,0 +1,48 @@
import { useCallback } from 'react';
import { useParams, useNavigate, Navigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import EmptyPromptPreview from '../display/EmptyPromptPreview';
import CreatePromptForm from '../forms/CreatePromptForm';
import { useHasAccess } from '~/hooks';
import PromptForm from '../forms/PromptForm';
export default function InlinePromptsView() {
const { promptId } = useParams();
const navigate = useNavigate();
const isNew = promptId === undefined;
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
});
const handleCreateSuccess = useCallback(
(groupId: string) => {
navigate(`/prompts/${groupId}`, { replace: true });
},
[navigate],
);
if (!hasAccess) {
return <Navigate to="/c/new" replace />;
}
if (isNew && !hasCreateAccess) {
return <EmptyPromptPreview />;
}
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-presentation">
{isNew ? (
<CreatePromptForm onSuccess={handleCreateSuccess} />
) : (
<PromptForm promptId={promptId} />
)}
</div>
);
}

View file

@ -1,140 +0,0 @@
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Sidebar, useMediaQuery } from '@librechat/client';
import { Outlet, useParams, useNavigate, useLocation } from 'react-router-dom';
import { PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider';
import { AdvancedSwitch, AdminSettings } from '~/components/Prompts';
import { useHasAccess, useLocalize, useAuthContext } from '~/hooks';
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
import GroupSidePanel from '../sidebar/GroupSidePanel';
import FilterPrompts from '../sidebar/FilterPrompts';
import { PromptGroupsProvider } from '~/Providers';
import { cn } from '~/utils';
const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/;
export default function PromptsView() {
const params = useParams();
const location = useLocation();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
const isSmallerScreen = useMediaQuery('(max-width: 768px)');
const [panelVisible, setPanelVisible] = useState(!isSmallerScreen);
const openPanelRef = useRef<HTMLButtonElement>(null);
const closePanelRef = useRef<HTMLButtonElement>(null);
const isPromptsPath = useMemo(
() => promptsPathPattern.test(location.pathname),
[location.pathname],
);
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (!hasAccess) {
timeoutId = setTimeout(() => {
navigate('/c/new');
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [hasAccess, navigate]);
const togglePanel = useCallback(() => {
setPanelVisible((prev) => {
const newValue = !prev;
requestAnimationFrame(() => {
if (newValue) {
closePanelRef?.current?.focus();
} else {
openPanelRef?.current?.focus();
}
});
return newValue;
});
}, []);
useEffect(() => {
if (isSmallerScreen && isDetailView) {
setPanelVisible(false);
}
}, [isSmallerScreen, isDetailView]);
if (!hasAccess) {
return null;
}
return (
<PromptGroupsProvider>
<div className="flex h-screen w-full flex-col bg-surface-primary p-0 lg:p-2">
{isSmallerScreen && isDetailView ? (
<div className="mr-2 mt-2 flex h-10 items-center justify-between">
<button
ref={openPanelRef}
type="button"
onClick={togglePanel}
className="ml-2 flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-surface-primary text-text-primary transition-all hover:bg-surface-hover"
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="prompts-panel"
>
<Sidebar className="h-4 w-4" />
</button>
<div className="flex items-center justify-center gap-2">
{isPromptsPath && <AdvancedSwitch />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
</div>
</div>
) : (
<DashBreadcrumb />
)}
<div className="flex w-full flex-grow flex-row overflow-hidden">
{isSmallerScreen && panelVisible && isDetailView && (
<div
className="fixed inset-0 z-40 bg-black/50 transition-opacity"
onClick={togglePanel}
role="button"
tabIndex={0}
aria-label={localize('com_nav_toggle_sidebar')}
/>
)}
{(!isSmallerScreen || !isDetailView || panelVisible) && (
<div
className={cn(
'transition-transform duration-300 ease-in-out',
isSmallerScreen && isDetailView
? 'fixed left-0 top-0 z-50 h-full w-[320px] bg-surface-primary'
: 'flex',
)}
>
<GroupSidePanel
closePanelRef={closePanelRef}
onClose={isSmallerScreen && isDetailView ? togglePanel : undefined}
>
<div className="mt-1 flex flex-row items-center justify-between px-2">
<FilterPrompts dropdownClassName="z-[100]" />
</div>
</GroupSidePanel>
</div>
)}
<div
className={cn(
'scrollbar-gutter-stable min-w-0 flex-1 overflow-y-auto',
isDetailView ? 'block' : 'hidden md:block',
)}
>
<Outlet />
</div>
</div>
</div>
</PromptGroupsProvider>
);
}

View file

@ -1 +1 @@
export { default as PromptsView } from './PromptsView';
export { default as InlinePromptsView } from './InlinePromptsView';

View file

@ -1,36 +1,89 @@
import { useState, memo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, TooltipAnchor } from '@librechat/client';
import { Eye, Pencil, EarthIcon, User } from 'lucide-react';
import { useState, memo, useRef, useCallback, useId, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { useNavigate, useParams } from 'react-router-dom';
import { Ellipsis, Eye, SquarePen, Trash, EarthIcon, User } from 'lucide-react';
import { PermissionBits, ResourceType } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import {
Label,
Button,
Spinner,
OGDialog,
TooltipAnchor,
DropdownPopup,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import { useLocalize, useAuthContext, useSubmitMessage, useResourcePermissions } from '~/hooks';
import { useRecordPromptUsage } from '~/data-provider';
import { useRecordPromptUsage, useDeletePromptGroup } from '~/data-provider';
import { useLiveAnnouncer } from '~/Providers';
import VariableDialog from '../dialogs/VariableDialog';
import PreviewPrompt from '../dialogs/PreviewPrompt';
import { detectVariables } from '~/utils';
import ListCard from './ListCard';
import CategoryIcon from '../utils/CategoryIcon';
import { detectVariables, cn } from '~/utils';
function ChatGroupItem({ group }: { group: TPromptGroup }) {
const PROMPT_PATH = '/prompts';
function ChatGroupItem({
group,
isChatRoute = true,
}: {
group: TPromptGroup;
isChatRoute?: boolean;
}) {
const localize = useLocalize();
const navigate = useNavigate();
const params = useParams();
const { user } = useAuthContext();
const { submitPrompt } = useSubmitMessage();
const recordUsage = useRecordPromptUsage();
const { announcePolite } = useLiveAnnouncer();
const { showToast } = useToastContext();
const menuId = useId();
const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
const [menuOpen, setMenuOpen] = useState(false);
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const groupIsGlobal = group.isPublic === true;
// Check permissions for the promptGroup
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
const canEdit = hasPermission(PermissionBits.EDIT);
const canDelete = hasPermission(PermissionBits.DELETE);
const previewButtonRef = useRef<HTMLButtonElement | null>(null);
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
const deleteGroup = useDeletePromptGroup({
onSuccess: () => {
setDeleteOpen(false);
announcePolite({
message: localize('com_ui_prompt_deleted_group', { 0: group.name }),
isStatus: true,
});
if (!isChatRoute && params.promptId === group._id) {
navigate(`${PROMPT_PATH}/new`, { replace: true });
}
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_prompt_delete_error') });
},
});
const handleDelete = () => {
if (!group._id) {
return;
}
deleteGroup.mutate({ id: group._id });
};
const onCardClick = useCallback(() => {
if (!isChatRoute) {
navigate(`${PROMPT_PATH}/${group._id}`, { replace: true });
return;
}
const onCardClick = () => {
const text = group.productionPrompt?.prompt;
if (!text?.trim()) {
return;
@ -45,91 +98,133 @@ function ChatGroupItem({ group }: { group: TPromptGroup }) {
if (group._id) {
recordUsage.mutate(group._id);
}
};
}, [group, submitPrompt, recordUsage, isChatRoute, navigate]);
const snippet =
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '');
const ariaLabel = group.category
? localize('com_ui_prompt_group_button', { name: group.name, category: group.category })
: localize('com_ui_prompt_group_button_no_category', { name: group.name });
const dropdownItems = useMemo(() => {
const items = [
{
label: localize('com_ui_preview'),
onClick: () => setPreviewDialogOpen(true),
icon: <Eye className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
},
];
if (canEdit) {
items.push({
label: localize('com_ui_edit'),
onClick: () => navigate(`${PROMPT_PATH}/${group._id}`),
icon: <SquarePen className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
});
}
if (canDelete) {
items.push({
label: localize('com_ui_delete'),
onClick: () => setDeleteOpen(true),
icon: <Trash className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
});
}
return items;
}, [localize, canEdit, canDelete, group._id, navigate]);
return (
<>
<div className="mb-2 rounded-xl border border-border-light bg-transparent px-1 hover:bg-surface-secondary">
<ListCard
name={group.name}
category={group.category ?? ''}
<div
className={cn(
'group/prompt relative mb-1.5 rounded-xl border border-border-light bg-transparent transition-colors hover:bg-surface-secondary',
!isChatRoute && params.promptId === group._id && 'bg-surface-hover',
)}
>
{/* Clickable overlay for card */}
<button
type="button"
className="absolute inset-0 z-0 rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
onClick={onCardClick}
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}
icon={
isSharedPrompt || groupIsGlobal ? (
<>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 cursor-default items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<User className="icon-md text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{groupIsGlobal && (
<EarthIcon
className="icon-md shrink-0 text-green-400"
aria-label={localize('com_ui_sr_global_prompt')}
/>
)}
</>
) : undefined
}
>
<div className="flex items-center gap-1">
<TooltipAnchor
description={localize('com_ui_preview')}
side="top"
render={
<Button
ref={previewButtonRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_preview')}
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
>
<Eye className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
{canEdit && (
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_edit')}
onClick={(e) => {
e.stopPropagation();
navigate(`/d/prompts/${group._id}`);
}}
>
<Pencil className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
)}
aria-label={ariaLabel}
/>
<div className="flex items-start gap-2.5 px-3 py-2.5">
<CategoryIcon
category={group.category ?? ''}
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold text-text-primary" title={group.name}>
{group.name}
</span>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 items-center"
>
<User className="size-3.5 text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{groupIsGlobal && (
<TooltipAnchor
description={localize('com_ui_sr_global_prompt')}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_sr_global_prompt')}
className="flex shrink-0 items-center"
>
<EarthIcon className="size-3.5 text-green-400" aria-hidden="true" />
</span>
}
/>
)}
</div>
<p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-text-secondary">
{snippet}
</p>
</div>
</ListCard>
<div className="relative z-10 shrink-0">
<DropdownPopup
portal={true}
menuId={menuId}
focusLoop={true}
className="z-[125]"
unmountOnHide={true}
isOpen={menuOpen}
setIsOpen={setMenuOpen}
trigger={
<Ariakit.MenuButton
ref={menuButtonRef}
aria-label={localize('com_nav_convo_menu_options')}
className={cn(
'flex size-7 items-center justify-center rounded-md text-text-secondary transition-opacity hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
menuOpen
? 'opacity-100'
: 'opacity-0 focus-visible:opacity-100 group-hover/prompt:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
>
<Ellipsis className="size-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={dropdownItems}
/>
</div>
</div>
</div>
<PreviewPrompt
group={group}
@ -137,15 +232,29 @@ function ChatGroupItem({ group }: { group: TPromptGroup }) {
onOpenChange={setPreviewDialogOpen}
onCloseAutoFocus={() => {
requestAnimationFrame(() => {
previewButtonRef.current?.focus({ preventScroll: true });
menuButtonRef.current?.focus({ preventScroll: true });
});
}}
/>
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
{isChatRoute && (
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
)}
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<OGDialogTemplate
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-md"
main={<Label>{localize('com_ui_prompt_delete_confirm', { 0: group.name })}</Label>}
selection={
<Button onClick={handleDelete} variant="destructive" disabled={deleteGroup.isLoading}>
{deleteGroup.isLoading ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
</>
);
}

View file

@ -1,223 +0,0 @@
import { memo, useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { EarthIcon, Pencil, Trash2, User } from 'lucide-react';
import { PermissionBits, ResourceType, type TPromptGroup } from 'librechat-data-provider';
import {
Input,
Label,
Button,
Spinner,
OGDialog,
TooltipAnchor,
OGDialogTrigger,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import { useLocalize, useAuthContext, useResourcePermissions } from '~/hooks';
import { useLiveAnnouncer } from '~/Providers';
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
import CategoryIcon from '../utils/CategoryIcon';
import { cn } from '~/utils';
function DashGroupItemComponent({ group }: { group: TPromptGroup }) {
const params = useParams();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
const { showToast } = useToastContext();
const { announcePolite } = useLiveAnnouncer();
const [nameInputValue, setNameInputValue] = useState(group.name);
const [renameOpen, setRenameOpen] = useState(false);
useEffect(() => {
if (!renameOpen) {
setNameInputValue(group.name);
}
}, [group.name, renameOpen]);
const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
const canEdit = hasPermission(PermissionBits.EDIT);
const canDelete = hasPermission(PermissionBits.DELETE);
const isGlobalGroup = group.isPublic === true;
const updateGroup = useUpdatePromptGroup({
onSuccess: () => {
setRenameOpen(false);
showToast({ status: 'success', message: localize('com_ui_prompt_renamed') });
announcePolite({ message: localize('com_ui_prompt_renamed'), isStatus: true });
},
onError: () => {
showToast({ status: 'error', message: localize('com_ui_prompt_update_error') });
},
});
const deleteGroup = useDeletePromptGroup({
onSuccess: (_response, variables) => {
announcePolite({
message: localize('com_ui_prompt_deleted_group', { 0: group.name }),
isStatus: true,
});
if (variables.id === group._id) {
navigate('/d/prompts');
}
},
});
const { isLoading: isSaving } = updateGroup;
const isDeleting = deleteGroup.isLoading;
const updateGroupRef = useRef(updateGroup);
updateGroupRef.current = updateGroup;
const deleteGroupRef = useRef(deleteGroup);
deleteGroupRef.current = deleteGroup;
const handleSaveRename = useCallback(() => {
updateGroupRef.current.mutate({ id: group._id ?? '', payload: { name: nameInputValue } });
}, [group._id, nameInputValue]);
const handleDelete = useCallback(() => {
deleteGroupRef.current.mutate({ id: group._id ?? '' });
}, [group._id]);
const handleContainerClick = useCallback(() => {
navigate(`/d/prompts/${group._id}`, { replace: true });
}, [group._id, navigate]);
const ariaLabel = group.category
? localize('com_ui_prompt_group_button', {
name: group.name,
category: group.category,
})
: localize('com_ui_prompt_group_button_no_category', {
name: group.name,
});
return (
<article
className={cn(
'group/card relative flex w-full items-center overflow-hidden rounded-lg border border-border-light bg-transparent text-left hover:bg-surface-secondary',
params.promptId === group._id && 'bg-surface-hover',
)}
>
<div className="flex w-0 min-w-0 flex-1 items-center gap-2 overflow-hidden p-4">
<CategoryIcon
category={group.category ?? ''}
className="icon-lg shrink-0"
aria-hidden="true"
/>
<a
href={`/d/prompts/${group._id}`}
onClick={(e) => {
e.preventDefault();
handleContainerClick();
}}
className="min-w-0 flex-1 truncate text-base font-semibold text-text-primary after:absolute after:inset-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary focus-visible:ring-offset-2"
title={group.name}
aria-label={ariaLabel}
>
{group.name}
</a>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 cursor-default items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<User className="icon-md text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{isGlobalGroup && (
<EarthIcon
className="icon-md shrink-0 text-green-400"
aria-label={localize('com_ui_global_group')}
/>
)}
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1 pr-2">
{canEdit && (
<OGDialog open={renameOpen} onOpenChange={setRenameOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_rename')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
>
<Pencil className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_rename_prompt')}
className="w-11/12 max-w-md"
main={
<Input
value={nameInputValue}
onChange={(e) => setNameInputValue(e.target.value)}
className="w-full"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
/>
}
selection={
<Button onClick={handleSaveRename} variant="submit">
{isSaving ? <Spinner /> : localize('com_ui_save')}
</Button>
}
/>
</OGDialog>
)}
{canDelete && (
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_delete_prompt_name', { name: group.name })}
>
<Trash2 className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-md"
main={<Label>{localize('com_ui_prompt_delete_confirm', { 0: group.name })}</Label>}
selection={
<Button onClick={handleDelete} variant="destructive">
{isDeleting ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
)}
</div>
</article>
);
}
export default memo(DashGroupItemComponent);

View file

@ -1,65 +1,44 @@
import { FileText } from 'lucide-react';
import { Skeleton } from '@librechat/client';
import type { TPromptGroup } from 'librechat-data-provider';
import DashGroupItem from './DashGroupItem';
import ChatGroupItem from './ChatGroupItem';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function List({
groups = [],
isChatRoute,
isLoading,
isChatRoute,
}: {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
isChatRoute?: boolean;
}) {
const localize = useLocalize();
return (
<div className="flex h-full flex-col">
<section className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading && !isChatRoute && (
<div className="space-y-2 px-2">
{Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton key={index} className="flex h-14 w-full rounded-lg border-0 p-4" />
))}
<section className="flex-grow" aria-label={localize('com_ui_prompt_groups')}>
<div>
{isLoading &&
Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="mb-1.5 h-[72px] w-full rounded-xl" />
))}
{!isLoading && groups.length === 0 && (
<div className="my-2 flex flex-col items-center justify-center rounded-lg border border-border-medium bg-transparent p-6 text-center">
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<FileText className="size-5 text-text-secondary" aria-hidden="true" />
</div>
)}
{!isLoading && groups.length === 0 && (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center',
isChatRoute ? 'my-2' : 'mx-2 my-4',
)}
>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<FileText className="size-5 text-text-secondary" aria-hidden="true" />
</div>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_prompts_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">
{localize('com_ui_add_first_prompt')}
</p>
</div>
)}
{isChatRoute ? (
groups.map((group) => <ChatGroupItem key={group._id} group={group} />)
) : (
<div className="space-y-2 px-0 md:px-2">
{groups.map((group) => (
<DashGroupItem key={group._id} group={group} />
))}
</div>
)}
</div>
</section>
</div>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_prompts_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">
{localize('com_ui_add_first_prompt')}
</p>
</div>
)}
{groups.map((group) => (
<ChatGroupItem key={group._id} group={group} isChatRoute={isChatRoute} />
))}
</div>
</section>
);
}

View file

@ -15,7 +15,7 @@ export default function NoPromptGroup() {
<Button
className="mt-4"
onClick={() => {
navigate('/d/prompts');
navigate('/prompts');
}}
aria-label={localize('com_ui_back_to_prompts')}
>

View file

@ -1,5 +1,4 @@
export { default as List } from './List';
export { default as ListCard } from './ListCard';
export { default as DashGroupItem } from './DashGroupItem';
export { default as ChatGroupItem } from './ChatGroupItem';
export { default as NoPromptGroup } from './NoPromptGroup';

View file

@ -18,7 +18,7 @@ export default function FilterPrompts({
dropdownClassName?: string;
}) {
const localize = useLocalize();
const { name, setName, hasAccess, promptGroups } = usePromptGroupsContext();
const { name, setName, hasAccess, promptGroups } = usePromptGroupsContext() ?? {};
const { categories } = useCategories({ className: 'h-4 w-4', hasAccess });
const [searchTerm, setSearchTerm] = useState(name || '');
const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory);
@ -77,6 +77,9 @@ export default function FilterPrompts({
}, [name]);
useEffect(() => {
if (!setName) {
return;
}
setName(debouncedSearchTerm);
}, [debouncedSearchTerm, setName]);

View file

@ -1,78 +1,38 @@
import { useMemo, useCallback } from 'react';
import { ArrowLeft } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { Button, Sidebar, TooltipAnchor } from '@librechat/client';
import { usePromptGroupsContext, useDashboardContext } from '~/Providers';
import { useLocalize, useCustomLink } from '~/hooks';
import ManagePrompts from '../buttons/ManagePrompts';
import { usePromptGroupsContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import PanelNavigation from './PanelNavigation';
import List from '../lists/List';
import { cn } from '~/utils';
import store from '~/store';
export default function GroupSidePanel({
children,
className = '',
closePanelRef,
onClose,
isChatRoute: isChatRouteProp,
}: {
children?: React.ReactNode;
className?: string;
closePanelRef?: React.RefObject<HTMLButtonElement>;
onClose?: () => void;
isChatRoute?: boolean;
}) {
const location = useLocation();
const localize = useLocalize();
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
const isChatRoute = isChatRouteProp ?? location.pathname?.startsWith('/c/') ?? false;
const { prevLocationPath } = useDashboardContext();
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const lastConversationId = useMemo(() => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) {
return 'new';
}
const parts = prevLocationPath.split('/');
return parts[parts.length - 1];
}, [prevLocationPath]);
const chatLinkHandler = useCustomLink('/c/' + lastConversationId, clickCallback);
const promptsLinkHandler = useCustomLink('/d/prompts');
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } =
usePromptGroupsContext();
const context = usePromptGroupsContext();
if (!context) {
return null;
}
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } = context;
return (
<div
id="prompts-panel"
className={cn('flex h-full w-full flex-col md:mr-2 md:w-[450px] md:shrink-0', className)}
>
<div id="prompts-panel" className={cn('flex h-full w-full flex-col', className)}>
{onClose && (
<div className="flex items-center justify-between px-2 py-[2px] md:py-2">
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm">
<a
href="/"
onClick={chatLinkHandler}
className="flex items-center gap-1 text-text-secondary hover:text-text-primary"
>
<ArrowLeft className="icon-xs" aria-hidden="true" />
<span>{localize('com_ui_chat')}</span>
</a>
<span className="text-text-tertiary" aria-hidden="true">
/
</span>
<a
href="/d/prompts"
onClick={promptsLinkHandler}
className="text-text-secondary hover:text-text-primary"
>
{localize('com_ui_prompts')}
</a>
</nav>
<div className="flex items-center justify-end px-2 py-[2px] md:py-2">
<TooltipAnchor
description={localize('com_nav_close_sidebar')}
render={
@ -92,27 +52,31 @@ export default function GroupSidePanel({
/>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-2 overflow-hidden">
{children}
<div className={cn('relative flex h-full flex-col', isChatRoute ? '' : 'px-2 md:px-0')}>
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="scrollbar-gutter-stable flex h-full min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden pl-3 pr-1 text-text-primary">
<div className="shrink-0 space-y-2">{children}</div>
<List
groups={promptGroups}
isChatRoute={isChatRoute}
isLoading={!!groupsQuery.isLoading}
isChatRoute={isChatRoute}
/>
</div>
</div>
<div className={cn(isChatRoute ? '' : 'px-2 pb-3 pt-2 md:px-0')}>
<PanelNavigation
onPrevious={prevPage}
onNext={nextPage}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
isLoading={groupsQuery.isFetching}
isChatRoute={isChatRoute}
<div
className={cn(
'pointer-events-none inset-x-0 bottom-0 bg-gradient-to-t from-surface-primary-alt from-60% to-transparent px-3 pb-2',
)}
>
{isChatRoute && <ManagePrompts className="select-none" />}
</PanelNavigation>
<div className="pointer-events-auto">
<PanelNavigation
onPrevious={prevPage}
onNext={nextPage}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
isLoading={groupsQuery.isFetching}
isChatRoute={isChatRoute}
/>
</div>
</div>
</div>
</div>
);

View file

@ -1,5 +1,5 @@
import { memo } from 'react';
import { Button, ThemeSelector } from '@librechat/client';
import { Button } from '@librechat/client';
import { useLocalize } from '~/hooks';
function PanelNavigation({
@ -8,7 +8,6 @@ function PanelNavigation({
hasNextPage,
hasPreviousPage,
isLoading,
isChatRoute,
children,
}: {
onPrevious: () => void;
@ -23,10 +22,7 @@ function PanelNavigation({
return (
<div className="flex items-center justify-between">
<div className="flex gap-2 pl-1">
{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
{children}
</div>
<div className="flex gap-2 pl-1">{children}</div>
<nav className="flex items-center gap-2" aria-label={localize('com_ui_pagination')}>
<Button
variant="outline"

View file

@ -1,21 +1,17 @@
import { usePromptGroupsContext } from '~/Providers';
import { SystemRoles } from 'librechat-data-provider';
import { useAuthContext } from '~/hooks';
import { AdminSettings } from '~/components/Prompts';
import AutoSendPrompt from '../buttons/AutoSendPrompt';
import PromptSidePanel from './GroupSidePanel';
import FilterPrompts from './FilterPrompts';
export default function PromptsAccordion() {
const groupsNav = usePromptGroupsContext();
const { user } = useAuthContext();
return (
<div className="flex h-auto w-full flex-col px-3 pb-3">
<PromptSidePanel
className="h-auto space-y-2 md:mr-0 md:min-w-0 lg:w-full xl:w-full"
{...groupsNav}
>
<FilterPrompts />
<div className="flex w-full items-center justify-end">
<AutoSendPrompt />
</div>
</PromptSidePanel>
</div>
<PromptSidePanel className="space-y-2">
<FilterPrompts />
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
<AutoSendPrompt />
</PromptSidePanel>
);
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import {
Dices,
BoxIcon,
FileText,
PenLineIcon,
LightbulbIcon,
LineChartIcon,
@ -9,7 +10,6 @@ import {
PlaneTakeoffIcon,
GraduationCapIcon,
TerminalSquareIcon,
// NEW: Add these for agent categories
Users as UsersIcon,
Beaker as BeakerIcon,
Settings as SettingsIcon,
@ -26,7 +26,6 @@ const categoryIconMap: Record<string, React.ElementType> = {
code: TerminalSquareIcon,
travel: PlaneTakeoffIcon,
teach_or_explain: GraduationCapIcon,
// NEW: Agent categories
general: BoxIcon,
hr: UsersIcon,
rd: BeakerIcon,
@ -39,13 +38,12 @@ const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-500/90 dark:text-yellow-300 ',
idea: 'text-yellow-500/90 dark:text-yellow-300',
write: 'text-purple-400',
travel: 'text-yellow-500/90 dark:text-yellow-300 ',
travel: 'text-yellow-500/90 dark:text-yellow-300',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
// NEW: Agent categories
general: 'text-blue-500',
hr: 'text-green-500',
rd: 'text-purple-500',
@ -61,10 +59,7 @@ export default function CategoryIcon({
category: string;
className?: string;
}) {
const IconComponent = categoryIconMap[category];
const colorClass = categoryColorMap[category] + ' ' + className;
if (!IconComponent) {
return null;
}
const IconComponent = categoryIconMap[category] ?? FileText;
const colorClass = categoryColorMap[category] ?? 'text-text-secondary';
return <IconComponent className={cn('size-4', colorClass, className)} aria-hidden="true" />;
}

View file

@ -1,14 +1,12 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useMediaQuery, NewChatIcon } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { List } from 'react-virtualized';
import {
useLocalize,
useNewConvo,
useHasAccess,
useAuthContext,
useLocalStorage,
@ -17,18 +15,14 @@ import {
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import SearchBar from '~/components/Nav/SearchBar';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav'));
const ConversationsSection = memo(() => {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const conversation = useRecoilValue(store.conversationByIndex(0));
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
@ -122,32 +116,6 @@ const ConversationsSection = memo(() => {
)}
{search.enabled && <SearchBar isSmallScreen={isSmallScreen} />}
</div>
{isSmallScreen && (
<div
role="button"
tabIndex={0}
aria-label={localize('com_ui_new_chat')}
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-2 text-sm text-text-primary outline-none hover:bg-surface-active-alt focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
onClick={() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
setSidebarExpanded(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
setSidebarExpanded(false);
}
}}
>
<NewChatIcon className="mr-2 h-5 w-5" />
<span className="truncate">{localize('com_ui_new_chat')}</span>
</div>
)}
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}

View file

@ -1,8 +1,9 @@
import { memo, useCallback, lazy, Suspense } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue } from 'recoil';
import { SquarePen } from 'lucide-react';
import { QueryKeys } from 'librechat-data-provider';
import { Skeleton, Sidebar, Button, TooltipAnchor, NewChatIcon } from '@librechat/client';
import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client';
import type { NavLink } from '~/common';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useActivePanel, resolveActivePanel } from '~/Providers';
@ -20,13 +21,12 @@ const NewChatButton = memo(function NewChatButton() {
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
return;
if (e.button === 0 && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
},
[queryClient, conversation?.conversationId, newConversation],
);
@ -43,9 +43,7 @@ const NewChatButton = memo(function NewChatButton() {
className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-surface-hover"
onClick={handleClick}
>
<div className="flex size-6 items-center justify-center rounded-full bg-text-primary">
<NewChatIcon className="size-3.5 text-white dark:text-black" />
</div>
<SquarePen className="h-5 w-5 text-text-primary" />
</a>
}
/>
@ -99,7 +97,7 @@ const NavIconButton = memo(function NavIconButton({
)}
onClick={handleClick}
>
<link.icon className="h-4 w-4" aria-hidden="true" />
<link.icon className="h-5 w-5" aria-hidden="true" />
</Button>
}
/>
@ -145,6 +143,7 @@ function ExpandedPanel({
}
/>
<NewChatButton />
<div className="mx-2 border-b border-border-light" />
<div className="flex flex-col gap-1 overflow-y-auto">
{links.map((link) => (
<NavIconButton

View file

@ -144,7 +144,7 @@ function UnifiedSidebar() {
width: 'min(85vw, 380px)',
transition: `transform ${TRANSITION_MS}ms ${EASING}`,
}}
{...{ inert: !expanded ? '' : undefined }}
inert={!expanded ? '' : undefined}
>
<SidebarChatProvider>
<ActivePanelProvider>

View file

@ -31,7 +31,11 @@ import { SESSION_KEY, isSafeRedirect, getPostLoginRedirect } from '~/utils';
import useTimeout from './useTimeout';
import store from '~/store';
const AuthContext = createContext<TAuthContext | undefined>(undefined);
const AuthContext = (import.meta.hot?.data?.__AuthContext ??
createContext<TAuthContext | undefined>(undefined)) as React.Context<TAuthContext | undefined>;
if (import.meta.hot) {
import.meta.hot.data.__AuthContext = AuthContext;
}
const AuthContextProvider = ({
authConfig,

View file

@ -17,11 +17,11 @@ import {
useGetAllEffectivePermissionsQuery,
} from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
import type { MCPServerInitState } from '~/store/mcp';
import type { ConfigFieldDetail } from '~/common';
import { useLocalize, useHasAccess, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
import { mcpServerInitStatesAtom, getServerInitState } from '~/store/mcp';
import type { MCPServerInitState } from '~/store/mcp';
export interface MCPServerDefinition {
serverName: string;

View file

@ -1,6 +1,13 @@
import { useMemo } from 'react';
import { Blocks, MCPIcon, AttachmentIcon } from '@librechat/client';
import { Database, Bookmark, Settings2, ArrowRightToLine, MessageSquareQuote } from 'lucide-react';
import { MCPIcon, AttachmentIcon, OpenAIMinimalIcon } from '@librechat/client';
import {
Bot,
Brain,
Bookmark,
NotebookPen,
ArrowRightToLine,
SlidersHorizontal,
} from 'lucide-react';
import {
Permissions,
EModelEndpoint,
@ -74,6 +81,22 @@ export default function useSideNavLinks({
const Links = useMemo(() => {
const links: NavLink[] = [];
if (
endpointsConfig?.[EModelEndpoint.agents] &&
hasAccessToAgents &&
hasAccessToCreateAgents &&
endpointsConfig[EModelEndpoint.agents].disableBuilder !== true
) {
links.push({
title: 'com_sidepanel_agent_builder',
label: '',
icon: Bot,
id: EModelEndpoint.agents,
Component: AgentPanelSwitch,
});
}
if (
isAssistantsEndpoint(endpoint) &&
((endpoint === EModelEndpoint.assistants &&
@ -87,32 +110,17 @@ export default function useSideNavLinks({
links.push({
title: 'com_sidepanel_assistant_builder',
label: '',
icon: Blocks,
icon: OpenAIMinimalIcon,
id: EModelEndpoint.assistants,
Component: PanelSwitch,
});
}
if (
endpointsConfig?.[EModelEndpoint.agents] &&
hasAccessToAgents &&
hasAccessToCreateAgents &&
endpointsConfig[EModelEndpoint.agents].disableBuilder !== true
) {
links.push({
title: 'com_sidepanel_agent_builder',
label: '',
icon: Blocks,
id: EModelEndpoint.agents,
Component: AgentPanelSwitch,
});
}
if (hasAccessToPrompts) {
links.push({
title: 'com_ui_prompts',
label: '',
icon: MessageSquareQuote,
icon: NotebookPen,
id: 'prompts',
Component: PromptsAccordion,
});
@ -122,24 +130,19 @@ export default function useSideNavLinks({
links.push({
title: 'com_ui_memories',
label: '',
icon: Database,
icon: Brain,
id: 'memories',
Component: MemoryPanel,
});
}
if (
interfaceConfig.parameters === true &&
isParamEndpoint(endpoint ?? '', endpointType ?? '') === true &&
!isAgentsEndpoint(endpoint) &&
keyProvided
) {
if (hasAccessToBookmarks) {
links.push({
title: 'com_sidepanel_parameters',
title: 'com_sidepanel_conversation_tags',
label: '',
icon: Settings2,
id: 'parameters',
Component: Parameters,
icon: Bookmark,
id: 'bookmarks',
Component: BookmarkPanel,
});
}
@ -151,13 +154,18 @@ export default function useSideNavLinks({
Component: FilesPanel,
});
if (hasAccessToBookmarks) {
if (
interfaceConfig.parameters === true &&
isParamEndpoint(endpoint ?? '', endpointType ?? '') === true &&
!isAgentsEndpoint(endpoint) &&
keyProvided
) {
links.push({
title: 'com_sidepanel_conversation_tags',
title: 'com_sidepanel_parameters',
label: '',
icon: Bookmark,
id: 'bookmarks',
Component: BookmarkPanel,
icon: SlidersHorizontal,
id: 'parameters',
Component: Parameters,
});
}

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { MessageSquare } from 'lucide-react';
import { MessagesSquare } from 'lucide-react';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { getConfigDefaults, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
@ -53,7 +53,7 @@ export default function useUnifiedSidebarLinks() {
const conversationLink: NavLink = {
title: 'com_ui_chat_history',
label: '',
icon: MessageSquare,
icon: MessagesSquare,
id: 'conversations',
Component: ConversationsSection,
};

View file

@ -400,6 +400,8 @@
"com_info_heic_converting": "Converting HEIC image to JPEG...",
"com_nav_2fa": "Two-Factor Authentication (2FA)",
"com_nav_account_settings": "Account Settings",
"com_nav_advanced_prompts": "Advanced prompts editor",
"com_nav_advanced_prompts_desc": "Enable versioning and production control for prompts",
"com_nav_always_make_prod": "Always make new prompt versions production",
"com_nav_archive_created_at": "Date Archived",
"com_nav_archive_name": "Name",
@ -595,7 +597,6 @@
"com_nav_theme_dark": "Dark",
"com_nav_theme_light": "Light",
"com_nav_theme_system": "System",
"com_nav_toggle_sidebar": "Toggle sidebar",
"com_nav_tool_dialog": "Assistant Tools",
"com_nav_tool_dialog_agents": "Agent Tools",
"com_nav_tool_dialog_description": "Assistant must be saved to persist tool selections.",
@ -770,7 +771,6 @@
"com_ui_azure_ad": "Entra ID",
"com_ui_back": "Back",
"com_ui_back_to_builder": "Back to builder",
"com_ui_back_to_chat": "Back to Chat",
"com_ui_back_to_prompts": "Back to Prompts",
"com_ui_backup_code_number": "Code #{{number}}",
"com_ui_backup_codes": "Backup Codes",
@ -889,7 +889,6 @@
"com_ui_custom_header_name": "Custom Header Name",
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
"com_ui_dark_theme_enabled": "Dark theme enabled",
"com_ui_dashboard": "Dashboard",
"com_ui_date": "Date",
"com_ui_date_april": "April",
"com_ui_date_august": "August",
@ -928,7 +927,6 @@
"com_ui_delete_not_allowed": "Delete operation is not allowed",
"com_ui_delete_preset": "Delete Preset?",
"com_ui_delete_prompt": "Delete Prompt?",
"com_ui_delete_prompt_name": "Delete Prompt - {{name}}",
"com_ui_delete_shared_link": "Delete shared link?",
"com_ui_delete_shared_link_heading": "Delete Shared Link",
"com_ui_delete_success": "Successfully deleted",
@ -1054,7 +1052,6 @@
"com_ui_generating_image": "Generating image...",
"com_ui_generation_settings": "Generation Settings",
"com_ui_getting_started": "Getting Started",
"com_ui_global_group": "Global prompt",
"com_ui_go_back": "Go back",
"com_ui_go_to_conversation": "Go to conversation",
"com_ui_good_afternoon": "Good afternoon",
@ -1278,6 +1275,7 @@
"com_ui_prompt": "Prompt",
"com_ui_prompt_category_selector_aria": "Prompt's category selector",
"com_ui_prompt_delete_confirm": "Are you sure you want to delete the '{{0}}' prompt?",
"com_ui_prompt_delete_error": "There was an error deleting the prompt",
"com_ui_prompt_deleted_group": "Prompt group \"{{0}}\" deleted",
"com_ui_prompt_details": "{{name}} prompt details",
"com_ui_prompt_group_button": "{{name}} prompt, {{category}} category",
@ -1288,7 +1286,6 @@
"com_ui_prompt_name": "Prompt Name",
"com_ui_prompt_name_required": "Prompt Name is required",
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.",
"com_ui_prompt_renamed": "Prompt renamed successfully",
"com_ui_prompt_text": "Text",
"com_ui_prompt_text_required": "Text is required",
"com_ui_prompt_update_error": "There was an error updating the prompt",
@ -1332,8 +1329,6 @@
"com_ui_rename": "Rename",
"com_ui_rename_conversation": "Rename Conversation",
"com_ui_rename_failed": "Failed to rename conversation",
"com_ui_rename_prompt": "Rename Prompt",
"com_ui_rename_prompt_name": "Rename Prompt - {{name}}",
"com_ui_requires_auth": "Requires Authentication",
"com_ui_reset": "Reset",
"com_ui_reset_adjustments": "Reset adjustments",

View file

@ -400,7 +400,7 @@
"com_info_heic_converting": "Konvertēju HEIC attēlu uz JPEG...",
"com_nav_2fa": "Divfaktoru autentifikācija (2FA)",
"com_nav_account_settings": "Konta iestatījumi",
"com_nav_always_make_prod": "Vienmēr uzlieciet jaunas versijas produkcijā",
"com_nav_always_make_prod": "Vienmēr producēt jaunāko uzvednes versiju",
"com_nav_archive_created_at": "Arhivēšanas datums",
"com_nav_archive_name": "Vārds",
"com_nav_archived_chats": "Arhivētās sarunas",
@ -410,7 +410,7 @@
"com_nav_audio_process_error": "Kļūda, apstrādājot audio: {{0}}",
"com_nav_auto_expand_tools": "Automātiski paplašināt rīka informāciju",
"com_nav_auto_scroll": "Automātiski iet uz jaunāko ziņu, atverot sarunu",
"com_nav_auto_send_prompts": "Automātiski sūtīt uzvednes",
"com_nav_auto_send_prompts": "Atlasot uzvedi, nosūtīt to automātiski uzreiz",
"com_nav_auto_send_prompts_desc": "Automātiski iesniegt uzaicinājumu sarunai, kad tas ir atlasīts",
"com_nav_auto_send_text": "Automātiski nosūtīt tekstu",
"com_nav_auto_transcribe_audio": "Automātiski transkribēt audio",
@ -1056,7 +1056,7 @@
"com_ui_generating_image": "Attēla ģenerēšana...",
"com_ui_generation_settings": "Ģenerēšanas iestatījumi",
"com_ui_getting_started": "Darba sākšana",
"com_ui_global_group": "Nav rezultātu",
"com_ui_global_group": "Globālā uzvedne",
"com_ui_go_back": "Atgriezties",
"com_ui_go_to_conversation": "Doties uz sarunu",
"com_ui_good_afternoon": "Labdien",
@ -1445,7 +1445,7 @@
"com_ui_special_var_desc_iso_datetime": "UTC datuma laiks ISO 8601 formātā",
"com_ui_special_var_iso_datetime": "UTC ISO datums un laiks",
"com_ui_special_variable_added": "{{0}} pievienots īpašs mainīgais.",
"com_ui_special_variables": "Īpašie mainīgie:",
"com_ui_special_variables": "Īpašie mainīgie",
"com_ui_speech_not_supported": "Jūsu pārlūkprogramma neatbalsta runas atpazīšanu",
"com_ui_speech_not_supported_use_external": "Jūsu pārlūkprogramma neatbalsta runas atpazīšanu. Mēģiniet pārslēgties uz ārējo STT sadaļā Iestatījumi > Runa.",
"com_ui_speech_while_submitting": "Nevar nosūtīt runu, kamēr tiek ģenerēta atbilde.",

View file

@ -1,80 +1,23 @@
import { Navigate } from 'react-router-dom';
import {
PromptsView,
PromptForm,
CreatePromptForm,
EmptyPromptPreview,
} from '~/components/Prompts';
import { Navigate, useParams } from 'react-router-dom';
import DashboardRoute from './Layouts/Dashboard';
function PromptsRedirect() {
const { '*': splat } = useParams();
const target = splat ? `/prompts/${splat}` : '/prompts/new';
return <Navigate to={target} replace={true} />;
}
const dashboardRoutes = {
path: 'd/*',
element: <DashboardRoute />,
children: [
/*
{
element: <FileDashboardView />,
children: [
{
index: true,
element: <EmptyVectorStorePreview />,
},
{
path: ':vectorStoreId',
element: <DataTableFilePreview />,
},
],
},
{
path: 'files/*',
element: <FilesListView />,
children: [
{
index: true,
element: <EmptyFilePreview />,
},
{
path: ':fileId',
element: <FilePreview />,
},
],
},
{
path: 'vector-stores/*',
element: <VectorStoreView />,
children: [
{
index: true,
element: <EmptyVectorStorePreview />,
},
{
path: ':vectorStoreId',
element: <VectorStorePreview />,
},
],
},
*/
{
path: 'prompts/*',
element: <PromptsView />,
children: [
{
index: true,
element: <EmptyPromptPreview />,
},
{
path: 'new',
element: <CreatePromptForm />,
},
{
path: ':promptId',
element: <PromptForm />,
},
],
element: <PromptsRedirect />,
},
{
path: '*',
element: <Navigate to="/d/files" replace={true} />,
element: <Navigate to="/c/new" replace={true} />,
},
],
};

View file

@ -1,104 +0,0 @@
import { useMemo, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { SystemRoles } from 'librechat-data-provider';
import { ArrowLeft, MessageSquareQuote } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '@librechat/client';
import { useLocalize, useCustomLink, useAuthContext } from '~/hooks';
import { AdvancedSwitch, AdminSettings } from '~/components/Prompts';
import { useDashboardContext } from '~/Providers';
import store from '~/store';
const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/;
const getConversationId = (prevLocationPath: string) => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) {
return 'new';
}
const lastPathnameParts = prevLocationPath.split('/');
return lastPathnameParts[lastPathnameParts.length - 1];
};
export default function DashBreadcrumb() {
const location = useLocation();
const localize = useLocalize();
const { user } = useAuthContext();
const { prevLocationPath } = useDashboardContext();
const lastConversationId = useMemo(() => getConversationId(prevLocationPath), [prevLocationPath]);
const setPromptsName = useSetRecoilState(store.promptsName);
const setPromptsCategory = useSetRecoilState(store.promptsCategory);
const clickCallback = useCallback(() => {
setPromptsName('');
setPromptsCategory('');
}, [setPromptsName, setPromptsCategory]);
const chatLinkHandler = useCustomLink('/c/' + lastConversationId, clickCallback);
const promptsLinkHandler = useCustomLink('/d/prompts');
const isPromptsPath = useMemo(
() => promptsPathPattern.test(location.pathname),
[location.pathname],
);
return (
<div className="mr-2 mt-2 flex h-10 items-center justify-between">
<Breadcrumb className="mt-1 px-2 dark:text-gray-200">
<BreadcrumbList>
<BreadcrumbItem className="hover:dark:text-white">
<BreadcrumbLink
href="/"
className="flex flex-row items-center gap-1"
onClick={chatLinkHandler}
>
<ArrowLeft className="icon-xs" aria-hidden="true" />
<span className="hidden md:flex">{localize('com_ui_back_to_chat')}</span>
<span className="flex md:hidden">{localize('com_ui_chat')}</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
{/*
<BreadcrumbItem className="hover:dark:text-white">
<DropdownMenu>
<DropdownMenuTrigger className="flex cursor-default items-center gap-1">
<BreadcrumbEllipsis className="h-4 w-4" />
<BreadcrumbItem className="hover:dark:text-white">
<span className="text-gray-400">{localize('com_ui_dashboard')}</span>
</BreadcrumbItem>
<span className="sr-only">Toggle menu</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>Documentation</DropdownMenuItem>
<DropdownMenuItem>Themes</DropdownMenuItem>
<DropdownMenuItem>GitHub</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
*/}
<BreadcrumbItem className="hover:dark:text-white">
<BreadcrumbLink
href="/d/prompts"
className="flex flex-row items-center gap-1"
onClick={promptsLinkHandler}
>
<MessageSquareQuote className="h-4 w-4 dark:text-gray-300" aria-hidden="true" />
{localize('com_ui_prompts')}
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center justify-center gap-2">
{isPromptsPath && <AdvancedSwitch />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
</div>
</div>
);
}

View file

@ -1,36 +1,12 @@
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthContext, usePreviousLocation } from '~/hooks';
import { DashboardContext } from '~/Providers';
import store from '~/store';
import { useAuthContext } from '~/hooks';
export default function DashboardRoute() {
const queryClient = useQueryClient();
const { isAuthenticated } = useAuthContext();
const prevLocationRef = usePreviousLocation();
const clearConvoState = store.useClearConvoState();
const [prevLocationPath, setPrevLocationPath] = useState('');
useEffect(() => {
setPrevLocationPath(prevLocationRef.current?.pathname || '');
}, [prevLocationRef]);
useEffect(() => {
queryClient.removeQueries([QueryKeys.messages, 'new']);
clearConvoState();
}, [queryClient, clearConvoState]);
if (!isAuthenticated) {
return null;
}
return (
<DashboardContext.Provider value={{ prevLocationPath }}>
<div className="h-screen w-full">
<Outlet />
</div>
</DashboardContext.Provider>
);
return <Outlet />;
}

View file

@ -80,7 +80,7 @@ export default function Root() {
isSmallScreen && sidebarExpanded ? 'translateX(min(85vw, 380px))' : 'none',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
{...{ inert: isSmallScreen && sidebarExpanded ? '' : undefined }}
inert={isSmallScreen && sidebarExpanded ? '' : undefined}
>
<Outlet />
</div>

View file

@ -28,6 +28,11 @@ const AuthLayout = () => (
</AuthContextProvider>
);
const loadInlinePromptsView = () =>
import('~/components/Prompts/layouts/InlinePromptsView').then((m) => ({
Component: m.default,
}));
const baseEl = document.querySelector('base');
const baseHref = baseEl?.getAttribute('href') || '/';
@ -111,6 +116,18 @@ export const router = createBrowserRouter(
path: 'search',
element: <Search />,
},
{
path: 'prompts',
element: <Navigate to="/prompts/new" replace={true} />,
},
{
path: 'prompts/new',
lazy: loadInlinePromptsView,
},
{
path: 'prompts/:promptId',
lazy: loadInlinePromptsView,
},
{
path: 'agents',
element: (

View file

@ -37,7 +37,6 @@ const localStorageAtoms = {
forkSetting: atomWithLocalStorage('forkSetting', ''),
splitAtTarget: atomWithLocalStorage('splitAtTarget', false),
rememberDefaultFork: atomWithLocalStorage(LocalStorageKeys.REMEMBER_FORK_OPTION, false),
showThinking: atomWithLocalStorage('showThinking', false),
saveBadgesState: atomWithLocalStorage('saveBadgesState', false),
// Beta features settings

View file

@ -0,0 +1,31 @@
/**
* Babel plugin that replaces `import.meta.hot` with `undefined`.
*
* `babel-plugin-transform-import-meta` handles standard properties (url,
* filename, dirname, resolve) but does NOT handle the Vite-specific `hot`
* property. Jest runs in CommonJS/Node where `import.meta` is unavailable,
* so any reference to `import.meta.hot` causes a SyntaxError.
*
* Replacing it with `undefined` makes the HMR guard blocks dead-code:
* import.meta.hot?.data undefined?.data undefined
* if (import.meta.hot) if (undefined) skipped
*/
module.exports = function transformImportMetaHot() {
return {
name: 'transform-import-meta-hot',
visitor: {
MemberExpression(path) {
const { node } = path;
if (
node.object.type === 'MetaProperty' &&
node.object.meta.name === 'import' &&
node.object.property.name === 'meta' &&
node.property.type === 'Identifier' &&
node.property.name === 'hot'
) {
path.replaceWith({ type: 'Identifier', name: 'undefined' });
}
},
},
};
};

View file

@ -1,7 +1,7 @@
import { Providers } from '@librechat/agents';
import { EModelEndpoint } from 'librechat-data-provider';
import type { Agent } from 'librechat-data-provider';
import type { ServerRequest, InitializeResultBase } from '~/types';
import type { ServerRequest, InitializeResultBase, EndpointTokenConfig } from '~/types';
import type { InitializeAgentDbMethods } from '../initialize';
// Mock logger
@ -55,22 +55,48 @@ jest.mock('../resources', () => ({
import { initializeAgent } from '../initialize';
const realUtils = jest.requireActual<typeof import('~/utils')>('~/utils');
/**
* Creates minimal mock objects for initializeAgent tests.
*
* @param overrides.overrideProvider - Simulates the value returned by `getProviderConfig`.
* Defaults to `provider` (native endpoint where no remapping occurs). Set to a different
* value (e.g. `Providers.OPENAI`) alongside a custom `provider` to simulate a custom
* endpoint whose provider is resolved to a built-in.
* @param overrides.useRealTokenLookup - When true, `getModelMaxTokens` delegates to the real
* implementation so tests exercise actual token-map resolution. Otherwise a controlled
* `modelDefault` is returned.
*/
function createMocks(overrides?: {
provider?: string;
overrideProvider?: string;
model?: string;
maxContextTokens?: number;
modelDefault?: number;
maxOutputTokens?: number;
endpointTokenConfig?: EndpointTokenConfig;
useRealTokenLookup?: boolean;
}) {
const { maxContextTokens, modelDefault = 200000, maxOutputTokens = 4096 } = overrides ?? {};
const {
provider = Providers.OPENAI,
overrideProvider,
model = 'test-model',
maxContextTokens,
modelDefault = 200000,
maxOutputTokens = 4096,
endpointTokenConfig,
useRealTokenLookup = false,
} = overrides ?? {};
const resolvedOverrideProvider = overrideProvider ?? provider;
const agent = {
id: 'agent-1',
model: 'test-model',
provider: Providers.OPENAI,
model,
provider,
tools: [],
model_parameters: { model: 'test-model' },
model_parameters: { model },
} as unknown as Agent;
const req = {
@ -81,39 +107,29 @@ function createMocks(overrides?: {
const res = {} as unknown as import('express').Response;
const mockGetOptions = jest.fn().mockResolvedValue({
llmConfig: {
model: 'test-model',
maxTokens: maxOutputTokens,
},
endpointTokenConfig: undefined,
llmConfig: { model, maxTokens: maxOutputTokens },
endpointTokenConfig,
} satisfies InitializeResultBase);
mockGetProviderConfig.mockReturnValue({
getOptions: mockGetOptions,
overrideProvider: Providers.OPENAI,
overrideProvider: resolvedOverrideProvider,
});
// extractLibreChatParams returns maxContextTokens when provided in model_parameters
mockExtractLibreChatParams.mockReturnValue({
resendFiles: false,
maxContextTokens,
modelOptions: { model: 'test-model' },
modelOptions: { model },
});
// getModelMaxTokens returns the model's default context window
mockGetModelMaxTokens.mockReturnValue(modelDefault);
if (useRealTokenLookup) {
mockGetModelMaxTokens.mockImplementation(realUtils.getModelMaxTokens);
} else {
mockGetModelMaxTokens.mockReturnValue(modelDefault);
}
// Implement real optionalChainWithEmptyCheck behavior
mockOptionalChainWithEmptyCheck.mockImplementation(
(...values: (string | number | undefined)[]) => {
for (const v of values) {
if (v !== undefined && v !== null && v !== '') {
return v;
}
}
return values[values.length - 1];
},
);
// Real implementation: treats 0 as a valid (non-empty) value — load-bearing for the maxContextTokens=0 test
mockOptionalChainWithEmptyCheck.mockImplementation(realUtils.optionalChainWithEmptyCheck);
const loadTools = jest.fn().mockResolvedValue({
tools: [],
@ -136,6 +152,80 @@ function createMocks(overrides?: {
return { agent, req, res, loadTools, db };
}
describe('initializeAgent — custom provider token lookup', () => {
const CUSTOM_PROVIDER = 'EduGPT';
beforeEach(() => {
jest.clearAllMocks();
});
it('passes the resolved provider endpoint to getModelMaxTokens, not the custom name', async () => {
const { agent, req, res, loadTools, db } = createMocks({
provider: CUSTOM_PROVIDER,
overrideProvider: Providers.OPENAI,
model: 'qwen3-235b-a22b',
useRealTokenLookup: true,
});
await initializeAgent(
{
req,
res,
agent,
loadTools,
endpointOption: { endpoint: EModelEndpoint.agents },
allowedProviders: new Set([CUSTOM_PROVIDER]),
isInitialAgent: true,
},
db,
);
// providerEndpointMap["openAI"] = "openAI" (valid), not providerEndpointMap["EduGPT"] = undefined
expect(mockGetModelMaxTokens).toHaveBeenCalledWith(
'qwen3-235b-a22b',
EModelEndpoint.openAI,
undefined,
);
});
it('uses endpointTokenConfig from the custom endpoint for unrecognized models', async () => {
const customTokenConfig: EndpointTokenConfig = {
'my-custom-model-v1': { context: 65536, prompt: 1, completion: 1 },
};
const { agent, req, res, loadTools, db } = createMocks({
provider: CUSTOM_PROVIDER,
overrideProvider: Providers.OPENAI,
model: 'my-custom-model-v1',
endpointTokenConfig: customTokenConfig,
useRealTokenLookup: true,
});
const result = await initializeAgent(
{
req,
res,
agent,
loadTools,
endpointOption: { endpoint: EModelEndpoint.agents },
allowedProviders: new Set([CUSTOM_PROVIDER]),
isInitialAgent: true,
},
db,
);
expect(mockGetModelMaxTokens).toHaveBeenCalledWith(
'my-custom-model-v1',
EModelEndpoint.openAI,
customTokenConfig,
);
// Pipeline check: verifies endpointTokenConfig.context flows through the full
// optionalChainWithEmptyCheck → Math.max formula. The toHaveBeenCalledWith
// assertion above catches the actual provider-resolution regression.
expect(result.maxContextTokens).toBe(Math.round((65536 - 4096) * 0.95));
});
});
describe('initializeAgent — maxContextTokens', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -354,7 +354,7 @@ export async function initializeAgent(
maxContextTokens,
getModelMaxTokens(
tokensModel ?? '',
providerEndpointMap[provider as keyof typeof providerEndpointMap],
providerEndpointMap[overrideProvider as keyof typeof providerEndpointMap],
options.endpointTokenConfig,
),
18000,

View file

@ -12,19 +12,19 @@ export default function MCPIcon({ className }: { className?: string }) {
<path
d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"
stroke="currentColor"
strokeWidth="12"
strokeWidth="16"
strokeLinecap="round"
/>
<path
d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"
stroke="currentColor"
strokeWidth="12"
strokeWidth="16"
strokeLinecap="round"
/>
<path
d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"
stroke="currentColor"
strokeWidth="12"
strokeWidth="16"
strokeLinecap="round"
/>
</svg>

View file

@ -539,6 +539,7 @@ export type TPromptGroup = {
productionPrompt?: Pick<TPrompt, 'prompt'> | null;
author: string;
authorName: string;
isPublic?: boolean;
createdAt?: Date;
updatedAt?: Date;
_id?: string;