refactor: remove Nextra, migrate to App Router, redesign Toolkit (#531)

* refactor: remove Nextra shims, migrate pages/ to app router, upgrade ESLint to v9

- Remove all nextra-shims and legacy pages/ directory
- Migrate subscribe, unsubscribe, and toolkit pages to app router
- Restructure config docs with guide-first setup steps and Tabs components
- Rewrite Docker install, OpenRouter, and custom endpoints docs
- Add Quick Start guide, Google Search docs, and image generation cross-links
- Update .gitignore

* feat: redesign toolkit and integrate into docs sidebar

Move credentials generator and YAML validator into the docs under
a new "Tools > Toolkit" sidebar section. Old /toolkit routes redirect
to /docs/toolkit.

Credentials generator: 2-column field grid, per-field copy buttons,
copy-all as .env block, empty state placeholder, design system tokens.

YAML validator: full-width theme-aware Ace Editor (chrome/twilight),
drag-and-drop overlay, result banners with icons, clear button,
no print margin.

Remove unused .error-marker and .custom-btn from style.css.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Marco Beretta
2026-03-20 22:43:32 +01:00
committed by GitHub
parent c8dfc77b19
commit e48cd86b2b
49 changed files with 395 additions and 1870 deletions

9
app/subscribe/client.tsx Normal file
View File

@@ -0,0 +1,9 @@
'use client'
import dynamic from 'next/dynamic'
const SubscribeForm = dynamic(() => import('@/components/Newsletter/SubscribeForm'), { ssr: false })
export default function SubscribeClient() {
return <SubscribeForm />
}

19
app/subscribe/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home'
import { baseOptions } from '@/app/layout.config'
import type { Metadata } from 'next'
import SubscribeClient from './client'
export const metadata: Metadata = {
title: 'Subscribe',
description: 'Subscribe to the LibreChat newsletter',
}
export default function SubscribePage() {
return (
<HomeLayout {...baseOptions}>
<main className="mx-auto max-w-xl px-4 py-16">
<SubscribeClient />
</main>
</HomeLayout>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function CredsGeneratorPage() {
redirect('/docs/toolkit/credentials-generator')
}

5
app/toolkit/page.ts Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function ToolkitPage() {
redirect('/docs/toolkit')
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function YamlCheckerPage() {
redirect('/docs/toolkit/yaml-validator')
}

View File

@@ -0,0 +1,11 @@
'use client'
import dynamic from 'next/dynamic'
const UnsubscribeForm = dynamic(() => import('@/components/Newsletter/UnsubscribeForm'), {
ssr: false,
})
export default function UnsubscribeClient() {
return <UnsubscribeForm />
}

19
app/unsubscribe/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home'
import { baseOptions } from '@/app/layout.config'
import type { Metadata } from 'next'
import UnsubscribeClient from './client'
export const metadata: Metadata = {
title: 'Unsubscribe',
description: 'Unsubscribe from the LibreChat newsletter',
}
export default function UnsubscribePage() {
return (
<HomeLayout {...baseOptions}>
<main className="mx-auto max-w-xl px-4 py-16">
<UnsubscribeClient />
</main>
</HomeLayout>
)
}

View File

@@ -1,92 +0,0 @@
import React, { useEffect, useState } from 'react'
import { getPagesUnderRoute } from 'nextra/context'
import { type Page } from 'nextra'
import { SocialIcon } from 'react-social-icons'
import Image from 'next/image'
import Link from 'next/link'
interface AuthorMetadata {
authorid: string
subtitle: string
name: string
bio: string
ogImage: string
socials?: {
[key: string]: string
}
}
const AuthorCard: React.FC<{ author: AuthorMetadata }> = ({ author }) => {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
const socialsEntries = Object.entries(author.socials ?? {}).filter(
([, value]) => !!value,
) as unknown as [string, string][]
return (
<Link href={`/authors/${author.authorid}`}>
<div className="flex flex-col items-center gap-4 bg-gray-200/20 rounded-lg p-6 h-full group">
<div className="relative overflow-hidden rounded-full">
<Image
width={200}
height={200}
src={author.ogImage}
alt={author.name}
className="rounded-full transition-transform duration-300 ease-in-out group-hover:scale-110"
style={{ objectFit: 'cover' }}
/>
</div>
<h2 className="font-bold text-xl">{author.name}</h2>
<p className="text-sm text-center text-gray-600 grow">{author.subtitle}</p>
<div className="flex flex-wrap gap-4 justify-center mt-2">
{isClient &&
socialsEntries.map(([key, value]) => (
<a
key={key}
href={value}
className="btn btn-square relative overflow-hidden"
title={`See ${author.name}'s ${key}`}
target="_blank"
rel="noopener noreferrer"
style={{ transition: 'transform 0.3s ease' }}
onClick={(e) => e.stopPropagation()}
>
<SocialIcon
url={value}
className="absolute inset-0 size-full scale-100 transition-transform opacity-100 hover:scale-90"
bgColor="#9B9B9B80"
fgColor="background"
style={{ width: '2em', height: '2em' }}
/>
</a>
))}
</div>
</div>
</Link>
)
}
const AuthorPage: React.FC = () => {
const allAuthors = getPagesUnderRoute('/authors') as Array<Page & { frontMatter: AuthorMetadata }>
const authors = allAuthors.filter((author) => !!author.frontMatter.authorid)
return (
<section className="max-w-4xl mx-auto mt-12 mb-24 md:mb-32">
<h1 className="font-extrabold text-3xl lg:text-5xl tracking-tight mb-8 text-center">
Our Authors
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{authors.map((author) => (
<AuthorCard key={author.frontMatter.authorid} author={author.frontMatter} />
))}
</div>
</section>
)
}
export default AuthorPage

View File

@@ -1,131 +0,0 @@
import { useEffect, useState } from 'react'
import { getPagesUnderRoute } from 'nextra/context'
import { type Page } from 'nextra'
import { SocialIcon } from 'react-social-icons'
import BlogCard from '../blog/BlogCard'
import Image from 'next/image'
import { Cards } from 'nextra/components'
import { OurAuthors, Blog } from '@/components/CardIcons'
// Known issues:
// - Mobile: social icons overflow when author has more than 4 social links
// - SocialIcon uses the generic "share" icon when the platform is unrecognized
// - "Recent Posts by" section does not support filtering by tag
// - Profile image positioning is off when the author has no bio text
interface AuthorMetadata {
authorid: string
subtitle: string
name: string
bio: string
ogImage: string
socials?: Record<string, string | undefined> // Dynamically match social media platforms
date: string | number | Date
}
interface AuthorProfileProps {
authorId: string
}
const AuthorProfile: React.FC<AuthorProfileProps> = ({ authorId }) => {
const authors = getPagesUnderRoute('/authors') as Array<Page & { frontMatter: AuthorMetadata }>
const author = authors.find((a) => a.frontMatter.authorid === authorId)?.frontMatter
const blogPosts = getPagesUnderRoute('/blog') as Array<Page & { frontMatter: AuthorMetadata }>
// Filter posts by the current authorId
const authorPosts = blogPosts.filter((post) => post.frontMatter.authorid === authorId)
const sortedAuthorPosts = authorPosts.sort(
(a, b) => new Date(b.frontMatter.date).getTime() - new Date(a.frontMatter.date).getTime(),
)
// State to track whether the component is rendered on the client side
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
if (!author) {
return <div>Author not found!</div>
}
const socialsEntries = Object.entries(author.socials ?? {}).filter(([, value]) => !!value)
return (
<>
<section className="max-w-4xl mx-auto flex flex-col md:flex-row gap-8 mt-12 mb-24 md:mb-32">
<div>
<h1 className="font-extrabold text-3xl lg:text-5xl tracking-tight mb-2">{author.name}</h1>
<p
className="md:text-lg mb-6 md:mb-10 font-medium"
style={{ fontSize: '1.3rem', fontWeight: 'bold' }}
>
{author.subtitle}
</p>
{author.bio && <p className="md:text-lg text-base-content/80">{author.bio}</p>}
</div>
<div className="max-md:order-first flex md:flex-col gap-4 shrink-0">
<Image
width={512}
height={512}
src={author.ogImage}
alt={author.name}
className="rounded-box size-[12rem] md:size-[16rem] rounded-square"
style={{ borderRadius: '20px', objectFit: 'cover' }}
/>
<div className="flex flex-wrap gap-4 max-w-[4rem] md:max-w-[16rem]">
{isClient &&
socialsEntries.map(([key, value]) => (
<a
key={key}
href={value}
className="btn btn-square relative overflow-hidden"
aria-label={`Visit ${author.name}'s ${key}`}
title={`See ${author.name}'s ${key}`}
target="_blank"
rel="noopener noreferrer"
style={{ transition: 'transform 0.3s ease' }} // Add transition here
>
<SocialIcon
url={value}
className="absolute inset-0 size-full scale-100 transition-transform opacity-100 hover:scale-90"
bgColor="#9B9B9B80"
fgColor="background"
// fallback={{ path: 'M32 2 A30 30 0 0 1 62 32 A30 30 0 0 1 32 62 A30 30 0 0 1 2 32 A30 30 0 0 1 32 2 Z' }}
/>
</a>
))}
</div>
</div>
</section>
<section className="max-w-4xl mx-auto mt-8">
<h2 className="font-bold text-2xl mb-6 text-center">Recent Posts by {author.name}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-7">
{sortedAuthorPosts.map((post) => (
<BlogCard
key={post.route}
page={post}
handleTagClick={() => {}}
selectedTags={undefined}
/>
))}
</div>
<div style={{ marginTop: '75px' }} />
<div>
<Cards num={3}>
<Cards.Card title="Blog" href="/blog" icon={<Blog />} image>
{null}
</Cards.Card>
<Cards.Card title="Our Authors" href="/authors" icon={<OurAuthors />} image>
{null}
</Cards.Card>
</Cards>
</div>
</section>
</>
)
}
export default AuthorProfile

View File

@@ -1,38 +0,0 @@
import Image from 'next/image'
import { getPagesUnderRoute } from 'nextra/context'
export const Author = ({ authorid }: { authorid: string }) => {
const authorPages = getPagesUnderRoute('/authors')
const page = authorPages?.find((page) => page.frontMatter.authorid === authorid)
if (!page) {
// Handle the case when the author page is not found
console.error('Author page not found for authorid:', authorid)
return null
}
const { name, ogImage } = page.frontMatter
return (
<div>
<a href={`/authors/${authorid}`} className="group shrink-0" rel="noopener noreferrer">
<div className="flex justify-end gap-2" key={name}>
<div className="flex items-center gap-2">
{ogImage ? (
<Image
src={ogImage}
width={40}
height={40}
className="rounded-full"
alt={`Picture ${name}`}
/>
) : null}
<span className="text-primary/60 group-hover:text-primary whitespace-nowrap">
{name}
</span>
</div>
</div>
</a>
</div>
)
}

View File

@@ -1,30 +0,0 @@
import Image from 'next/image'
import { getPagesUnderRoute } from 'nextra/context'
export const AuthorSmall = ({ authorid }: { authorid: string }) => {
const authorPages = getPagesUnderRoute('/authors')
const page = authorPages?.find((page) => page.frontMatter.authorid === authorid)
if (!page) {
// Handle the case when the author page is not found
console.error('Author page not found for authorid:', authorid)
return null
}
const { name, ogImage } = page.frontMatter
return (
<div className="group shrink-0" key={name}>
<div className="flex items-center gap-4">
<Image
src={ogImage}
width={20}
height={20}
className="rounded-full"
alt={`Picture ${name}`}
/>
<span className="text-primary/60 whitespace-nowrap">{name}</span>
</div>
</div>
)
}

View File

@@ -1,98 +0,0 @@
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/router'
import type { Page, MdxFile } from 'nextra'
import { Author } from '../Author/Authors'
import { Video } from '../Video'
const BlogCard = ({
page,
handleTagClick,
selectedTags = [],
}: {
page: MdxFile & Page
handleTagClick: (tag: string) => void
selectedTags?: string[]
}) => {
const router = useRouter()
const [cardWidth, setCardWidth] = useState(0)
const [maxDescriptionLength, setMaxDescriptionLength] = useState(160)
const handleCardClick = () => {
router.push(page.route)
}
useEffect(() => {
setMaxDescriptionLength(cardWidth > 260 ? 145 : 46) // Adjust maxLength based on card width
}, [cardWidth])
useEffect(() => {
const updateCardWidth = () => {
setCardWidth(document.querySelector('.blog-card')?.clientWidth ?? 0)
}
window.addEventListener('resize', updateCardWidth)
updateCardWidth()
return () => {
window.removeEventListener('resize', updateCardWidth)
}
}, [])
const truncateDescription = (text) => {
if (text.length > maxDescriptionLength) {
return text.slice(0, maxDescriptionLength) + '...'
}
return text
}
return (
<div className="bg-popover rounded-lg shadow-md overflow-hidden blog-card">
<div
className="relative h-52 md:h-64 mb-1 overflow-hidden scale-100 transition-transform hover:scale-105 cursor-pointer"
onClick={handleCardClick}
style={{ transformOrigin: 'bottom center' }}
>
{page.frontMatter?.ogVideo ? (
<Video src={page.frontMatter.ogVideo} gifStyle className="object-cover size-full mt-0" />
) : page.frontMatter?.ogImage ? (
<Image
src={page.frontMatter.gif ?? page.frontMatter.ogImage}
width={1200}
height={675}
className="object-cover absolute top-0 left-0 size-full"
alt={page.frontMatter?.title ?? 'Blog post image'}
unoptimized={
page.frontMatter.gif !== undefined || page.frontMatter.ogImage?.endsWith('.gif')
}
/>
) : null}
</div>
<div className="p-4 pt-2 h-56 overflow-hidden relative">
<div className="items-center justify-between mb-2">
{page.frontMatter?.tags?.map((tag) => (
<span
key={tag}
className={`cursor-pointer text-xs py-1 px-2 bg-background/80 shadow-md rounded-md mx-1 ${
selectedTags.includes(tag) ? 'bg-gray-700/20' : ''
}`}
onClick={() => handleTagClick(tag)}
>
{tag}
</span>
))}
</div>
{/* Modified title and description to be clickable */}
<div className="mb-2 mx-1 cursor-pointer" onClick={handleCardClick}>
<h2 className="font-mono text-xl mb-2 font-bold">
{page.meta?.title || page.frontMatter?.title || page.name}
</h2>
<div>{truncateDescription(page.frontMatter?.description || '')}</div>
</div>
<div className="flex items-center justify-between absolute bottom-4 inset-x-4">
<Author authorid={page.frontMatter?.authorid} />
<span className="text-sm opacity-60">{page.frontMatter?.date}</span>
</div>
</div>
</div>
)
}
export default BlogCard

View File

@@ -1,61 +0,0 @@
'use client'
import Image from 'next/image'
import { usePathname } from 'next/navigation'
import { getPagesUnderRoute } from 'nextra/context'
import { Author } from '../Author/Authors'
import { Video } from '../Video'
export const BlogHeader = () => {
const pathname = usePathname()
const changelogPages = getPagesUnderRoute('/blog')
const page = changelogPages.find((page) => page.route === pathname)
const { title, description, ogImage, ogVideo, gif, date, authorid } = page.frontMatter
return (
<div className="md:mt-10 flex flex-col gap-10">
<div>
<div className="text-lg text-primary/60 mb-3">
{new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})}
</div>
<div className="flex flex-col gap-5 md:gap-10 md:flex-row justify-between md:items-center">
<div>
<h1 className="font-extrabold text-3xl lg:text-5xl tracking-tight mb-2">{title}</h1>
</div>
</div>
<p
className="md:text-lg md:mb-5 font-medium"
style={{ fontSize: '1.3rem', fontWeight: 'bold' }}
>
{description}
</p>
<div style={{ textAlign: 'right' }}>
<Author authorid={authorid} />
</div>
<br />
{ogVideo ? (
<Video src={ogVideo} gifStyle />
) : ogImage ? (
<Image
src={gif ?? ogImage}
alt={title}
width={1200}
height={630}
className="border"
style={{ borderRadius: 20 }}
unoptimized={
page.frontMatter.gif !== undefined || page.frontMatter.ogImage?.endsWith('.gif')
}
/>
) : null}
<div className="mt-6 md:mt-4" />
</div>
</div>
)
}

View File

@@ -1,105 +0,0 @@
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { Page } from 'nextra'
import { getPagesUnderRoute } from 'nextra/context'
const changelogItems = getPagesUnderRoute('/changelog') as Array<Page & { frontMatter: any }>
export default function Changelog({ className }: { className?: string }) {
const changelog = changelogItems
.filter(
(page) =>
page.route &&
!page.route.includes('content') &&
page.frontMatter.title &&
page.frontMatter.date,
)
.sort((a, b) => new Date(b.frontMatter.date).getTime() - new Date(a.frontMatter.date).getTime())
.slice(0, 20)
.map(({ route, frontMatter }) => ({
route,
title: frontMatter.title ?? null,
author: frontMatter.author ?? null,
date: new Date(frontMatter.date),
}))
return (
<div
className={cn('rounded border p-5 max-w-lg mx-5 sm:mx-auto', className)}
role="region" // Added role for the container
aria-labelledby="changelog-heading" // Added aria-labelledby to reference the heading
>
<div className="px-5 py-2 text-center -mt-5 -mx-5 mb-5 border-b font-medium">
<h3 id="changelog-heading">Changelog</h3> {/* Added id to the heading */}
</div>
<div
role="list"
className="space-y-6 max-h-52 lg:max-h-96 overflow-y-scroll"
aria-label="Recent changelog entries" // Added aria-label for the list
>
{changelog.map((activityItem) => (
<Link
href={activityItem.route}
className="relative flex gap-x-4 group"
key={activityItem.route}
role="listitem" // Added role for each list item
aria-label={`Changelog entry for ${activityItem.title}`} // Added aria-label for each list item
>
<div className="-bottom-6 absolute left-0 top-0 flex w-6 justify-center">
<div className="w-px bg-secondary" />
</div>
<div className="relative flex size-6 flex-none items-center justify-center bg-background">
<div className="size-1.5 rounded-full bg-secondary ring-1 ring-primary/80 opacity-60 group-hover:opacity-100" />
</div>
<p className="flex-auto py-0.5 text-sm leading-5 text-primary/70 opacity-80 group-hover:opacity-100">
<span className="font-medium text-primary">{activityItem.title}</span>{' '}
{activityItem.author ? `by ${activityItem.author}` : null}
</p>
{activityItem.date ? (
<time
dateTime={activityItem.date.toISOString()}
className="flex-none py-0.5 text-sm leading-5 text-primary/70 opacity-80 group-hover:opacity-100"
aria-label={`Date: ${formatDate(activityItem.date)}`} // Added aria-label for the date
>
{formatDate(activityItem.date)}
</time>
) : null}
</Link>
))}
</div>
<Link
key="root"
href="/changelog"
className="relative flex gap-x-4 group"
role="button" // Added role for the link acting as a button
aria-label="Read the full changelog" // Added aria-label for the link
>
<div className="size-6 absolute left-0 top-0 flex justify-center">
<div className="w-px bg-secondary" />
</div>
<div className="relative flex size-6 flex-none items-center justify-center bg-background">
<div className="size-1.5 rounded-full bg-secondary ring-1 ring-primary/80 opacity-60 group-hover:opacity-100" />
</div>
<p className="flex-auto py-0.5 text-sm leading-5 text-primary/60 opacity-80 group-hover:opacity-100">
<span className="font-medium text-primary">Read the full changelog ...</span> {null}
</p>
</Link>
</div>
)
}
function formatDate(date: Date) {
const dayDiff = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
if (dayDiff < 1) return 'today'
if (dayDiff < 14) return `${Math.round(dayDiff)} days ago`
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}

View File

@@ -1,235 +0,0 @@
import { Link } from 'nextra-theme-docs'
import { Button } from 'nextra/components'
const PrivacyPolicy = () => {
const currentDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<main className="max-w-xl mx-auto">
<div className="p-5">
<h1 className="text-3xl font-extrabold pb-6">Privacy Policy for LibreChat Documentation</h1>
<pre className="leading-relaxed whitespace-pre-wrap" style={{ fontFamily: 'sans-serif' }}>
{`Effective Date: ${currentDate}
This Privacy Policy outlines how the LibreChat documentation website (`}{' '}
<Link href="https://librechat.ai">https://librechat.ai</Link>
{`) operates with respect to user privacy.
1. No Data Collection
We do not collect, store, or process any personal information when you visit our documentation site. This includes:
- No collection of names, email addresses, or contact information
- No use of cookies or tracking technologies
- No analytics or usage tracking
- No user accounts or authentication
2. Purpose of This Site
This website serves solely as documentation for the open-source LibreChat project. It provides:
- Installation guides
- Configuration documentation
- API references
- User guides
- Contributing guidelines
3. External Links
Our documentation may contain links to external websites, including:
- The LibreChat GitHub repository
- Third-party service documentation
- Community resources
We are not responsible for the privacy practices of these external sites.
4. Changes to This Policy
We may update this privacy policy to reflect changes in our practices or for clarity. Any updates will be posted on this page with an updated effective date.
5. Contact Information
For questions about this privacy policy or the LibreChat project, please visit our GitHub repository at`}{' '}
<Link href="https://github.com/danny-avila/LibreChat">
https://github.com/danny-avila/LibreChat
</Link>
{`.
By using this documentation site, you acknowledge that no personal data is collected or processed.`}
</pre>
</div>
<Button onClick={() => window.open('/', '_self')}> Go Back</Button>
</main>
)
}
const TermsOfServices = () => {
const currentDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<main className="max-w-xl mx-auto">
<div className="p-5">
<h1 className="text-3xl font-extrabold pb-6">
Terms of Service for LibreChat Documentation
</h1>
<pre className="leading-relaxed whitespace-pre-wrap" style={{ fontFamily: 'sans-serif' }}>
{`Effective Date: ${currentDate}
Welcome to the LibreChat documentation website, available at `}{' '}
<Link href="https://librechat.ai">https://librechat.ai</Link>
{`. These Terms of Service ("Terms") govern your use of our documentation website.
1. Purpose and Scope
This website provides documentation for LibreChat, an open-source AI chat platform. The site is purely informational and includes:
- Technical documentation
- Installation and setup guides
- Configuration references
- API documentation
- Contributing guidelines
- Blog posts related to LibreChat
2. No Commercial Services
This documentation site:
- Does not sell any products or services
- Does not require payment for access
- Does not collect user data or personal information
- Does not require user registration or accounts
3. Open Source Project
LibreChat is an open-source project licensed under the MIT License. The source code is available at:`}{' '}
<Link href="https://github.com/danny-avila/LibreChat">
https://github.com/danny-avila/LibreChat
</Link>
{`
4. Use of Documentation
You may freely:
- Access and read all documentation
- Share links to the documentation
- Use the documentation to implement and configure LibreChat
- Contribute improvements to the documentation via GitHub
5. No Warranty
This documentation is provided "as is" without warranty of any kind. While we strive for accuracy, we make no guarantees about:
- The completeness or accuracy of the documentation
- The suitability of LibreChat for any particular purpose
- The availability of this documentation site
6. External Resources
Our documentation may reference or link to third-party services, tools, or resources. We are not responsible for:
- The content or practices of external sites
- The availability of external resources
- Any issues arising from the use of third-party services
7. Intellectual Property
The documentation content is licensed under the MIT License, consistent with the LibreChat project. You are free to use, modify, and distribute the documentation in accordance with this license.
8. Changes to Terms
We may update these terms to reflect changes in the project or for clarity. Updates will be posted on this page with a new effective date.
9. Contact
For questions about these terms or to contribute to the project, please visit:`}{' '}
<Link href="https://github.com/danny-avila/LibreChat">
https://github.com/danny-avila/LibreChat
</Link>
{`
By using this documentation site, you agree to these Terms of Service.`}
</pre>
</div>
</main>
)
}
const CookiePolicy = () => {
const currentDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<main className="max-w-xl mx-auto">
<div className="p-5">
<h1 className="text-3xl font-extrabold pb-6">Cookie Policy for LibreChat Documentation</h1>
<pre className="leading-relaxed whitespace-pre-wrap" style={{ fontFamily: 'sans-serif' }}>
{`Effective Date: ${currentDate}
1. No Cookies Used
The LibreChat documentation website (`}{' '}
<Link href="https://librechat.ai">https://librechat.ai</Link>
{`) does not use cookies or any similar tracking technologies.
2. No Data Collection
We do not:
- Set or read cookies
- Use web beacons or tracking pixels
- Employ analytics or tracking scripts
- Collect any personal information
- Track user behavior or preferences
3. Third-Party Services
While our site does not use cookies, please note:
- External links may lead to sites that use cookies
- Your browser may have its own cookie settings
- We are not responsible for cookies set by external sites
4. Browser Storage
This documentation site may use browser features like:
- Local storage for theme preferences (light/dark mode)
- Session storage for navigation state
This data is stored only in your browser and is never transmitted to any server.
5. Your Privacy
Since we don't use cookies or collect data:
- There's nothing to opt out of
- No personal information is at risk
- Your browsing is completely private
6. Changes to This Policy
Any updates to this policy will be posted here with a new effective date. However, we do not anticipate adding cookies or tracking to this documentation site.
7. Contact
For questions about this policy or the LibreChat project, visit:`}{' '}
<Link href="https://github.com/danny-avila/LibreChat">
https://github.com/danny-avila/LibreChat
</Link>
{`
By using this site, you acknowledge that no cookies or tracking technologies are employed.`}
</pre>
</div>
<Button onClick={() => window.open('/', '_self')}> Go Back</Button>
</main>
)
}
export { TermsOfServices }
export { PrivacyPolicy }
export { CookiePolicy }

