mirror of
https://github.com/LibreChat-AI/librechat.ai.git
synced 2026-03-27 02:38:32 +07:00
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:
9
app/subscribe/client.tsx
Normal file
9
app/subscribe/client.tsx
Normal 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
19
app/subscribe/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
app/toolkit/creds-generator/page.ts
Normal file
5
app/toolkit/creds-generator/page.ts
Normal 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
5
app/toolkit/page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function ToolkitPage() {
|
||||||
|
redirect('/docs/toolkit')
|
||||||
|
}
|
||||||
5
app/toolkit/yaml-checker/page.ts
Normal file
5
app/toolkit/yaml-checker/page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function YamlCheckerPage() {
|
||||||
|
redirect('/docs/toolkit/yaml-validator')
|
||||||
|
}
|
||||||
11
app/unsubscribe/client.tsx
Normal file
11
app/unsubscribe/client.tsx
Normal 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
19
app/unsubscribe/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -1,215 +1,150 @@
|
|||||||
import { useState } from 'react'
|
'use client'
|
||||||
import useCredentialsGenerator from './credentialsGenerator' // Adjust the path based on your project structure
|
|
||||||
|
|
||||||
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 { generateCredentials } = useCredentialsGenerator()
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<Credentials | null>(null)
|
||||||
CREDS_KEY: string
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
CREDS_IV: string
|
const [generated, setGenerated] = useState(false)
|
||||||
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 handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
try {
|
try {
|
||||||
const newCredentials = generateCredentials()
|
setCredentials(generateCredentials())
|
||||||
setCredentials(newCredentials)
|
setGenerated(true)
|
||||||
setCopyEnabled(true) // Enable copy after generating credentials
|
setCopiedKey(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message)
|
console.error((error as Error).message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = (value) => {
|
const copyToClipboard = useCallback(async (text: string, key: string) => {
|
||||||
navigator.clipboard
|
try {
|
||||||
.writeText(value)
|
await navigator.clipboard.writeText(text)
|
||||||
.then(() => {
|
setCopiedKey(key)
|
||||||
setShowTooltip(true) // Show tooltip on successful copy
|
setTimeout(() => setCopiedKey(null), 2000)
|
||||||
setTimeout(() => setShowTooltip(false), 2000) // Hide tooltip after 2 seconds
|
} catch (err) {
|
||||||
})
|
console.error('Copy failed:', err)
|
||||||
.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 (
|
return (
|
||||||
<div className="credentials-box">
|
<div className="space-y-6">
|
||||||
<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>}
|
|
||||||
<button
|
<button
|
||||||
className="generate-button"
|
|
||||||
style={{ display: 'block', margin: '0 auto', marginTop: '15px' }}
|
|
||||||
onClick={handleGenerate}
|
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"
|
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>
|
</button>
|
||||||
<style jsx>{`
|
|
||||||
.credentials-box {
|
{credentials && (
|
||||||
position: relative;
|
<>
|
||||||
padding: 10px;
|
<div
|
||||||
// border: 1px solid #ccc;
|
className="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||||
border-radius: 20px;
|
role="region"
|
||||||
display: inline-block;
|
aria-label="Generated credentials"
|
||||||
width: 100%;
|
>
|
||||||
margin: 0 auto;
|
{CREDENTIAL_FIELDS.map((field) => {
|
||||||
}
|
const id = `cred-${field.key}`
|
||||||
.credentials-container {
|
const isCopied = copiedKey === field.key
|
||||||
margin-top: 10px;
|
return (
|
||||||
}
|
<div
|
||||||
.input-container {
|
key={field.key}
|
||||||
display: flex;
|
className="group/field rounded-lg border border-fd-border bg-fd-card p-4 transition-colors hover:border-fd-primary/20"
|
||||||
align-items: center;
|
>
|
||||||
}
|
<div className="mb-1 flex items-center justify-between">
|
||||||
.input-container input {
|
<label
|
||||||
width: calc(100% - 70px);
|
htmlFor={id}
|
||||||
margin-right: 10px;
|
className="font-mono text-xs font-semibold text-fd-foreground"
|
||||||
border: 1px solid #ccc;
|
>
|
||||||
border-radius: 5px;
|
{field.label}
|
||||||
padding: 5px;
|
</label>
|
||||||
}
|
<button
|
||||||
.copy-button {
|
onClick={() => copyToClipboard(credentials[field.key], field.key)}
|
||||||
padding: 6px 12px; /* Adjust as needed */
|
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"
|
||||||
background: rgb(30, 163, 128);
|
aria-label={`Copy ${field.label}`}
|
||||||
color: #fff;
|
>
|
||||||
border: none;
|
{isCopied ? (
|
||||||
border-radius: 5px;
|
<>
|
||||||
cursor: pointer;
|
<Check className="size-3 text-emerald-500" aria-hidden="true" />
|
||||||
width: auto;
|
<span className="text-emerald-500">Copied</span>
|
||||||
font-size: 0.8rem; /* Adjusted size */
|
</>
|
||||||
}
|
) : (
|
||||||
.generate-button {
|
<>
|
||||||
padding: 6px 12px; /* Adjust as needed */
|
<Copy className="size-3" aria-hidden="true" />
|
||||||
background: rgb(30, 163, 128);
|
<span>Copy</span>
|
||||||
color: #fff;
|
</>
|
||||||
border: none;
|
)}
|
||||||
border-radius: 5px;
|
</button>
|
||||||
cursor: pointer;
|
</div>
|
||||||
width: auto;
|
<p className="mb-2 text-xs text-fd-muted-foreground">{field.hint}</p>
|
||||||
font-size: 1rem; /* Adjusted size */
|
<input
|
||||||
}
|
id={id}
|
||||||
.tooltip {
|
readOnly
|
||||||
position: absolute;
|
value={credentials[field.key]}
|
||||||
top: 10px;
|
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"
|
||||||
right: 10px;
|
aria-label={`${field.label} value`}
|
||||||
background-color: rgba(30, 163, 128, 0.5);
|
/>
|
||||||
color: #fff;
|
</div>
|
||||||
padding: 5px 10px;
|
)
|
||||||
border-radius: 5px;
|
})}
|
||||||
z-index: 999; /* Ensure a higher z-index */
|
</div>
|
||||||
}
|
|
||||||
`}</style>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CredsGenerator
|
|
||||||
|
|||||||
12
components/tools/CredentialsGeneratorMDX.tsx
Normal file
12
components/tools/CredentialsGeneratorMDX.tsx
Normal 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 />
|
||||||
|
}
|
||||||
12
components/tools/YAMLValidatorMDX.tsx
Normal file
12
components/tools/YAMLValidatorMDX.tsx
Normal 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 />
|
||||||
|
}
|
||||||
@@ -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 AceEditor, { IMarker } from 'react-ace'
|
||||||
import 'ace-builds/src-noconflict/mode-yaml'
|
import 'ace-builds/src-noconflict/mode-yaml'
|
||||||
import 'ace-builds/src-noconflict/theme-twilight'
|
import 'ace-builds/src-noconflict/theme-twilight'
|
||||||
|
import 'ace-builds/src-noconflict/theme-chrome'
|
||||||
import jsYaml from 'js-yaml'
|
import jsYaml from 'js-yaml'
|
||||||
|
import { CheckCircle, XCircle, Trash2, Upload } from 'lucide-react'
|
||||||
|
|
||||||
function YAMLChecker() {
|
export default function YAMLValidator() {
|
||||||
const [yaml, setYaml] = useState('')
|
const [yaml, setYaml] = useState('')
|
||||||
const [validationResult, setValidationResult] = useState<{
|
const [validationResult, setValidationResult] = useState<{
|
||||||
valid: boolean
|
valid: boolean
|
||||||
@@ -12,49 +16,55 @@ function YAMLChecker() {
|
|||||||
error?: string
|
error?: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [errorLine, setErrorLine] = useState<number | 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 {
|
try {
|
||||||
const result = jsYaml.load(yamlContent)
|
const result = jsYaml.load(yamlContent)
|
||||||
setErrorLine(null) // No error
|
setErrorLine(null)
|
||||||
return { valid: true, result: JSON.stringify(result, null, 2) }
|
return { valid: true, result: JSON.stringify(result, null, 2) }
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
let errorMessage = ''
|
const yamlError = error as { reason?: string; mark?: { line?: number } }
|
||||||
const line = error.mark?.line
|
const line = yamlError.mark?.line
|
||||||
setErrorLine(line)
|
setErrorLine(line ?? null)
|
||||||
if (error.reason === 'bad indentation of a mapping entry') {
|
const errorMessage =
|
||||||
errorMessage = ` Incorrect indentation at line ${line + 1}. Each entry in YAML should be properly indented.`
|
yamlError.reason === 'bad indentation of a mapping entry'
|
||||||
} else {
|
? `Incorrect indentation at line ${(line ?? 0) + 1}. Each entry in YAML should be properly indented.`
|
||||||
errorMessage = ` ${error.reason} at line ${line + 1}`
|
: `${yamlError.reason} at line ${(line ?? 0) + 1}`
|
||||||
}
|
|
||||||
return { valid: false, error: errorMessage }
|
return { valid: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleYamlChange = (newYaml: string) => {
|
|
||||||
setYaml(newYaml)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
const file = e.dataTransfer.files[0]
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (!file) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => setYaml(reader.result as string)
|
||||||
const fileContent = reader.result as string
|
|
||||||
setYaml(fileContent)
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (yaml.trim() === '') {
|
if (yaml.trim() === '') {
|
||||||
setValidationResult(null) // Clear validation result if YAML is empty
|
setValidationResult(null)
|
||||||
|
setErrorLine(null)
|
||||||
} else {
|
} else {
|
||||||
const result = validateYAML(yaml)
|
setValidationResult(validateYAML(yaml))
|
||||||
setValidationResult(result)
|
|
||||||
}
|
}
|
||||||
}, [yaml]) // Trigger validation whenever `yaml` changes
|
}, [yaml, validateYAML])
|
||||||
|
|
||||||
const errorMarkers: IMarker[] =
|
const errorMarkers: IMarker[] =
|
||||||
errorLine === null
|
errorLine === null
|
||||||
@@ -66,65 +76,91 @@ function YAMLChecker() {
|
|||||||
startCol: 0,
|
startCol: 0,
|
||||||
endCol: Number.MAX_VALUE,
|
endCol: Number.MAX_VALUE,
|
||||||
type: 'text',
|
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 (
|
return (
|
||||||
<div style={{ width: '100%', maxWidth: '800px', margin: '0 auto' }}>
|
<div className="space-y-4">
|
||||||
<h2 style={{ textAlign: 'left', fontSize: '1.5rem', margin: '10px 0' }}>
|
|
||||||
YAML Validator (beta)
|
|
||||||
</h2>
|
|
||||||
<div
|
<div
|
||||||
onDrop={handleFileDrop}
|
onDrop={handleFileDrop}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => {
|
||||||
style={{ width: '100%', marginBottom: '10px' }}
|
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
|
<AceEditor
|
||||||
mode="yaml"
|
mode="yaml"
|
||||||
theme="twilight"
|
theme={isDark ? 'twilight' : 'chrome'}
|
||||||
onChange={handleYamlChange}
|
onChange={setYaml}
|
||||||
value={yaml}
|
value={yaml}
|
||||||
name="YAML_EDITOR"
|
name="YAML_EDITOR"
|
||||||
editorProps={{ $blockScrolling: true }}
|
editorProps={{ $blockScrolling: true }}
|
||||||
setOptions={{
|
setOptions={{
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
highlightActiveLine: false,
|
highlightActiveLine: false,
|
||||||
|
showPrintMargin: false,
|
||||||
|
tabSize: 2,
|
||||||
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
markers={errorMarkers}
|
markers={errorMarkers}
|
||||||
style={{ width: '100%' }}
|
width="100%"
|
||||||
placeholder="Paste the content of the YAML file here or drop a file here..."
|
height="500px"
|
||||||
|
placeholder="Paste your librechat.yaml content here, or drag & drop a file..."
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
|
||||||
readOnly
|
<div className="flex items-start gap-3">
|
||||||
style={textAreaStyle}
|
<div className="min-w-0 flex-1" role="status" aria-live="polite">
|
||||||
placeholder="Validation Result will be displayed here"
|
{validationResult === null ? (
|
||||||
value={
|
<p className="rounded-lg border border-dashed border-fd-border px-4 py-3 text-sm text-fd-muted-foreground">
|
||||||
validationResult
|
Validation results will appear here once you paste or drop YAML content.
|
||||||
? validationResult.valid
|
</p>
|
||||||
? 'YAML is valid!'
|
) : validationResult.valid ? (
|
||||||
: validationResult.error || ''
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default YAMLChecker
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"features",
|
"features",
|
||||||
"user_guides",
|
"user_guides",
|
||||||
"translation",
|
"translation",
|
||||||
|
"---Tools---",
|
||||||
|
"toolkit",
|
||||||
"---Contributing---",
|
"---Contributing---",
|
||||||
"development",
|
"development",
|
||||||
"documentation"
|
"documentation"
|
||||||
|
|||||||
10
content/docs/toolkit/credentials-generator.mdx
Normal file
10
content/docs/toolkit/credentials-generator.mdx
Normal 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 />
|
||||||
13
content/docs/toolkit/index.mdx
Normal file
13
content/docs/toolkit/index.mdx
Normal 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>
|
||||||
9
content/docs/toolkit/meta.json
Normal file
9
content/docs/toolkit/meta.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Toolkit",
|
||||||
|
"icon": "Wrench",
|
||||||
|
"pages": [
|
||||||
|
"index",
|
||||||
|
"credentials-generator",
|
||||||
|
"yaml-validator"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
content/docs/toolkit/yaml-validator.mdx
Normal file
10
content/docs/toolkit/yaml-validator.mdx
Normal 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 />
|
||||||
@@ -12,6 +12,8 @@ import { QuickStartHub } from '@/components/QuickStartHub'
|
|||||||
import { FeaturesHub } from '@/components/FeaturesHub'
|
import { FeaturesHub } from '@/components/FeaturesHub'
|
||||||
import Carousel from '@/components/carousel/Carousel'
|
import Carousel from '@/components/carousel/Carousel'
|
||||||
import { TrackedLink, TrackedAnchor } from '@/components/TrackedLink'
|
import { TrackedLink, TrackedAnchor } from '@/components/TrackedLink'
|
||||||
|
import { CredentialsGeneratorMDX } from '@/components/tools/CredentialsGeneratorMDX'
|
||||||
|
import { YAMLValidatorMDX } from '@/components/tools/YAMLValidatorMDX'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
function mapCalloutType(type?: string): 'info' | 'warn' | 'error' {
|
function mapCalloutType(type?: string): 'info' | 'warn' | 'error' {
|
||||||
@@ -251,4 +253,6 @@ export const mdxComponents = {
|
|||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
|
CredentialsGeneratorMDX,
|
||||||
|
YAMLValidatorMDX,
|
||||||
}
|
}
|
||||||
|
|||||||
9
lib/mdx-provider.ts
Normal file
9
lib/mdx-provider.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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}</>
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -59,15 +59,10 @@ const nonPermanentRedirects = [
|
|||||||
['/docs/user_guides/plugins', '/docs/features/agents'],
|
['/docs/user_guides/plugins', '/docs/features/agents'],
|
||||||
['/docs/features/plugins', '/docs/features/agents'],
|
['/docs/features/plugins', '/docs/features/agents'],
|
||||||
['/docs/configuration/librechat_yaml/setup', '/docs/configuration/librechat_yaml'],
|
['/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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
typescript: {
|
typescript: {
|
||||||
@@ -76,15 +71,6 @@ const config = {
|
|||||||
turbopack: {},
|
turbopack: {},
|
||||||
pageExtensions: ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'],
|
pageExtensions: ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'],
|
||||||
webpack(webpackConfig, options) {
|
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.
|
* Fumadocs MDX loader: only applied to content/ directory files.
|
||||||
* These are processed by fumadocs-mdx for the app/ router docs.
|
* 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.
|
* MDX loader for components/ directory files.
|
||||||
* Provides minimal MDX compilation so existing pages/ content
|
* These are MDX files imported directly as React components
|
||||||
* can compile during the migration period.
|
* (e.g. changelog content, repeated sections).
|
||||||
* Uses a custom providerImportSource that provides the same
|
|
||||||
* components Nextra used to auto-inject (Callout, Steps, etc.).
|
|
||||||
*/
|
*/
|
||||||
webpackConfig.module.rules.push({
|
webpackConfig.module.rules.push({
|
||||||
test: /\.mdx?$/,
|
test: /\.mdx?$/,
|
||||||
include: [resolve(process.cwd(), 'pages'), resolve(process.cwd(), 'components')],
|
include: [resolve(process.cwd(), 'components')],
|
||||||
use: [
|
use: [
|
||||||
options.defaultLoaders.babel,
|
options.defaultLoaders.babel,
|
||||||
{
|
{
|
||||||
loader: '@mdx-js/loader',
|
loader: '@mdx-js/loader',
|
||||||
options: {
|
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;
|
return webpackConfig;
|
||||||
},
|
},
|
||||||
transpilePackages: ['react-tweet', 'geist'],
|
transpilePackages: ['react-tweet', 'geist'],
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
"homepage": "https://www.librechat.ai",
|
"homepage": "https://www.librechat.ai",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@glidejs/glide": "^3.6.0",
|
"@glidejs/glide": "^3.6.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx}": [
|
"*.{ts,tsx,js,jsx}": [
|
||||||
"prettier --write",
|
"prettier --write",
|
||||||
"eslint --fix"
|
"cross-env ESLINT_USE_FLAT_CONFIG=false eslint --fix"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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):
|
|
||||||

|
|
||||||
|
|
||||||
### 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 />
|
|
||||||
```
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<div className="text-center py-20">
|
|
||||||
|
|
||||||
import SubscribeForm from '@/components/Newsletter/SubscribeForm'
|
|
||||||
|
|
||||||
<SubscribeForm />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export default {
|
|
||||||
index: 'Intro',
|
|
||||||
creds_generator: 'Credentials Generator',
|
|
||||||
yaml_checker: 'YAML Validator',
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import CredsGenerator from '@/components/tools/CredentialsGeneratorBox'
|
|
||||||
|
|
||||||
<CredsGenerator />
|
|
||||||
@@ -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="🔐  " 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="🔍  " title="YAML Validator" href="/toolkit/yaml_checker" />
|
|
||||||
</Cards>
|
|
||||||
|
|
||||||
@@ -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 />
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<div className="text-center py-20">
|
|
||||||
|
|
||||||
import UnsubscribeForm from '@/components/Newsletter/UnsubscribeForm'
|
|
||||||
|
|
||||||
<UnsubscribeForm />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
18
style.css
18
style.css
@@ -80,21 +80,3 @@
|
|||||||
border-radius: 10px !important;
|
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 */
|
|
||||||
}
|
|
||||||
|
|||||||
118
theme.config.tsx
118
theme.config.tsx
@@ -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
|
|
||||||
Reference in New Issue
Block a user