diff --git a/.fly/gitnexus/Caddyfile b/.fly/gitnexus/Caddyfile new file mode 100644 index 0000000000..ac69e6e775 --- /dev/null +++ b/.fly/gitnexus/Caddyfile @@ -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 +} diff --git a/.fly/gitnexus/Dockerfile b/.fly/gitnexus/Dockerfile new file mode 100644 index 0000000000..cf5cd29756 --- /dev/null +++ b/.fly/gitnexus/Dockerfile @@ -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"] diff --git a/.fly/gitnexus/entrypoint.sh b/.fly/gitnexus/entrypoint.sh new file mode 100644 index 0000000000..8a127848b4 --- /dev/null +++ b/.fly/gitnexus/entrypoint.sh @@ -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=" + 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 diff --git a/.fly/gitnexus/fly.toml b/.fly/gitnexus/fly.toml new file mode 100644 index 0000000000..086a64a640 --- /dev/null +++ b/.fly/gitnexus/fly.toml @@ -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' diff --git a/.github/workflows/gitnexus-deploy.yml b/.github/workflows/gitnexus-deploy.yml new file mode 100644 index 0000000000..7ee6e23fc7 --- /dev/null +++ b/.github/workflows/gitnexus-deploy.yml @@ -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 + +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 }} diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml new file mode 100644 index 0000000000..4fb425b4c2 --- /dev/null +++ b/.github/workflows/gitnexus-index.yml @@ -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 diff --git a/LICENSE b/LICENSE index 535850a920..5ee6463dee 100644 --- a/LICENSE +++ b/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 diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 07306ac4db..9b0ecb66a5 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -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, diff --git a/client/babel.config.cjs b/client/babel.config.cjs index 44b0501a61..270f18f1a0 100644 --- a/client/babel.config.cjs +++ b/client/babel.config.cjs @@ -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', diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index dce1c38a78..1bedcec66f 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -29,11 +29,7 @@ interface BadgeRowContextType { const BadgeRowContext = createContext(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 { diff --git a/client/src/Providers/DashboardContext.tsx b/client/src/Providers/DashboardContext.tsx deleted file mode 100644 index f33a240d00..0000000000 --- a/client/src/Providers/DashboardContext.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext, useContext } from 'react'; -type TDashboardContext = { - prevLocationPath: string; -}; - -export const DashboardContext = createContext({} as TDashboardContext); -export const useDashboardContext = () => useContext(DashboardContext); diff --git a/client/src/Providers/DragDropContext.tsx b/client/src/Providers/DragDropContext.tsx index b519c0171f..ed4dddb58f 100644 --- a/client/src/Providers/DragDropContext.tsx +++ b/client/src/Providers/DragDropContext.tsx @@ -83,10 +83,14 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) { return {children}; } +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; } diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx index 3df373b165..147d2bc336 100644 --- a/client/src/Providers/PromptGroupsContext.tsx +++ b/client/src/Providers/PromptGroupsContext.tsx @@ -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); }; diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 3ae90e189c..aac5fccc21 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -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'; diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 816705a0db..adf406f7b0 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -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 = ({ className = '' }) = )} {/* Sticky wrapper for search bar and categories */} -
+
+
+ + +
{/* Search bar */}
{/* TODO: Remove this once we have a better way to handle admin settings */} - {/* Admin Settings */} - +
+ +
{/* Category tabs */} diff --git a/client/src/components/Agents/MarketplaceAdminSettings.tsx b/client/src/components/Agents/MarketplaceAdminSettings.tsx index c3d7dedda3..be62064aaf 100644 --- a/client/src/components/Agents/MarketplaceAdminSettings.tsx +++ b/client/src/components/Agents/MarketplaceAdminSettings.tsx @@ -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 ? ( + + ) : (
))} +
+ +
); diff --git a/client/src/components/Prompts/buttons/AdminSettings.tsx b/client/src/components/Prompts/buttons/AdminSettings.tsx index 25e6df05a8..7168217694 100644 --- a/client/src/components/Prompts/buttons/AdminSettings.tsx +++ b/client/src/components/Prompts/buttons/AdminSettings.tsx @@ -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 = ( - - ); - const confirmDialog = ( { permissions={permissions} menuId="prompt-role-dropdown" mutation={mutation} - trigger={trigger} onPermissionConfirm={handlePermissionConfirm} confirmPermissions={[Permissions.USE]} extraContent={confirmDialog} diff --git a/client/src/components/Prompts/buttons/AdvancedSwitch.tsx b/client/src/components/Prompts/buttons/AdvancedSwitch.tsx deleted file mode 100644 index 9941c78e66..0000000000 --- a/client/src/components/Prompts/buttons/AdvancedSwitch.tsx +++ /dev/null @@ -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: , - }, - { - value: PromptsEditorMode.ADVANCED, - label: localize('com_ui_advanced'), - icon: , - }, - ], - [localize], - ); - - const handleChange = useCallback( - (value: string) => { - if (value === PromptsEditorMode.SIMPLE) { - setAlwaysMakeProd(true); - } - setMode(value as PromptsEditorMode); - }, - [setMode, setAlwaysMakeProd], - ); - - return ( - - ); -}; - -export default AdvancedSwitch; diff --git a/client/src/components/Prompts/buttons/AutoSendPrompt.tsx b/client/src/components/Prompts/buttons/AutoSendPrompt.tsx index c2ebee70c5..759c33739e 100644 --- a/client/src/components/Prompts/buttons/AutoSendPrompt.tsx +++ b/client/src/components/Prompts/buttons/AutoSendPrompt.tsx @@ -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' : ''}`} >