View File

@@ -1,215 +1,150 @@
import { useState } from 'react'
import useCredentialsGenerator from './credentialsGenerator' // Adjust the path based on your project structure
'use client'
const CredsGenerator = () => {
import { useState, useCallback } from 'react'
import { Copy, Check, RefreshCw, ClipboardList } from 'lucide-react'
import useCredentialsGenerator from './credentialsGenerator'
const CREDENTIAL_FIELDS = [
{ key: 'CREDS_KEY', label: 'CREDS_KEY', hint: 'Encryption key for stored credentials' },
{ key: 'CREDS_IV', label: 'CREDS_IV', hint: 'Initialization vector for encryption' },
{ key: 'JWT_SECRET', label: 'JWT_SECRET', hint: 'Secret for signing access tokens' },
{
key: 'JWT_REFRESH_SECRET',
label: 'JWT_REFRESH_SECRET',
hint: 'Secret for signing refresh tokens',
},
{ key: 'MEILI_KEY', label: 'MEILI_MASTER_KEY', hint: 'MeiliSearch master key' },
] as const
type CredentialKey = (typeof CREDENTIAL_FIELDS)[number]['key']
type Credentials = Record<CredentialKey, string>
export default function CredentialsGenerator() {
const { generateCredentials } = useCredentialsGenerator()
const [credentials, setCredentials] = useState<{
CREDS_KEY: string
CREDS_IV: string
JWT_SECRET: string
JWT_REFRESH_SECRET: string
MEILI_KEY: string
} | null>(null)
const [copyEnabled, setCopyEnabled] = useState(false) // State to track whether copy is enabled
const [showTooltip, setShowTooltip] = useState(false) // State to track tooltip visibility
const [credentials, setCredentials] = useState<Credentials | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [generated, setGenerated] = useState(false)
const handleGenerate = () => {
try {
const newCredentials = generateCredentials()
setCredentials(newCredentials)
setCopyEnabled(true) // Enable copy after generating credentials
setCredentials(generateCredentials())
setGenerated(true)
setCopiedKey(null)
} catch (error) {
console.error(error.message)
console.error((error as Error).message)
}
}
const handleCopy = (value) => {
navigator.clipboard
.writeText(value)
.then(() => {
setShowTooltip(true) // Show tooltip on successful copy
setTimeout(() => setShowTooltip(false), 2000) // Hide tooltip after 2 seconds
})
.catch((err) => console.error('Copy failed:', err))
}
const copyToClipboard = useCallback(async (text: string, key: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
} catch (err) {
console.error('Copy failed:', err)
}
}, [])
const handleCopyAll = useCallback(() => {
if (!credentials) return
const block = CREDENTIAL_FIELDS.map((f) => `${f.label}=${credentials[f.key]}`).join('\n')
copyToClipboard(block, 'all')
}, [credentials, copyToClipboard])
return (
<div className="credentials-box">
<h2 style={{ textAlign: 'center', fontSize: '1.5rem', marginBottom: '15px' }}>
Generate Credentials
</h2>
<div className="credentials-container">
<div>
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>CREDS_KEY</p>
<div className="input-container">
<input
type="text"
value={credentials?.CREDS_KEY || ''}
placeholder=""
readOnly
aria-label="CREDS_KEY value"
/>
<button
className="copy-button"
onClick={() => handleCopy(credentials?.CREDS_KEY)}
disabled={!copyEnabled}
aria-label="Copy CREDS_KEY"
>
Copy
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>CREDS_IV</p>
<div className="input-container">
<input
type="text"
value={credentials?.CREDS_IV || ''}
placeholder=""
readOnly
aria-label="CREDS_IV value"
/>
<button
className="copy-button"
onClick={() => handleCopy(credentials?.CREDS_IV)}
disabled={!copyEnabled}
aria-label="Copy CREDS_IV"
>
Copy
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>JWT_SECRET</p>
<div className="input-container">
<input
type="text"
value={credentials?.JWT_SECRET || ''}
placeholder=""
readOnly
aria-label="JWT_SECRET value"
/>
<button
className="copy-button"
onClick={() => handleCopy(credentials?.JWT_SECRET)}
disabled={!copyEnabled}
aria-label="Copy JWT_SECRET"
>
Copy
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>
JWT_REFRESH_SECRET
</p>
<div className="input-container">
<input
type="text"
value={credentials?.JWT_REFRESH_SECRET || ''}
placeholder=""
readOnly
aria-label="JWT_REFRESH_SECRET value"
/>
<button
className="copy-button"
onClick={() => handleCopy(credentials?.JWT_REFRESH_SECRET)}
disabled={!copyEnabled}
aria-label="Copy JWT_REFRESH_SECRET"
>
Copy
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.8rem', marginBottom: '5px', marginTop: '10px' }}>
MEILI_MASTER_KEY
</p>
<div className="input-container">
<input
type="text"
value={credentials?.MEILI_KEY || ''}
placeholder=""
readOnly
aria-label="MEILI_MASTER_KEY value"
/>
<button
className="copy-button"
onClick={() => handleCopy(credentials?.MEILI_KEY)}
disabled={!copyEnabled}
aria-label="Copy MEILI_MASTER_KEY"
>
Copy
</button>
</div>
</div>
</div>
{showTooltip && <div className="tooltip">Copied to Clipboard</div>}
<div className="space-y-6">
<button
className="generate-button"
style={{ display: 'block', margin: '0 auto', marginTop: '15px' }}
onClick={handleGenerate}
className="group flex w-full items-center justify-center gap-2 rounded-lg bg-fd-primary px-5 py-3 text-sm font-medium text-fd-primary-foreground transition-all hover:opacity-90 active:scale-[0.99]"
aria-label="Generate new credentials"
>
Generate
<RefreshCw
className={`size-4 transition-transform ${generated ? 'group-hover:rotate-180' : ''}`}
aria-hidden="true"
/>
{generated ? 'Regenerate Credentials' : 'Generate Credentials'}
</button>
<style jsx>{`
.credentials-box {
position: relative;
padding: 10px;
// border: 1px solid #ccc;
border-radius: 20px;
display: inline-block;
width: 100%;
margin: 0 auto;
}
.credentials-container {
margin-top: 10px;
}
.input-container {
display: flex;
align-items: center;
}
.input-container input {
width: calc(100% - 70px);
margin-right: 10px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
}
.copy-button {
padding: 6px 12px; /* Adjust as needed */
background: rgb(30, 163, 128);
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
width: auto;
font-size: 0.8rem; /* Adjusted size */
}
.generate-button {
padding: 6px 12px; /* Adjust as needed */
background: rgb(30, 163, 128);
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
width: auto;
font-size: 1rem; /* Adjusted size */
}
.tooltip {
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(30, 163, 128, 0.5);
color: #fff;
padding: 5px 10px;
border-radius: 5px;
z-index: 999; /* Ensure a higher z-index */
}
`}</style>
{credentials && (
<>
<div
className="grid grid-cols-1 gap-4 lg:grid-cols-2"
role="region"
aria-label="Generated credentials"
>
{CREDENTIAL_FIELDS.map((field) => {
const id = `cred-${field.key}`
const isCopied = copiedKey === field.key
return (
<div
key={field.key}
className="group/field rounded-lg border border-fd-border bg-fd-card p-4 transition-colors hover:border-fd-primary/20"
>
<div className="mb-1 flex items-center justify-between">
<label
htmlFor={id}
className="font-mono text-xs font-semibold text-fd-foreground"
>
{field.label}
</label>
<button
onClick={() => copyToClipboard(credentials[field.key], field.key)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
aria-label={`Copy ${field.label}`}
>
{isCopied ? (
<>
<Check className="size-3 text-emerald-500" aria-hidden="true" />
<span className="text-emerald-500">Copied</span>
</>
) : (
<>
<Copy className="size-3" aria-hidden="true" />
<span>Copy</span>
</>
)}
</button>
</div>
<p className="mb-2 text-xs text-fd-muted-foreground">{field.hint}</p>
<input
id={id}
readOnly
value={credentials[field.key]}
className="w-full truncate rounded border border-fd-border bg-fd-muted px-3 py-2 font-mono text-xs text-fd-foreground outline-none focus-visible:ring-2 focus-visible:ring-fd-ring"
aria-label={`${field.label} value`}
/>
</div>
)
})}
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-fd-border pt-4">
<button
onClick={handleCopyAll}
className="flex items-center gap-2 rounded-lg border border-fd-border bg-fd-secondary px-4 py-2 text-sm font-medium text-fd-secondary-foreground transition-colors hover:bg-fd-accent"
aria-label="Copy all credentials as .env block"
>
<ClipboardList className="size-4" aria-hidden="true" />
{copiedKey === 'all' ? 'Copied to clipboard!' : 'Copy All as .env'}
</button>
<span aria-live="polite" className="text-xs text-fd-muted-foreground">
{copiedKey === 'all' && 'All 5 credentials copied as KEY=value format'}
</span>
</div>
</>
)}
{!credentials && (
<div className="rounded-lg border border-dashed border-fd-border bg-fd-muted/50 px-6 py-12 text-center">
<p className="text-sm text-fd-muted-foreground">
Click the button above to generate secure random credentials for your{' '}
<code className="rounded bg-fd-muted px-1.5 py-0.5 font-mono text-xs text-fd-foreground">
.env
</code>{' '}
file.
</p>
</div>
)}
</div>
)
}
export default CredsGenerator

View File

@@ -0,0 +1,12 @@
'use client'
import dynamic from 'next/dynamic'
const CredentialsGenerator = dynamic(() => import('@/components/tools/CredentialsGeneratorBox'), {
ssr: false,
loading: () => <div className="h-64 animate-pulse rounded-lg bg-fd-muted" />,
})
export function CredentialsGeneratorMDX() {
return <CredentialsGenerator />
}

View File

@@ -0,0 +1,12 @@
'use client'
import dynamic from 'next/dynamic'
const YAMLValidator = dynamic(() => import('@/components/tools/yamlChecker'), {
ssr: false,
loading: () => <div className="h-[500px] animate-pulse rounded-lg bg-fd-muted" />,
})
export function YAMLValidatorMDX() {
return <YAMLValidator />
}

View File

@@ -1,10 +1,14 @@
import React, { useState, useEffect, useRef } from 'react'
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import AceEditor, { IMarker } from 'react-ace'
import 'ace-builds/src-noconflict/mode-yaml'
import 'ace-builds/src-noconflict/theme-twilight'
import 'ace-builds/src-noconflict/theme-chrome'
import jsYaml from 'js-yaml'
import { CheckCircle, XCircle, Trash2, Upload } from 'lucide-react'
function YAMLChecker() {
export default function YAMLValidator() {
const [yaml, setYaml] = useState('')
const [validationResult, setValidationResult] = useState<{
valid: boolean
@@ -12,49 +16,55 @@ function YAMLChecker() {
error?: string
} | null>(null)
const [errorLine, setErrorLine] = useState<number | null>(null)
const editorRef = useRef<any>(null)
const [isDark, setIsDark] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const editorRef = useRef<AceEditor>(null)
const validateYAML = (yamlContent: string) => {
useEffect(() => {
const root = document.documentElement
setIsDark(root.classList.contains('dark'))
const observer = new MutationObserver(() => {
setIsDark(root.classList.contains('dark'))
})
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const validateYAML = useCallback((yamlContent: string) => {
try {
const result = jsYaml.load(yamlContent)
setErrorLine(null) // No error
setErrorLine(null)
return { valid: true, result: JSON.stringify(result, null, 2) }
} catch (error) {
let errorMessage = ''
const line = error.mark?.line
setErrorLine(line)
if (error.reason === 'bad indentation of a mapping entry') {
errorMessage = ` Incorrect indentation at line ${line + 1}. Each entry in YAML should be properly indented.`
} else {
errorMessage = ` ${error.reason} at line ${line + 1}`
}
} catch (error: unknown) {
const yamlError = error as { reason?: string; mark?: { line?: number } }
const line = yamlError.mark?.line
setErrorLine(line ?? null)
const errorMessage =
yamlError.reason === 'bad indentation of a mapping entry'
? `Incorrect indentation at line ${(line ?? 0) + 1}. Each entry in YAML should be properly indented.`
: `${yamlError.reason} at line ${(line ?? 0) + 1}`
return { valid: false, error: errorMessage }
}
}
const handleYamlChange = (newYaml: string) => {
setYaml(newYaml)
}
}, [])
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const fileContent = reader.result as string
setYaml(fileContent)
}
reader.onload = () => setYaml(reader.result as string)
reader.readAsText(file)
}
useEffect(() => {
if (yaml.trim() === '') {
setValidationResult(null) // Clear validation result if YAML is empty
setValidationResult(null)
setErrorLine(null)
} else {
const result = validateYAML(yaml)
setValidationResult(result)
setValidationResult(validateYAML(yaml))
}
}, [yaml]) // Trigger validation whenever `yaml` changes
}, [yaml, validateYAML])
const errorMarkers: IMarker[] =
errorLine === null
@@ -66,65 +76,91 @@ function YAMLChecker() {
startCol: 0,
endCol: Number.MAX_VALUE,
type: 'text',
className: 'error-marker', // Use className instead of style
className: 'ace-error-marker',
},
]
const textAreaStyle = {
width: '100%',
minHeight: '50px',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '5px',
fontSize: '1rem',
backgroundColor: validationResult
? validationResult.valid
? 'rgba(0,255,0,0.2)' // Green background for valid YAML
: 'rgba(255,0,0,0.2)' // Red background for invalid YAML
: 'transparent', // Transparent background by default
}
return (
<div style={{ width: '100%', maxWidth: '800px', margin: '0 auto' }}>
<h2 style={{ textAlign: 'left', fontSize: '1.5rem', margin: '10px 0' }}>
YAML Validator (beta)
</h2>
<div className="space-y-4">
<div
onDrop={handleFileDrop}
onDragOver={(e) => e.preventDefault()}
style={{ width: '100%', marginBottom: '10px' }}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
className={`relative overflow-hidden rounded-lg border transition-colors ${
isDragging ? 'border-fd-primary bg-fd-primary/5' : 'border-fd-border'
}`}
>
{isDragging && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-fd-background/80 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium text-fd-primary">
<Upload className="size-5" aria-hidden="true" />
Drop YAML file here
</div>
</div>
)}
<AceEditor
mode="yaml"
theme="twilight"
onChange={handleYamlChange}
theme={isDark ? 'twilight' : 'chrome'}
onChange={setYaml}
value={yaml}
name="YAML_EDITOR"
editorProps={{ $blockScrolling: true }}
setOptions={{
showLineNumbers: true,
highlightActiveLine: false,
showPrintMargin: false,
tabSize: 2,
fontSize: 14,
}}
markers={errorMarkers}
style={{ width: '100%' }}
placeholder="Paste the content of the YAML file here or drop a file here..."
width="100%"
height="500px"
placeholder="Paste your librechat.yaml content here, or drag & drop a file..."
ref={editorRef}
/>
</div>
<textarea
readOnly
style={textAreaStyle}
placeholder="Validation Result will be displayed here"
value={
validationResult
? validationResult.valid
? 'YAML is valid!'
: validationResult.error || ''
: ''
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1" role="status" aria-live="polite">
{validationResult === null ? (
<p className="rounded-lg border border-dashed border-fd-border px-4 py-3 text-sm text-fd-muted-foreground">
Validation results will appear here once you paste or drop YAML content.
</p>
) : validationResult.valid ? (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle className="size-4 shrink-0" aria-hidden="true" />
YAML is valid!
</div>
) : (
<div className="flex items-start gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400">
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>{validationResult.error}</span>
</div>
)}
</div>
{yaml.trim() !== '' && (
<button
onClick={() => setYaml('')}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-fd-border px-3 py-2.5 text-sm text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-foreground"
aria-label="Clear editor"
>
<Trash2 className="size-3.5" aria-hidden="true" />
Clear
</button>
)}
</div>
<style>{`
.ace-error-marker {
position: absolute;
background-color: rgba(255, 0, 0, 0.3);
z-index: 20;
}
/>
`}</style>
</div>
)
}
export default YAMLChecker

