mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
Merge branch 'main' into aron/data-retention-upstream
This commit is contained in:
commit
f01dd6e172
83 changed files with 1350 additions and 1448 deletions
21
.fly/gitnexus/Caddyfile
Normal file
21
.fly/gitnexus/Caddyfile
Normal 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
29
.fly/gitnexus/Dockerfile
Normal 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"]
|
||||
14
.fly/gitnexus/entrypoint.sh
Normal file
14
.fly/gitnexus/entrypoint.sh
Normal 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
24
.fly/gitnexus/fly.toml
Normal 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
104
.github/workflows/gitnexus-deploy.yml
vendored
Normal 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
91
.github/workflows/gitnexus-index.yml
vendored
Normal 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
|
||||
2
LICENSE
2
LICENSE
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)) && (
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}` })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
×
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
48
client/src/components/Prompts/layouts/InlinePromptsView.tsx
Normal file
48
client/src/components/Prompts/layouts/InlinePromptsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export { default as PromptsView } from './PromptsView';
|
||||
export { default as InlinePromptsView } from './InlinePromptsView';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ function UnifiedSidebar() {
|
|||
width: 'min(85vw, 380px)',
|
||||
transition: `transform ${TRANSITION_MS}ms ${EASING}`,
|
||||
}}
|
||||
{...{ inert: !expanded ? '' : undefined }}
|
||||
inert={!expanded ? '' : undefined}
|
||||
>
|
||||
<SidebarChatProvider>
|
||||
<ActivePanelProvider>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
client/test/babel-plugin-transform-import-meta-hot.cjs
Normal file
31
client/test/babel-plugin-transform-import-meta-hot.cjs
Normal 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' });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -539,6 +539,7 @@ export type TPromptGroup = {
|
|||
productionPrompt?: Pick<TPrompt, 'prompt'> | null;
|
||||
author: string;
|
||||
authorName: string;
|
||||
isPublic?: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
_id?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue