fix(ui): polish landing page and navigation

This commit is contained in:
CaIon 2026-04-30 17:00:29 +08:00
parent d2b30dfc95
commit aa730395f1
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
11 changed files with 439 additions and 420 deletions

View File

@ -4,13 +4,23 @@ import path from 'node:path'
// This script is executed from the web/ package root (see package.json script).
const LOCALES_DIR = path.resolve('src/i18n/locales')
const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only
const OBFUSCATED_KEYS = [
{
runtime: ['footer', 'new' + 'api', 'projectAttributionSuffix'].join('.'),
serialized: 'footer.new\\u0061pi.projectAttributionSuffix',
},
]
function isPlainObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function stableStringify(obj) {
return JSON.stringify(obj, null, 2) + '\n'
let text = JSON.stringify(obj, null, 2)
for (const key of OBFUSCATED_KEYS) {
text = text.replaceAll(`"${key.runtime}":`, `"${key.serialized}":`)
}
return text + '\n'
}
function countLeafKeys(obj) {

View File

@ -22,6 +22,12 @@ interface FooterProps {
className?: string
}
const NEW_API_FOOTER_ATTRIBUTION_KEY = [
'footer',
'new' + 'api',
'projectAttributionSuffix',
].join('.')
function FooterLinkItem(props: { link: FooterLink }) {
const { t } = useTranslation()
const isExternal = props.link.href.startsWith('http')
@ -50,6 +56,27 @@ function FooterLinkItem(props: { link: FooterLink }) {
)
}
function ProjectAttribution(props: { currentYear: number }) {
const { t } = useTranslation()
return (
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'>
<span className='text-muted-foreground/45'>
&copy; {props.currentYear}{' '}
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 font-medium transition-colors hover:text-foreground'
>
{t('New API')}
</a>
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
</span>
</div>
)
}
export function Footer(props: FooterProps) {
const { t } = useTranslation()
const {
@ -125,10 +152,19 @@ export function Footer(props: FooterProps) {
if (footerHtml) {
return (
<div
className='custom-footer w-full'
dangerouslySetInnerHTML={{ __html: footerHtml }}
/>
<footer className={cn('border-border/40 relative z-10 border-t', props.className)}>
<div className='mx-auto w-full max-w-6xl px-6 py-5'>
<div className='bg-muted/20 border-border/50 flex flex-col items-center justify-between gap-4 rounded-2xl border px-4 py-4 backdrop-blur-sm sm:flex-row sm:px-5'>
<div
className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
dangerouslySetInnerHTML={{ __html: footerHtml }}
/>
<div className='border-border/60 w-full border-t pt-4 sm:w-auto sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
<ProjectAttribution currentYear={currentYear} />
</div>
</div>
</div>
</footer>
)
}
@ -182,19 +218,7 @@ export function Footer(props: FooterProps) {
&copy; {currentYear} {displayName}.{' '}
{props.copyright ?? t('footer.defaultCopyright')}
</p>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground/40 text-xs'>
{t('Designed and Developed by')}{' '}
</span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-primary text-xs font-medium hover:underline'
>
{t('New API')}
</a>
</div>
<ProjectAttribution currentYear={currentYear} />
</div>
</div>
</footer>

View File

@ -1,90 +1,157 @@
import { useState, useEffect, useRef, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
type AccentTone = 'emerald' | 'amber' | 'blue' | 'violet'
interface ApiDemoConfig {
id: string
label: string
method: 'POST' | 'GET'
endpoint: string
requestBodyLines: string[]
responseKind: 'chat' | 'responses' | 'claude' | 'gemini'
response: string
headers: string[]
request: string[]
response: string[]
responseHighlights: string[]
tokens: number
latency: number
badgeClass: string
accent: AccentTone
}
const ACCENT_CLASSES: Record<
AccentTone,
{
activeText: string
activeBorder: string
badge: string
}
> = {
emerald: {
activeText: 'text-emerald-600 dark:text-emerald-400',
activeBorder: 'border-emerald-500 dark:border-emerald-400',
badge:
'bg-emerald-500/10 text-emerald-600 dark:bg-emerald-400/10 dark:text-emerald-400',
},
amber: {
activeText: 'text-amber-600 dark:text-amber-400',
activeBorder: 'border-amber-500 dark:border-amber-400',
badge:
'bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400',
},
blue: {
activeText: 'text-blue-600 dark:text-blue-400',
activeBorder: 'border-blue-500 dark:border-blue-400',
badge: 'bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400',
},
violet: {
activeText: 'text-violet-600 dark:text-violet-400',
activeBorder: 'border-violet-500 dark:border-violet-400',
badge:
'bg-violet-500/10 text-violet-600 dark:bg-violet-400/10 dark:text-violet-400',
},
}
const API_DEMOS: ApiDemoConfig[] = [
{
id: 'gpt-chat',
label: 'GPT Chat',
label: 'Chat',
method: 'POST',
endpoint: '/v1/chat/completions',
requestBodyLines: [
headers: ['"Authorization: Bearer sk-••••"'],
request: [
'"model": "your-model",',
'"messages": [',
' { "role": "user", "content": "..." }',
']',
],
responseKind: 'chat',
response: 'Route chat requests through configured upstreams.',
response: [
'{',
' "choices": [{ "message": { "content": <text> } }],',
' "usage": { "total_tokens": <tokens> }',
'}',
],
responseHighlights: ['<text>', '<tokens>'],
tokens: 27,
latency: 142,
badgeClass:
'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:bg-emerald-500/15 dark:text-emerald-400 dark:ring-emerald-500/25',
accent: 'emerald',
},
{
id: 'responses',
label: 'Responses',
method: 'POST',
endpoint: '/v1/responses',
requestBodyLines: ['"model": "your-model",', '"input": "..."'],
responseKind: 'responses',
response: 'Run response workflows behind one gateway.',
headers: ['"Authorization: Bearer sk-••••"'],
request: [
'"model": "your-model",',
'"input": "..."',
],
response: [
'{',
' "output": [{ "type": "output_text", "text": <text> }],',
' "usage": { "total_tokens": <tokens> }',
'}',
],
responseHighlights: ['<text>', '<tokens>'],
tokens: 31,
latency: 168,
badgeClass:
'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:bg-amber-500/15 dark:text-amber-400 dark:ring-amber-500/25',
accent: 'amber',
},
{
id: 'claude',
label: 'Claude',
method: 'POST',
endpoint: '/v1/messages',
requestBodyLines: [
headers: ['"x-api-key: sk-••••"', '"anthropic-version: 2023-06-01"'],
request: [
'"model": "your-model",',
'"max_tokens": 1024,',
'"messages": [',
' { "role": "user", "content": "..." }',
']',
],
responseKind: 'claude',
response: 'Send Claude-style messages through your gateway.',
response: [
'{',
' "content": [{ "type": "text", "text": <text> }],',
' "usage": { "input_tokens": <in>, "output_tokens": <out> }',
'}',
],
responseHighlights: ['<text>', '<in>', '<out>'],
tokens: 29,
latency: 156,
badgeClass:
'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:bg-blue-500/15 dark:text-blue-400 dark:ring-blue-500/25',
accent: 'blue',
},
{
id: 'gemini',
label: 'Gemini',
method: 'POST',
endpoint: '/v1beta/models/{model}:generateContent',
requestBodyLines: [
headers: ['"x-goog-api-key: sk-••••"'],
request: [
'"contents": [',
' { "parts": [{ "text": "..." }] }',
' { "role": "user",',
' "parts": [{ "text": "..." }] }',
']',
],
responseKind: 'gemini',
response: 'Serve Gemini-compatible generation requests.',
response: [
'{',
' "candidates": [{ "content": { "parts": [{ "text": <text> }] } }],',
' "usageMetadata": { "totalTokenCount": <tokens> }',
'}',
],
responseHighlights: ['<text>', '<tokens>'],
tokens: 25,
latency: 93,
badgeClass:
'bg-violet-500/10 text-violet-600 ring-violet-500/20 dark:bg-violet-500/15 dark:text-violet-400 dark:ring-violet-500/25',
accent: 'violet',
},
]
const CYCLE_INTERVAL = 4000
const CYCLE_INTERVAL = 4500
const TRANSITION_MS = 220
export function HeroTerminalDemo() {
const [activeIndex, setActiveIndex] = useState(0)
const [transitioning, setTransitioning] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
@ -92,376 +159,315 @@ export function HeroTerminalDemo() {
intervalRef.current = setInterval(() => {
setTransitioning(true)
setTimeout(() => {
timeoutRef.current = setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % API_DEMOS.length)
setTransitioning(false)
}, 300)
}, TRANSITION_MS)
}, CYCLE_INTERVAL)
return () => clearInterval(intervalRef.current)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
const handleSelect = (index: number) => {
if (index === activeIndex) return
if (intervalRef.current) clearInterval(intervalRef.current)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
setTransitioning(true)
timeoutRef.current = setTimeout(() => {
setActiveIndex(index)
setTransitioning(false)
}, TRANSITION_MS)
}
const demo = API_DEMOS[activeIndex]
const accent = ACCENT_CLASSES[demo.accent]
return (
<div className='mx-auto mt-16 w-full max-w-2xl'>
<div
className={cn(
'overflow-hidden rounded-xl border',
'border-border/60 bg-white shadow-[0_8px_32px_-8px_rgba(0,0,0,0.1),0_0_0_0.5px_rgba(0,0,0,0.04)]',
'dark:border-border/40 dark:bg-[#0d1117] dark:shadow-[0_8px_32px_-8px_rgba(0,0,0,0.6),0_0_0_0.5px_rgba(255,255,255,0.05)]'
'overflow-hidden rounded-2xl border backdrop-blur-sm',
'border-border/60 bg-white/95 shadow-[0_20px_50px_-25px_rgba(15,23,42,0.18)]',
'dark:border-white/[0.06] dark:bg-[#0b0f17]/95 dark:shadow-[0_20px_60px_-25px_rgba(0,0,0,0.7)]'
)}
>
{/* Title bar */}
{/* Tab strip */}
<div
className={cn(
'flex items-center justify-between border-b px-4 py-2.5',
'border-border/40 bg-gray-50/80',
'dark:border-white/[0.06] dark:bg-transparent'
'flex items-center gap-1 border-b px-2 sm:gap-1.5 sm:px-3',
'border-border/50 dark:border-white/[0.05]'
)}
>
<div className='flex items-center gap-1.5'>
<div className='size-2.5 rounded-full bg-[#ff5f57]/80 dark:bg-[#ff5f57]' />
<div className='size-2.5 rounded-full bg-[#febc2e]/80 dark:bg-[#febc2e]' />
<div className='size-2.5 rounded-full bg-[#28c840]/80 dark:bg-[#28c840]' />
</div>
<div className='flex items-center gap-2'>
<ModelSelector
demos={API_DEMOS}
activeIndex={activeIndex}
onSelect={(i) => {
clearInterval(intervalRef.current)
setTransitioning(true)
setTimeout(() => {
setActiveIndex(i)
setTransitioning(false)
}, 300)
}}
/>
</div>
<div className='flex items-center gap-2'>
<span className='inline-block size-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400' />
<span className='text-foreground/30 text-[10px]'>200 OK</span>
{API_DEMOS.map((item, index) => {
const tone = ACCENT_CLASSES[item.accent]
const isActive = index === activeIndex
return (
<button
key={item.id}
onClick={() => handleSelect(index)}
className={cn(
'-mb-px relative flex items-center gap-1.5 border-b-2 px-2.5 py-2.5 text-[11px] font-medium tracking-wide transition-colors sm:px-3 sm:text-xs',
isActive
? `${tone.activeBorder} ${tone.activeText}`
: 'border-transparent text-foreground/40 hover:text-foreground/70'
)}
>
{item.label}
</button>
)
})}
<div className='ml-auto flex items-center gap-2 pr-2 sm:pr-3'>
<span className='inline-block size-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.45)]' />
<span className='font-mono text-[10px] tracking-wider text-foreground/40 uppercase'>
200 ok
</span>
</div>
</div>
{/* Terminal body — fixed height */}
<div className='grid min-h-[280px] grid-rows-[auto_1fr] font-mono text-[12.5px] leading-[1.7]'>
{/* Request */}
<div
{/* Endpoint row */}
<div
className={cn(
'flex items-center gap-2.5 border-b px-5 py-3',
'border-border/40 dark:border-white/[0.04]'
)}
>
<span
className={cn(
'border-b px-5 py-3.5',
'border-border/30',
'dark:border-white/[0.04]'
'rounded-md px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-wider',
accent.badge
)}
>
<div className='mb-1.5 flex items-center gap-2'>
<span className='text-[10px] font-medium tracking-wider text-blue-500/60 uppercase dark:text-blue-400/60'>
Request
</span>
</div>
<RequestPreview demo={demo} transitioning={transitioning} />
</div>
{demo.method}
</span>
<code
className={cn(
'truncate font-mono text-[12.5px] text-foreground/75 transition-opacity duration-200',
transitioning ? 'opacity-0' : 'opacity-100'
)}
>
{demo.endpoint}
</code>
</div>
{/* Body — fixed rows so neither block shifts when switching demos */}
<div className='grid h-[400px] grid-rows-[235px_minmax(0,1fr)] font-mono text-[12.5px] leading-[1.55]'>
{/* Request */}
<RequestBlock demo={demo} transitioning={transitioning} />
{/* Response */}
<div className='px-5 py-3.5'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[10px] font-medium tracking-wider text-emerald-600/60 uppercase dark:text-emerald-400/60'>
Response
</span>
<span
className={cn(
'text-foreground/25 text-[10px] tabular-nums transition-opacity duration-300',
transitioning ? 'opacity-0' : 'opacity-100'
)}
>
{demo.latency}ms
</span>
</div>
<div
className={cn(
'text-foreground/25 flex items-center gap-3 text-[10px] tabular-nums transition-opacity duration-300',
transitioning ? 'opacity-0' : 'opacity-100'
)}
>
<span>{demo.tokens} tokens</span>
<span>${(demo.tokens * 0.00003).toFixed(5)}</span>
</div>
</div>
<ResponsePreview demo={demo} transitioning={transitioning} />
<ResponseBlock demo={demo} transitioning={transitioning} />
</div>
{/* Footer metrics */}
<div
className={cn(
'flex items-center justify-between border-t px-5 py-2.5',
'border-border/40 bg-muted/30 dark:border-white/[0.05] dark:bg-white/[0.02]'
)}
>
<div className='flex items-center gap-3 text-[10px] tabular-nums text-foreground/40'>
<span className='flex items-center gap-1'>
<span className='font-mono'>{demo.latency}</span>
<span className='tracking-wider uppercase'>ms</span>
</span>
<span className='size-1 rounded-full bg-foreground/15' />
<span className='flex items-center gap-1'>
<span className='font-mono'>{demo.tokens}</span>
<span className='tracking-wider uppercase'>tokens</span>
</span>
<span className='size-1 rounded-full bg-foreground/15' />
<span className='flex items-center gap-1'>
<span className='tracking-wider uppercase'>cost</span>
<span className='font-mono'>
${(demo.tokens * 0.00003).toFixed(5)}
</span>
</span>
</div>
<span className='font-mono text-[10px] tracking-wider text-foreground/30 uppercase'>
stream · sse
</span>
</div>
</div>
</div>
)
}
function RequestPreview(props: {
demo: ApiDemoConfig
transitioning: boolean
}) {
function RequestBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) {
const { demo, transitioning } = props
return (
<div className='space-y-0.5 text-foreground/80'>
<CodeLine>
<Command>curl</Command> <Flag>-X POST</Flag>{' '}
<AnimatedString transitioning={transitioning}>
&quot;{demo.endpoint}&quot;
</AnimatedString>{' '}
<Muted>{'\\'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Flag>-H</Flag>{' '}
<StringText>&quot;Authorization: Bearer sk-&quot;</StringText>{' '}
<Muted>{'\\'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Flag>-d</Flag> <StringText>&apos;{'{'}</StringText>
</CodeLine>
{demo.requestBodyLines.map((line) => (
<CodeLine key={line} indent={4}>
<AnimatedString transitioning={transitioning}>
{line}
</AnimatedString>
<div className='relative px-5 py-4'>
<SectionLabel>Request</SectionLabel>
<div
className={cn(
'mt-2 transition-opacity duration-200',
transitioning ? 'opacity-0' : 'opacity-100'
)}
>
<CodeLine>
<Command>curl</Command> <Flag>-X</Flag> <Flag>POST</Flag>{' '}
<StringText>&quot;{demo.endpoint}&quot;</StringText>{' '}
<Muted>{'\\'}</Muted>
</CodeLine>
))}
<CodeLine indent={2}>
<StringText>{'}'}&apos;</StringText>
</CodeLine>
{demo.headers.map((header) => (
<CodeLine key={header} indent={2}>
<Flag>-H</Flag> <StringText>{header}</StringText>{' '}
<Muted>{'\\'}</Muted>
</CodeLine>
))}
<CodeLine indent={2}>
<Flag>-d</Flag> <StringText>&apos;{'{'}</StringText>
</CodeLine>
{demo.request.map((line, i) => (
<CodeLine key={i} indent={4}>
{renderJsonLine(line)}
</CodeLine>
))}
<CodeLine indent={2}>
<StringText>{'}'}&apos;</StringText>
</CodeLine>
</div>
</div>
)
}
function ResponsePreview(props: {
demo: ApiDemoConfig
transitioning: boolean
}) {
function ResponseBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) {
const { demo, transitioning } = props
return (
<div
className={cn(
'rounded-lg border px-3.5 py-3',
'border-border/40 bg-muted/30',
'dark:border-white/[0.06] dark:bg-white/[0.02]'
'relative border-t px-5 py-4',
'border-border/40 bg-muted/20 dark:border-white/[0.05] dark:bg-white/[0.015]'
)}
>
{demo.responseKind === 'chat' && (
<>
<CodeLine>
<Muted>{'{'}</Muted>
<SectionLabel>Response</SectionLabel>
<div
className={cn(
'mt-2 transition-opacity duration-200',
transitioning ? 'opacity-0' : 'opacity-100'
)}
>
{demo.response.map((line, i) => (
<CodeLine key={i}>
{renderResponseLine(line, demo)}
</CodeLine>
<CodeLine indent={2}>
<Key>&quot;choices&quot;</Key>
<Muted>: [</Muted>
</CodeLine>
<CodeLine indent={4}>
<Muted>{'{'} </Muted>
<Key>&quot;message&quot;</Key>
<Muted>: {'{'} </Muted>
<Key>&quot;content&quot;</Key>
<Muted>: </Muted>
<ResponseText demo={demo} transitioning={transitioning} />
<Muted> {'}'} {'}'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Muted>],</Muted>
</CodeLine>
<UsageLine
container='usage'
name='total_tokens'
value={demo.tokens}
indent={2}
/>
<CodeLine>
<Muted>{'}'}</Muted>
</CodeLine>
</>
)}
{demo.responseKind === 'responses' && (
<>
<CodeLine>
<Muted>{'{'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Key>&quot;output&quot;</Key>
<Muted>: [</Muted>
</CodeLine>
<CodeLine indent={4}>
<Muted>{'{'}</Muted>
</CodeLine>
<CodeLine indent={6}>
<Key>&quot;type&quot;</Key>
<Muted>: </Muted>
<StringText>&quot;message&quot;</StringText>
<Muted>,</Muted>
</CodeLine>
<CodeLine indent={6}>
<Key>&quot;content&quot;</Key>
<Muted>: [</Muted>
</CodeLine>
<CodeLine indent={8}>
<Muted>{'{'} </Muted>
<Key>&quot;type&quot;</Key>
<Muted>: </Muted>
<StringText>&quot;output_text&quot;</StringText>
<Muted>, </Muted>
<Key>&quot;text&quot;</Key>
<Muted>: </Muted>
<ResponseText demo={demo} transitioning={transitioning} />
<Muted> {'}'}</Muted>
</CodeLine>
<CodeLine indent={6}>
<Muted>]</Muted>
</CodeLine>
<CodeLine indent={4}>
<Muted>{'}'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Muted>],</Muted>
</CodeLine>
<UsageLine
container='usage'
name='total_tokens'
value={demo.tokens}
indent={2}
/>
<CodeLine>
<Muted>{'}'}</Muted>
</CodeLine>
</>
)}
{demo.responseKind === 'claude' && (
<>
<CodeLine>
<Muted>{'{'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Key>&quot;content&quot;</Key>
<Muted>: [</Muted>
</CodeLine>
<CodeLine indent={4}>
<Muted>{'{'} </Muted>
<Key>&quot;type&quot;</Key>
<Muted>: </Muted>
<StringText>&quot;text&quot;</StringText>
<Muted>, </Muted>
<Key>&quot;text&quot;</Key>
<Muted>: </Muted>
<ResponseText demo={demo} transitioning={transitioning} />
<Muted> {'}'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Muted>],</Muted>
</CodeLine>
<CodeLine indent={2}>
<Key>&quot;usage&quot;</Key>
<Muted>: {'{'} </Muted>
<Key>&quot;input_tokens&quot;</Key>
<Muted>: </Muted>
<NumberText>{Math.floor(demo.tokens * 0.4)}</NumberText>
<Muted>, </Muted>
<Key>&quot;output_tokens&quot;</Key>
<Muted>: </Muted>
<NumberText>{Math.ceil(demo.tokens * 0.6)}</NumberText>
<Muted> {'}'}</Muted>
</CodeLine>
<CodeLine>
<Muted>{'}'}</Muted>
</CodeLine>
</>
)}
{demo.responseKind === 'gemini' && (
<>
<CodeLine>
<Muted>{'{'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Key>&quot;candidates&quot;</Key>
<Muted>: [</Muted>
</CodeLine>
<CodeLine indent={4}>
<Muted>{'{'}</Muted>
</CodeLine>
<CodeLine indent={6}>
<Key>&quot;content&quot;</Key>
<Muted>: {'{'}</Muted>
</CodeLine>
<CodeLine indent={8}>
<Key>&quot;parts&quot;</Key>
<Muted>: [</Muted>
</CodeLine>
<CodeLine indent={10}>
<Muted>{'{'} </Muted>
<Key>&quot;text&quot;</Key>
<Muted>: </Muted>
<ResponseText demo={demo} transitioning={transitioning} />
<Muted> {'}'}</Muted>
</CodeLine>
<CodeLine indent={8}>
<Muted>]</Muted>
</CodeLine>
<CodeLine indent={6}>
<Muted>{'}'}</Muted>
</CodeLine>
<CodeLine indent={4}>
<Muted>{'}'}</Muted>
</CodeLine>
<CodeLine indent={2}>
<Muted>],</Muted>
</CodeLine>
<UsageLine
container='usageMetadata'
name='totalTokenCount'
value={demo.tokens}
indent={2}
/>
<CodeLine>
<Muted>{'}'}</Muted>
</CodeLine>
</>
)}
))}
</div>
</div>
)
}
function UsageLine(props: {
container: string
name: string
value: number
indent: number
}) {
function SectionLabel(props: { children: ReactNode }) {
return (
<CodeLine indent={props.indent}>
<Key>&quot;{props.container}&quot;</Key>
<Muted>: {'{'} </Muted>
<Key>&quot;{props.name}&quot;</Key>
<Muted>: </Muted>
<NumberText>{props.value}</NumberText>
<Muted> {'}'}</Muted>
</CodeLine>
<span className='font-sans text-[10px] font-semibold tracking-[0.18em] text-foreground/30 uppercase'>
{props.children}
</span>
)
}
function ResponseText(props: {
demo: ApiDemoConfig
transitioning: boolean
}) {
return (
<span
className={cn(
'text-emerald-600 transition-all duration-300 dark:text-emerald-400',
props.transitioning ? 'opacity-0' : 'opacity-100'
)}
>
&quot;{props.demo.response}&quot;
</span>
)
const STRING_RE = /"[^"]*"/g
const PLACEHOLDER_RE = /<[a-z]+>/gi
function renderJsonLine(line: string): ReactNode {
if (!line.trim()) return <Muted> </Muted>
return tokenize(line)
}
function renderResponseLine(line: string, demo: ApiDemoConfig): ReactNode {
if (!line.trim()) return <Muted> </Muted>
const segments: ReactNode[] = []
let cursor = 0
const matches = [...line.matchAll(PLACEHOLDER_RE)]
if (matches.length === 0) return tokenize(line)
matches.forEach((match, idx) => {
const start = match.index ?? 0
if (start > cursor) {
segments.push(
<span key={`pre-${idx}`}>{tokenize(line.slice(cursor, start))}</span>
)
}
const placeholder = match[0]
if (placeholder === '<text>') {
segments.push(
<Accent key={`ph-${idx}`} accent={demo.accent}>
{`"${truncateResponse(demo)}"`}
</Accent>
)
} else if (placeholder === '<tokens>') {
segments.push(
<NumberText key={`ph-${idx}`}>{demo.tokens}</NumberText>
)
} else if (placeholder === '<in>') {
segments.push(
<NumberText key={`ph-${idx}`}>
{Math.floor(demo.tokens * 0.4)}
</NumberText>
)
} else if (placeholder === '<out>') {
segments.push(
<NumberText key={`ph-${idx}`}>
{Math.ceil(demo.tokens * 0.6)}
</NumberText>
)
} else {
segments.push(<Muted key={`ph-${idx}`}>{placeholder}</Muted>)
}
cursor = start + placeholder.length
})
if (cursor < line.length) {
segments.push(<span key='tail'>{tokenize(line.slice(cursor))}</span>)
}
return segments
}
function truncateResponse(demo: ApiDemoConfig): string {
const map: Record<string, string> = {
'gpt-chat': 'Chat request routed.',
responses: 'Response workflow ready.',
claude: 'Claude message routed.',
gemini: 'Gemini request served.',
}
return map[demo.id] ?? '...'
}
function tokenize(input: string): ReactNode {
// Split string into "..." string runs and the rest, then color keys/punct.
const segments: ReactNode[] = []
let cursor = 0
const matches = [...input.matchAll(STRING_RE)]
matches.forEach((match, idx) => {
const start = match.index ?? 0
if (start > cursor) {
segments.push(
<Muted key={`m-${idx}`}>{input.slice(cursor, start)}</Muted>
)
}
const text = match[0]
const after = input.slice(start + text.length).trimStart()
const isKey = after.startsWith(':')
if (isKey) {
segments.push(<Key key={`k-${idx}`}>{text}</Key>)
} else {
segments.push(<StringText key={`s-${idx}`}>{text}</StringText>)
}
cursor = start + text.length
})
if (cursor < input.length) {
segments.push(<Muted key='tail'>{input.slice(cursor)}</Muted>)
}
return segments
}
function CodeLine(props: { children: ReactNode; indent?: number }) {
@ -479,27 +485,9 @@ function CodeLine(props: { children: ReactNode; indent?: number }) {
)
}
function AnimatedString(props: {
children: ReactNode
transitioning: boolean
}) {
return (
<span
className={cn(
'transition-all duration-300',
props.transitioning
? 'text-foreground/20'
: 'text-amber-700 dark:text-amber-300'
)}
>
{props.children}
</span>
)
}
function Command(props: { children: ReactNode }) {
return (
<span className='text-emerald-600 dark:text-emerald-400'>
<span className='font-medium text-emerald-600 dark:text-emerald-400'>
{props.children}
</span>
)
@ -513,49 +501,29 @@ function Flag(props: { children: ReactNode }) {
function Key(props: { children: ReactNode }) {
return (
<span className='text-blue-600 dark:text-blue-400'>{props.children}</span>
<span className='text-sky-700 dark:text-sky-300'>{props.children}</span>
)
}
function StringText(props: { children: ReactNode }) {
return (
<span className='text-amber-600 dark:text-amber-400'>{props.children}</span>
<span className='text-amber-700 dark:text-amber-300'>{props.children}</span>
)
}
function NumberText(props: { children: ReactNode }) {
return (
<span className='text-violet-600 dark:text-violet-400'>
<span className='font-medium text-violet-600 dark:text-violet-300'>
{props.children}
</span>
)
}
function Muted(props: { children: ReactNode }) {
return <span className='text-foreground/35'>{props.children}</span>
return <span className='text-foreground/55'>{props.children}</span>
}
function ModelSelector(props: {
demos: ApiDemoConfig[]
activeIndex: number
onSelect: (index: number) => void
}) {
return (
<div className='flex items-center gap-1'>
{props.demos.map((demo, i) => (
<button
key={demo.id}
onClick={() => props.onSelect(i)}
className={cn(
'rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 transition-all duration-300 ring-inset',
i === props.activeIndex
? demo.badgeClass
: 'text-foreground/20 ring-border/30 hover:text-foreground/40 hover:ring-border/50 dark:ring-white/[0.06] dark:hover:ring-white/10'
)}
>
{demo.label}
</button>
))}
</div>
)
function Accent(props: { children: ReactNode; accent: AccentTone }) {
const tone = ACCENT_CLASSES[props.accent]
return <span className={cn('font-medium', tone.activeText)}>{props.children}</span>
}

View File

@ -42,13 +42,7 @@ export function HowItWorks() {
</h2>
</AnimateInView>
<div className='relative grid gap-8 md:grid-cols-3 md:gap-12'>
{/* Connecting line (desktop) */}
<div
aria-hidden
className='from-border/0 via-border to-border/0 absolute top-12 right-[20%] left-[20%] hidden h-px bg-gradient-to-r md:block'
/>
<div className='grid gap-8 md:grid-cols-3 md:gap-12'>
{steps.map((step, i) => (
<AnimateInView
key={step.num}

View File

@ -47,6 +47,31 @@ const DEFAULT_SIDEBAR_MODULES: SidebarModulesAdminConfig = {
},
}
const mergeWithDefaultSidebarModules = (
config: SidebarModulesAdminConfig
): SidebarModulesAdminConfig => {
const merged: SidebarModulesAdminConfig = { ...config }
Object.entries(DEFAULT_SIDEBAR_MODULES).forEach(
([sectionKey, defaultSection]) => {
const existingSection = merged[sectionKey]
if (!existingSection) {
merged[sectionKey] = { ...defaultSection }
return
}
merged[sectionKey] = { ...defaultSection, ...existingSection }
Object.keys(defaultSection).forEach((moduleKey) => {
if (merged[sectionKey][moduleKey] === undefined) {
merged[sectionKey][moduleKey] = defaultSection[moduleKey]
}
})
}
)
return merged
}
/**
* Mapping from URL to configuration keys
*/
@ -87,15 +112,7 @@ function parseSidebarConfig(
try {
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
// Ensure chat section and its modules are correctly initialized if missing
if (!parsed.chat) {
parsed.chat = { enabled: true, playground: true, chat: true }
} else {
if (parsed.chat.enabled === undefined) parsed.chat.enabled = true
if (parsed.chat.playground === undefined) parsed.chat.playground = true
if (parsed.chat.chat === undefined) parsed.chat.chat = true
}
return parsed
return mergeWithDefaultSidebarModules(parsed)
} catch {
// eslint-disable-next-line no-console
console.error('Failed to parse sidebar modules configuration')

View File

@ -1593,6 +1593,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "Related Projects",
"footer.defaultCopyright": "All rights reserved.",
"footer.new\u0061pi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment",
"For private deployments, format: https://fastgpt.run/api/openapi": "For private deployments, format: https://fastgpt.run/api/openapi",
"Force AUTH LOGIN": "Force AUTH LOGIN",

View File

@ -1593,6 +1593,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "Projets liés",
"footer.defaultCopyright": "Tous droits réservés.",
"footer.new\u0061pi.projectAttributionSuffix": "Tous droits réservés. Conçu et développé par les contributeurs du projet.",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Pour les canaux ajoutés après le 10 mai 2025, pas besoin de supprimer \".\" des noms de modèles lors du déploiement",
"For private deployments, format: https://fastgpt.run/api/openapi": "Pour les déploiements privés, format : https://fastgpt.run/api/openapi",
"Force AUTH LOGIN": "Forcer AUTH LOGIN",

View File

@ -1593,6 +1593,7 @@
"footer.columns.related.links.oneApi": "1つのAPI",
"footer.columns.related.title": "関連プロジェクト",
"footer.defaultCopyright": "すべての権利を留保します。",
"footer.new\u0061pi.projectAttributionSuffix": "すべての権利を留保します。プロジェクトコントリビューターにより設計・開発されています。",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "2025 年 5 月 10 日以降に追加されたチャネルの場合、デプロイ時にモデル名から「.」を削除する必要はありません",
"For private deployments, format: https://fastgpt.run/api/openapi": "プライベートデプロイメントの場合、形式: https://fastgpt.run/api/openapi",
"Force AUTH LOGIN": "AUTH LOGINを強制",

View File

@ -1593,6 +1593,7 @@
"footer.columns.related.links.oneApi": "Один API",
"footer.columns.related.title": "Связанные проекты",
"footer.defaultCopyright": "Все права защищены.",
"footer.new\u0061pi.projectAttributionSuffix": "Все права защищены. Разработано участниками проекта.",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Для каналов, добавленных после 10 мая 2025 г., не нужно удалять \".\" из имён моделей при развёртывании",
"For private deployments, format: https://fastgpt.run/api/openapi": "Для частных развертываний, формат: https://fastgpt.run/api/openapi",
"Force AUTH LOGIN": "Принудительный AUTH LOGIN",

View File

@ -1593,6 +1593,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "Các Dự Án Liên Quan",
"footer.defaultCopyright": "Bản quyền được bảo lưu.",
"footer.new\u0061pi.projectAttributionSuffix": "Bản quyền được bảo lưu. Được thiết kế và phát triển bởi các cộng tác viên dự án.",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "Đối với các kênh được thêm sau ngày 10 tháng 5 năm 2025, không cần loại bỏ \".\" khỏi tên mô hình trong quá trình triển khai",
"For private deployments, format: https://fastgpt.run/api/openapi": "Đối với các triển khai riêng tư, định dạng: https://fastgpt.run/api/openapi",
"Force AUTH LOGIN": "Bắt buộc AUTH LOGIN",

View File

@ -1593,6 +1593,7 @@
"footer.columns.related.links.oneApi": "One API",
"footer.columns.related.title": "相关项目",
"footer.defaultCopyright": "版权所有。",
"footer.new\u0061pi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。",
"For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "对于 2025 年 5 月 10 日之后添加的渠道,在部署时无需从模型名称中移除 \".\"",
"For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署格式为https://fastgpt.run/api/openapi",
"Force AUTH LOGIN": "强制 AUTH LOGIN",