View File

@@ -9,6 +9,8 @@
"features",
"user_guides",
"translation",
"---Tools---",
"toolkit",
"---Contributing---",
"development",
"documentation"

View File

@@ -0,0 +1,10 @@
---
title: Credentials Generator
description: Generate secure random credentials for your LibreChat .env file.
---
import { CredentialsGeneratorMDX } from '@/components/tools/CredentialsGeneratorMDX'
Generate cryptographically secure random values for the required secrets in your LibreChat `.env` configuration file. Click **Generate** and then copy individual values or all of them at once.
<CredentialsGeneratorMDX />

View File

@@ -0,0 +1,13 @@
---
title: Toolkit
description: Development tools and utilities for configuring LibreChat.
---
<Cards num={2}>
<Card title="Credentials Generator" href="/docs/toolkit/credentials-generator" arrow>
Generate secure random values for CREDS_KEY, JWT_SECRET, and other .env variables.
</Card>
<Card title="YAML Validator" href="/docs/toolkit/yaml-validator" arrow>
Paste or drop your librechat.yaml to check for syntax errors and formatting issues.
</Card>
</Cards>

View File

@@ -0,0 +1,9 @@
{
"title": "Toolkit",
"icon": "Wrench",
"pages": [
"index",
"credentials-generator",
"yaml-validator"
]
}

View File

@@ -0,0 +1,10 @@
---
title: YAML Validator
description: Validate your LibreChat YAML configuration files for syntax errors.
---
import { YAMLValidatorMDX } from '@/components/tools/YAMLValidatorMDX'
Paste your `librechat.yaml` content below or drag and drop a file to check for syntax errors. The validator highlights the exact line where issues occur.
<YAMLValidatorMDX />

View File

@@ -12,6 +12,8 @@ import { QuickStartHub } from '@/components/QuickStartHub'
import { FeaturesHub } from '@/components/FeaturesHub'
import Carousel from '@/components/carousel/Carousel'
import { TrackedLink, TrackedAnchor } from '@/components/TrackedLink'
import { CredentialsGeneratorMDX } from '@/components/tools/CredentialsGeneratorMDX'
import { YAMLValidatorMDX } from '@/components/tools/YAMLValidatorMDX'
import type { ReactNode } from 'react'
function mapCalloutType(type?: string): 'info' | 'warn' | 'error' {
@@ -251,4 +253,6 @@ export const mdxComponents = {
{children}
</button>
),
CredentialsGeneratorMDX,
YAMLValidatorMDX,
}

9
lib/mdx-provider.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Minimal MDX provider for components/ directory MDX files.
* Provides component mapping without depending on Nextra.
*/
import type { ComponentType } from 'react'
export function useMDXComponents(components: Record<string, ComponentType<any>> = {}) {
return components
}

View File

@@ -1,12 +0,0 @@
'use client'
import type { ReactNode } from 'react'
interface ButtonProps {
children?: ReactNode
[key: string]: any
}
export function ClientButton({ children, ...props }: ButtonProps) {
return <button {...props}>{children}</button>
}

View File

@@ -1,103 +0,0 @@
/**
* Nextra compatibility shim for `nextra/components`.
* Provides minimal component stubs so existing pages/ components
* can resolve their imports during the migration to Fumadocs.
*
* Nextra's components use compound patterns (e.g., Cards.Card, Tabs.Tab,
* FileTree.File) which are replicated here using Object.assign.
*/
import type { ReactNode } from 'react'
interface ChildrenProps {
children?: ReactNode
[key: string]: any
}
export function Steps({ children }: ChildrenProps) {
return <div className="nextra-steps">{children}</div>
}
function TabComponent({ children }: ChildrenProps) {
return <div>{children}</div>
}
function TabsBase({ children }: ChildrenProps) {
return <div>{children}</div>
}
export const Tabs = Object.assign(TabsBase, { Tab: TabComponent })
function CardComponent({
children,
title,
href,
icon,
...props
}: ChildrenProps & { title?: string; href?: string; icon?: ReactNode }) {
const content = (
<div
className="nextra-card flex items-center gap-3 rounded-lg border border-gray-200 dark:border-gray-800 p-4 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800/50"
{...props}
>
{icon && <span className="shrink-0 [&>svg]:size-6">{icon}</span>}
{title && <h3 className="m-0 text-base font-medium">{title}</h3>}
{children}
</div>
)
if (href)
return (
<a
href={href}
className="no-underline"
{...(props.target ? { target: props.target, rel: 'noopener noreferrer' } : {})}
>
{content}
</a>
)
return content
}
function CardsBase({ children }: ChildrenProps) {
return <div className="nextra-cards mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>
}
export const Cards = Object.assign(CardsBase, { Card: CardComponent })
function FileComponent({
children,
name,
...props
}: ChildrenProps & { name?: string; active?: boolean }) {
return (
<div className="file" {...props}>
{name && <span>{name}</span>}
{children}
</div>
)
}
function FolderComponent({
children,
name,
...props
}: ChildrenProps & { name?: string; defaultOpen?: boolean }) {
return (
<div className="folder" {...props}>
{name && <span>{name}/</span>}
{children}
</div>
)
}
function FileTreeBase({ children }: ChildrenProps) {
return <div>{children}</div>
}
export const FileTree = Object.assign(FileTreeBase, {
File: FileComponent,
Folder: FolderComponent,
})
export function Button({ children, ...props }: ChildrenProps) {
return <button {...props}>{children}</button>
}

View File

@@ -1,95 +0,0 @@
/**
* Nextra compatibility shim for `nextra/context`.
* Provides stub implementations so existing pages/ components
* can resolve their imports during the migration to Fumadocs.
*
* Components like ChangelogHeader use:
* const pages = getPagesUnderRoute('/changelog');
* const page = pages.find(p => p.route === router.pathname);
* const { title } = page.frontMatter;
*
* We return a Proxy-based array that always matches .find() calls,
* returning a safe stub page with empty frontMatter defaults.
*/
const safeFrontMatter: Record<string, any> = new Proxy(
{
title: '',
description: '',
date: new Date().toISOString(),
ogImage: '',
ogVideo: '',
gif: undefined,
authorid: '',
author: '',
version: '',
tags: [],
category: '',
featured: false,
},
{
get(target, prop) {
if (prop in target) return target[prop as string]
return ''
},
},
)
function createStubPage(route: string): any {
return {
name: route.split('/').pop() || 'stub',
route,
kind: 'MdxPage',
frontMatter: safeFrontMatter,
meta: { title: '' },
children: [],
}
}
/**
* Returns a proxy array that intercepts .find() calls to always return
* a valid stub page, preventing undefined access errors.
*/
export function getPagesUnderRoute(route: string): any[] {
const stubArray: any[] = []
return new Proxy(stubArray, {
get(target, prop, receiver) {
if (prop === 'find') {
return (_predicate: any) => createStubPage(route)
}
if (prop === 'filter') {
return (_predicate: any) => []
}
if (prop === 'sort') {
return (_compareFn: any) => receiver
}
if (prop === 'slice') {
return () => []
}
if (prop === 'map') {
return (_mapFn: any) => []
}
if (prop === 'flatMap') {
return (_mapFn: any) => []
}
if (prop === 'length') {
return 0
}
if (prop === Symbol.iterator) {
return function* () {}
}
return Reflect.get(target, prop, receiver)
},
})
}
/**
* Stub for useConfig - returns a config object with empty frontMatter.
*/
export function useConfig(): any {
return {
title: '',
frontMatter: safeFrontMatter,
}
}

View File

@@ -1,23 +0,0 @@
/**
* Nextra compatibility shim for `nextra` (main package).
* Provides stub type exports so existing pages/ components
* can resolve their imports during the migration to Fumadocs.
*/
export interface Page {
name: string
route: string
children?: Page[]
meta?: Record<string, any>
frontMatter?: Record<string, any>
[key: string]: any
}
export interface MdxFile {
name: string
route: string
frontMatter?: Record<string, any>
[key: string]: any
}
export type { Page as default }

View File

@@ -1,144 +0,0 @@
/**
* MDX components provider for the Nextra compatibility layer.
* This module is used as the `providerImportSource` for @mdx-js/loader
* to automatically provide components that Nextra used to inject
* via theme.config.tsx's `components` property.
*/
import type { ReactNode, ComponentType } from 'react'
interface ChildrenProps {
children?: ReactNode
[key: string]: any
}
function Callout({ children, type = 'info', ...props }: ChildrenProps & { type?: string }) {
return (
<div data-callout={type} className="callout" {...props}>
{children}
</div>
)
}
function Steps({ children }: ChildrenProps) {
return <div className="steps">{children}</div>
}
function Tab({ children }: ChildrenProps) {
return <div>{children}</div>
}
function TabsComponent({ children }: ChildrenProps) {
return <div>{children}</div>
}
/** Nextra's Tabs component uses compound pattern: `<Tabs.Tab>` */
const Tabs = Object.assign(TabsComponent, { Tab })
function Card({
children,
title,
href,
...props
}: ChildrenProps & { title?: string; href?: string }) {
const content = (
<div className="card" {...props}>
{title && <h3>{title}</h3>}
{children}
</div>
)
if (href) return <a href={href}>{content}</a>
return content
}
function CardsComponent({ children }: ChildrenProps) {
return <div className="cards">{children}</div>
}
/** Nextra's Cards component uses compound pattern: `<Cards.Card>` */
const Cards = Object.assign(CardsComponent, { Card })
function FileComponent({ children, name, ...props }: ChildrenProps & { name?: string }) {
return (
<div className="file" {...props}>
{name && <span>{name}</span>}
{children}
</div>
)
}
function FolderComponent({ children, name, ...props }: ChildrenProps & { name?: string }) {
return (
<div className="folder" {...props}>
{name && <span>{name}/</span>}
{children}
</div>
)
}
function FileTreeBase({ children }: ChildrenProps) {
return <div className="file-tree">{children}</div>
}
/** Nextra's FileTree uses compound pattern: `<FileTree.File>`, `<FileTree.Folder>` */
const FileTree = Object.assign(FileTreeBase, {
File: FileComponent,
Folder: FolderComponent,
})
function OptionTable({ options, ...props }: { options: any[]; [key: string]: any }) {
if (!options || !Array.isArray(options)) return null
return (
<table {...props}>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{options.map((opt: any, i: number) => (
<tr key={i}>
<td>{opt[0]}</td>
<td>{opt[1]}</td>
<td>{opt[2]}</td>
</tr>
))}
</tbody>
</table>
)
}
function Button({ children, ...props }: ChildrenProps) {
return <button {...props}>{children}</button>
}
function Carousel({ children }: ChildrenProps) {
return <div className="carousel">{children}</div>
}
function Frame({ children }: ChildrenProps) {
return <div className="frame">{children}</div>
}
const components: Record<string, ComponentType<any>> = {
Callout,
Steps,
Tabs,
Tab,
Cards,
Card,
FileTree,
OptionTable,
Button,
Carousel,
Frame,
}
export function useMDXComponents(existing: Record<string, ComponentType<any>> = {}) {
return { ...components, ...existing }
}
export function MDXProvider({ children }: { children: ReactNode }) {
return <>{children}</>
}

View File

@@ -1,15 +0,0 @@
/**
* Webpack loader that replaces Nextra _meta.ts file content with a
* dummy React component default export. This prevents Next.js from
* failing during build optimization when it expects all files in
* pages/ to export a React component.
*
* This shim is temporary and will be removed once all pages/ content
* is migrated to the Fumadocs app/ directory.
*/
module.exports = function metaLoader() {
return `
export default function MetaPlaceholder() { return null; }
export const getStaticProps = () => ({ notFound: true });
`;
};

View File

@@ -1,33 +0,0 @@
/**
* Nextra compatibility shim for `nextra-theme-docs`.
* Provides stub implementations so existing pages/ components
* can resolve their imports during the migration to Fumadocs.
*/
import type { ReactNode } from 'react'
interface ChildrenProps {
children?: ReactNode
[key: string]: any
}
export type DocsThemeConfig = Record<string, any>
export function useConfig(): any {
return {
title: '',
frontMatter: {},
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function ThemeSwitch(props: any) {
return null
}
export function Link({ children, href, ...props }: ChildrenProps & { href?: string }) {
return (
<a href={href} {...props}>
{children}
</a>
)
}

View File

@@ -59,15 +59,10 @@ const nonPermanentRedirects = [
['/docs/user_guides/plugins', '/docs/features/agents'],
['/docs/features/plugins', '/docs/features/agents'],
['/docs/configuration/librechat_yaml/setup', '/docs/configuration/librechat_yaml'],
['/toolkit/yaml_checker', '/toolkit/yaml-checker'],
['/toolkit/creds_generator', '/toolkit/creds-generator'],
];
/**
* Nextra compatibility shims - redirect nextra imports to local stubs
* so the existing pages/ directory can build during the Fumadocs migration.
* These will be removed once all pages/ content is migrated to app/.
*/
const nextraShims = resolve(process.cwd(), 'lib/nextra-shims');
/** @type {import('next').NextConfig} */
const config = {
typescript: {
@@ -76,15 +71,6 @@ const config = {
turbopack: {},
pageExtensions: ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'],
webpack(webpackConfig, options) {
// Nextra compatibility: redirect nextra imports to local shims
webpackConfig.resolve.alias = {
...webpackConfig.resolve.alias,
'nextra/context': resolve(nextraShims, 'context.tsx'),
'nextra/components': resolve(nextraShims, 'components.tsx'),
nextra: resolve(nextraShims, 'index.ts'),
'nextra-theme-docs': resolve(nextraShims, 'theme-docs.tsx'),
};
/**
* Fumadocs MDX loader: only applied to content/ directory files.
* These are processed by fumadocs-mdx for the app/ router docs.
@@ -105,37 +91,24 @@ const config = {
});
/**
* Basic MDX loader for pages/ directory and components/ directory files.
* Provides minimal MDX compilation so existing pages/ content
* can compile during the migration period.
* Uses a custom providerImportSource that provides the same
* components Nextra used to auto-inject (Callout, Steps, etc.).
* MDX loader for components/ directory files.
* These are MDX files imported directly as React components
* (e.g. changelog content, repeated sections).
*/
webpackConfig.module.rules.push({
test: /\.mdx?$/,
include: [resolve(process.cwd(), 'pages'), resolve(process.cwd(), 'components')],
include: [resolve(process.cwd(), 'components')],
use: [
options.defaultLoaders.babel,
{
loader: '@mdx-js/loader',
options: {
providerImportSource: resolve(process.cwd(), 'lib/nextra-shims/mdx-components.tsx'),
providerImportSource: resolve(process.cwd(), 'lib/mdx-provider.ts'),
},
},
],
});
/**
* Replace Nextra _meta files with a dummy React component export
* so Next.js doesn't fail when encountering them as pages.
*/
webpackConfig.module.rules.push({
test: /pages[\\/].*_meta\.(ts|js|tsx|jsx)$/,
use: {
loader: resolve(process.cwd(), 'lib/nextra-shims/meta-loader.cjs'),
},
});
return webpackConfig;
},
transpilePackages: ['react-tweet', 'geist'],

View File

@@ -17,9 +17,9 @@
"homepage": "https://www.librechat.ai",
"dependencies": {
"@glidejs/glide": "^3.6.0",
"@hookform/resolvers": "^3.3.4",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@@ -114,7 +114,7 @@
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"prettier --write",
"eslint --fix"
"cross-env ESLINT_USE_FLAT_CONFIG=false eslint --fix"
]
}
}

View File

@@ -1,9 +0,0 @@
<div className="text-center py-20">
# 404: Page Not Found
# 🤷
[Submit an issue about broken links ↗](https://github.com/LibreChat-AI/librechat.ai/issues/new?title=Found%20broken%20%60%2Fdocs%60%20link.%20Please%20fix!&labels=bug)
</div>

View File

@@ -1,26 +0,0 @@
### Author profiles
- Profiles located in `pages\authors`
- create a mdx file named with your authorid
- look at the other profiles for examples
- Authors Profile pics in: `public\images\people`
- Supported socials for authors (react-social-icons):
![Socials](https://camo.githubusercontent.com/bb10ce76806a2db855ae9411682342b31f2857ce8ab62b8c0a46d3c3cdb77fdf/68747470733a2f2f7374617469632e72656163742d736f6369616c2d69636f6e732e636f6d2f726561646d652d696d6167652e706e67)
### Changelogs/Blog Headers example
⚠️ Title, Screenshot and author is automatically populated in the changelog/blog
```markdown
---
date: 2024-04-01
title: LibreChat v0.7.0
description: The v0.7.0 release of LibreChat
authorid: danny
ogImage: /images/changelog/2024-04-01-v0.7.0.png
---
import { ChangelogHeader } from "@/components/changelog/ChangelogHeader";
<ChangelogHeader />
```

View File

@@ -1,55 +0,0 @@
import '../style.css'
import '../src/overrides.css'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/router'
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Hubspot, hsPageView } from '@/components/analytics/hubspot'
import { CrispWidget } from '@/components/supportChat'
import { Banner } from '@/components/Banner'
import type { AppProps } from 'next/app'
import type PostHogType from 'posthog-js'
type PostHog = typeof PostHogType
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter()
const posthogRef = useRef<PostHog | null>(null)
useEffect(() => {
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
import('posthog-js').then(({ default: ph }) => {
ph.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.posthog.com',
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') posthog.debug()
},
})
posthogRef.current = ph
})
}
}, [])
useEffect(() => {
const handleRouteChange = (path: string) => {
posthogRef.current?.capture('$pageview')
hsPageView(path)
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [])
return (
<div className={`${GeistSans.variable} font-sans ${GeistMono.variable} font-mono`}>
<Banner storageKey="clickhouse-announcement" />
<Component {...pageProps} />
<Analytics />
<SpeedInsights />
{process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID ? <CrispWidget /> : null}
<Hubspot />
</div>
)
}

View File

@@ -1,13 +0,0 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View File

@@ -1,69 +0,0 @@
export default {
index: {
type: 'page',
title: 'LibreChat',
display: 'hidden',
theme: {
layout: 'raw',
},
},
demo: {
type: 'page',
title: 'Demo',
href: 'https://chat.librechat.ai/',
},
tos: {
title: 'Terms of Service',
type: 'page',
display: 'hidden',
},
privacy: {
title: 'Privacy Policy',
type: 'page',
display: 'hidden',
},
cookie: {
title: 'Cookie Policy',
type: 'page',
display: 'hidden',
},
about: {
title: 'About us',
type: 'page',
display: 'hidden',
theme: {
timestamp: false,
},
},
features: {
title: 'Features',
type: 'page',
display: 'hidden',
theme: {
timestamp: false,
},
},
subscribe: {
title: 'Subscribe',
type: 'page',
display: 'hidden',
},
unsubscribe: {
title: 'Unsubscribe',
type: 'page',
display: 'hidden',
},
'404': {
type: 'page',
theme: {
typesetting: 'article',
timestamp: false,
},
},
toolkit: 'ToolKit',
README: {
title: 'readme.md',
type: 'page',
display: 'hidden',
},
}

View File

@@ -1,3 +0,0 @@
https://chat.librechat.ai/
place holder document used so the demo entry in the top menu doesn't look selected at all time

View File

@@ -1,7 +0,0 @@
<div className="text-center py-20">
import SubscribeForm from '@/components/Newsletter/SubscribeForm'
<SubscribeForm />
</div>

View File

@@ -1,5 +0,0 @@
export default {
index: 'Intro',
creds_generator: 'Credentials Generator',
yaml_checker: 'YAML Validator',
}

View File

@@ -1,3 +0,0 @@
import CredsGenerator from '@/components/tools/CredentialsGeneratorBox'
<CredsGenerator />

View File

@@ -1,18 +0,0 @@
# ToolKit
## 🔐 Credentials Generator
First up is the 'Credentials Generator,' a handy tool that effortlessly generates secure crypto keys and JWT secrets.
These keys are crucial for securing sensitive environment variables like `CREDS_KEY`, `CREDS_IV`, `JWT_SECRET`, and `JWT_REFRESH_SECRET`.
<Cards>
<Cards.Card icon="🔐&nbsp;&ensp;" title="Credentials Generator" href="/toolkit/creds_generator" />
</Cards>
## 🔍 YAML Validator (beta)
Additionally, we present the beta version of our YAML Validator, designed to validate both your librechat.yaml and docker-compose.override.yml files, ensuring smooth sailing as you configure your LibreChat environment.
<Cards>
<Cards.Card icon="🔍&nbsp;&ensp;" title="YAML Validator" href="/toolkit/yaml_checker" />
</Cards>

View File

@@ -1,7 +0,0 @@
import YAMLChecker from '@/components/tools/yamlChecker'
This tool is currently in its beta testing phase. If you encounter any issues or have any doubts, please validate your results using https://yamlchecker.com/.
---
<YAMLChecker />

View File

@@ -1,7 +0,0 @@
<div className="text-center py-20">
import UnsubscribeForm from '@/components/Newsletter/UnsubscribeForm'
<UnsubscribeForm />
</div>

View File

@@ -80,21 +80,3 @@
border-radius: 10px !important;
}
.error-marker {
position: absolute;
background-color: rgba(255, 0, 0, 0.3);
z-index: 20;
}
.custom-btn {
background-color: rgba(150, 150, 150, 0.2); /* transparent gray background */
border-radius: 10px; /* rounded corners */
padding: 8px 16px; /* optional: adjust padding as needed */
border: none; /* optional: remove border */
cursor: pointer; /* optional: change cursor on hover */
}
.custom-btn:hover {
background-color: rgba(150, 150, 150, 0.4); /* darker background on hover */
}

View File

@@ -1,118 +0,0 @@
import React from 'react'
import { useRouter } from 'next/router'
import { GeistSans } from 'geist/font/sans'
import { DocsThemeConfig, useConfig, ThemeSwitch } from 'nextra-theme-docs'
import { Steps, Tabs, Cards, FileTree, Button } from 'nextra/components'
import { Callout } from '@/components/callouts/callout'
import Carousel from '@/components/carousel/Carousel'
import { Logo } from '@/components/logo'
import { Frame } from './components/Frame'
import { OptionTable } from 'components/table'
import FooterMenu from './components/FooterMenu'
/** Nextra docs theme configuration. Social card images are chosen based on the current page path (docs, blog, changelog). */
const config: DocsThemeConfig = {
logo: <Logo />,
logoLink: '/',
project: {
link: 'https://github.com/danny-avila/LibreChat',
},
chat: {
link: 'https://discord.librechat.ai',
},
search: {
placeholder: 'Search...',
},
navbar: {
extraContent: () => {
return <>{ThemeSwitch({ lite: true, className: 'button-switch theme-switch' })}</>
},
},
sidebar: {
defaultMenuCollapseLevel: 1,
toggleButton: true,
},
editLink: {
content: 'Edit this page on GitHub',
},
toc: {
backToTop: true,
},
docsRepositoryBase: 'https://github.com/LibreChat-AI/librechat.ai/tree/main',
footer: {
content: <FooterMenu />,
},
head: () => {
const { asPath, defaultLocale, locale } = useRouter()
const { frontMatter, title: pageTitle } = useConfig()
const url = 'https://librechat.ai' + (defaultLocale === locale ? asPath : `/${locale}${asPath}`)
const description = frontMatter.description ?? ''
const title = frontMatter.title ?? pageTitle
// Default frontmatter image based on path
const defaultImage = asPath.startsWith('/docs')
? '/images/socialcards/default-docs-image.png'
: asPath.startsWith('/blog')
? '/images/socialcards/default-blog-image.png'
: asPath.startsWith('/changelog')
? '/images/socialcards/default-changelog-image.png'
: '/images/socialcards/default-image.png'
const image = frontMatter.ogImage
? 'https://www.librechat.ai' + frontMatter.ogImage // Use frontmatter image if available
: defaultImage // Use default image based on path if frontmatter image is not available
const video = frontMatter.ogVideo ? 'https://www.librechat.ai' + frontMatter.ogVideo : null
return (
<>
<title>{title}</title>
<link rel="canonical" href={url} />
<meta property="og:url" content={url} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="LibreChat" />
<meta httpEquiv="Content-Language" content="en" />
<meta name="title" content={title} />
<meta property="og:title" content={title} />
<meta name="description" content={description} />
<meta property="og:description" content={description} />
{video && <meta property="og:video" content={video} />}
<meta property="og:image" content={image} />
<meta property="twitter:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site:domain" content="librechat.ai" />
<meta name="twitter:url" content="https://librechat.ai" />
<style
dangerouslySetInnerHTML={{
__html: `html { --font-geist-sans: ${GeistSans.style.fontFamily}; }`,
}}
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#080808" />
</>
)
},
components: {
Frame,
Tabs,
Steps,
Cards,
FileTree,
Callout,
Button,
Carousel,
OptionTable,
},
}
export default config