fix(ui): polish landing page and navigation
This commit is contained in:
parent
d2b30dfc95
commit
aa730395f1
12
web/default/scripts/sync-i18n.mjs
vendored
12
web/default/scripts/sync-i18n.mjs
vendored
@ -4,13 +4,23 @@ import path from 'node:path'
|
|||||||
// This script is executed from the web/ package root (see package.json script).
|
// This script is executed from the web/ package root (see package.json script).
|
||||||
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||||
const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only
|
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) {
|
function isPlainObject(v) {
|
||||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableStringify(obj) {
|
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) {
|
function countLeafKeys(obj) {
|
||||||
|
|||||||
@ -22,6 +22,12 @@ interface FooterProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NEW_API_FOOTER_ATTRIBUTION_KEY = [
|
||||||
|
'footer',
|
||||||
|
'new' + 'api',
|
||||||
|
'projectAttributionSuffix',
|
||||||
|
].join('.')
|
||||||
|
|
||||||
function FooterLinkItem(props: { link: FooterLink }) {
|
function FooterLinkItem(props: { link: FooterLink }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isExternal = props.link.href.startsWith('http')
|
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'>
|
||||||
|
© {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) {
|
export function Footer(props: FooterProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
@ -125,10 +152,19 @@ export function Footer(props: FooterProps) {
|
|||||||
|
|
||||||
if (footerHtml) {
|
if (footerHtml) {
|
||||||
return (
|
return (
|
||||||
<div
|
<footer className={cn('border-border/40 relative z-10 border-t', props.className)}>
|
||||||
className='custom-footer w-full'
|
<div className='mx-auto w-full max-w-6xl px-6 py-5'>
|
||||||
dangerouslySetInnerHTML={{ __html: footerHtml }}
|
<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) {
|
|||||||
© {currentYear} {displayName}.{' '}
|
© {currentYear} {displayName}.{' '}
|
||||||
{props.copyright ?? t('footer.defaultCopyright')}
|
{props.copyright ?? t('footer.defaultCopyright')}
|
||||||
</p>
|
</p>
|
||||||
<div className='flex items-center gap-2'>
|
<ProjectAttribution currentYear={currentYear} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -1,90 +1,157 @@
|
|||||||
import { useState, useEffect, useRef, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, type ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type AccentTone = 'emerald' | 'amber' | 'blue' | 'violet'
|
||||||
|
|
||||||
interface ApiDemoConfig {
|
interface ApiDemoConfig {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
method: 'POST' | 'GET'
|
||||||
endpoint: string
|
endpoint: string
|
||||||
requestBodyLines: string[]
|
headers: string[]
|
||||||
responseKind: 'chat' | 'responses' | 'claude' | 'gemini'
|
request: string[]
|
||||||
response: string
|
response: string[]
|
||||||
|
responseHighlights: string[]
|
||||||
tokens: number
|
tokens: number
|
||||||
latency: 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[] = [
|
const API_DEMOS: ApiDemoConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'gpt-chat',
|
id: 'gpt-chat',
|
||||||
label: 'GPT Chat',
|
label: 'Chat',
|
||||||
|
method: 'POST',
|
||||||
endpoint: '/v1/chat/completions',
|
endpoint: '/v1/chat/completions',
|
||||||
requestBodyLines: [
|
headers: ['"Authorization: Bearer sk-••••"'],
|
||||||
|
request: [
|
||||||
'"model": "your-model",',
|
'"model": "your-model",',
|
||||||
'"messages": [',
|
'"messages": [',
|
||||||
' { "role": "user", "content": "..." }',
|
' { "role": "user", "content": "..." }',
|
||||||
']',
|
']',
|
||||||
],
|
],
|
||||||
responseKind: 'chat',
|
response: [
|
||||||
response: 'Route chat requests through configured upstreams.',
|
'{',
|
||||||
|
' "choices": [{ "message": { "content": <text> } }],',
|
||||||
|
' "usage": { "total_tokens": <tokens> }',
|
||||||
|
'}',
|
||||||
|
],
|
||||||
|
responseHighlights: ['<text>', '<tokens>'],
|
||||||
tokens: 27,
|
tokens: 27,
|
||||||
latency: 142,
|
latency: 142,
|
||||||
badgeClass:
|
accent: 'emerald',
|
||||||
'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',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'responses',
|
id: 'responses',
|
||||||
label: 'Responses',
|
label: 'Responses',
|
||||||
|
method: 'POST',
|
||||||
endpoint: '/v1/responses',
|
endpoint: '/v1/responses',
|
||||||
requestBodyLines: ['"model": "your-model",', '"input": "..."'],
|
headers: ['"Authorization: Bearer sk-••••"'],
|
||||||
responseKind: 'responses',
|
request: [
|
||||||
response: 'Run response workflows behind one gateway.',
|
'"model": "your-model",',
|
||||||
|
'"input": "..."',
|
||||||
|
],
|
||||||
|
response: [
|
||||||
|
'{',
|
||||||
|
' "output": [{ "type": "output_text", "text": <text> }],',
|
||||||
|
' "usage": { "total_tokens": <tokens> }',
|
||||||
|
'}',
|
||||||
|
],
|
||||||
|
responseHighlights: ['<text>', '<tokens>'],
|
||||||
tokens: 31,
|
tokens: 31,
|
||||||
latency: 168,
|
latency: 168,
|
||||||
badgeClass:
|
accent: 'amber',
|
||||||
'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',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'claude',
|
id: 'claude',
|
||||||
label: 'Claude',
|
label: 'Claude',
|
||||||
|
method: 'POST',
|
||||||
endpoint: '/v1/messages',
|
endpoint: '/v1/messages',
|
||||||
requestBodyLines: [
|
headers: ['"x-api-key: sk-••••"', '"anthropic-version: 2023-06-01"'],
|
||||||
|
request: [
|
||||||
'"model": "your-model",',
|
'"model": "your-model",',
|
||||||
'"max_tokens": 1024,',
|
'"max_tokens": 1024,',
|
||||||
'"messages": [',
|
'"messages": [',
|
||||||
' { "role": "user", "content": "..." }',
|
' { "role": "user", "content": "..." }',
|
||||||
']',
|
']',
|
||||||
],
|
],
|
||||||
responseKind: 'claude',
|
response: [
|
||||||
response: 'Send Claude-style messages through your gateway.',
|
'{',
|
||||||
|
' "content": [{ "type": "text", "text": <text> }],',
|
||||||
|
' "usage": { "input_tokens": <in>, "output_tokens": <out> }',
|
||||||
|
'}',
|
||||||
|
],
|
||||||
|
responseHighlights: ['<text>', '<in>', '<out>'],
|
||||||
tokens: 29,
|
tokens: 29,
|
||||||
latency: 156,
|
latency: 156,
|
||||||
badgeClass:
|
accent: 'blue',
|
||||||
'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',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini',
|
id: 'gemini',
|
||||||
label: 'Gemini',
|
label: 'Gemini',
|
||||||
|
method: 'POST',
|
||||||
endpoint: '/v1beta/models/{model}:generateContent',
|
endpoint: '/v1beta/models/{model}:generateContent',
|
||||||
requestBodyLines: [
|
headers: ['"x-goog-api-key: sk-••••"'],
|
||||||
|
request: [
|
||||||
'"contents": [',
|
'"contents": [',
|
||||||
' { "parts": [{ "text": "..." }] }',
|
' { "role": "user",',
|
||||||
|
' "parts": [{ "text": "..." }] }',
|
||||||
']',
|
']',
|
||||||
],
|
],
|
||||||
responseKind: 'gemini',
|
response: [
|
||||||
response: 'Serve Gemini-compatible generation requests.',
|
'{',
|
||||||
|
' "candidates": [{ "content": { "parts": [{ "text": <text> }] } }],',
|
||||||
|
' "usageMetadata": { "totalTokenCount": <tokens> }',
|
||||||
|
'}',
|
||||||
|
],
|
||||||
|
responseHighlights: ['<text>', '<tokens>'],
|
||||||
tokens: 25,
|
tokens: 25,
|
||||||
latency: 93,
|
latency: 93,
|
||||||
badgeClass:
|
accent: 'violet',
|
||||||
'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',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const CYCLE_INTERVAL = 4000
|
const CYCLE_INTERVAL = 4500
|
||||||
|
const TRANSITION_MS = 220
|
||||||
|
|
||||||
export function HeroTerminalDemo() {
|
export function HeroTerminalDemo() {
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const [transitioning, setTransitioning] = useState(false)
|
const [transitioning, setTransitioning] = useState(false)
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
|
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
@ -92,376 +159,315 @@ export function HeroTerminalDemo() {
|
|||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
setTransitioning(true)
|
setTransitioning(true)
|
||||||
setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
setActiveIndex((prev) => (prev + 1) % API_DEMOS.length)
|
setActiveIndex((prev) => (prev + 1) % API_DEMOS.length)
|
||||||
setTransitioning(false)
|
setTransitioning(false)
|
||||||
}, 300)
|
}, TRANSITION_MS)
|
||||||
}, CYCLE_INTERVAL)
|
}, 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 demo = API_DEMOS[activeIndex]
|
||||||
|
const accent = ACCENT_CLASSES[demo.accent]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mx-auto mt-16 w-full max-w-2xl'>
|
<div className='mx-auto mt-16 w-full max-w-2xl'>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'overflow-hidden rounded-xl border',
|
'overflow-hidden rounded-2xl border backdrop-blur-sm',
|
||||||
'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)]',
|
'border-border/60 bg-white/95 shadow-[0_20px_50px_-25px_rgba(15,23,42,0.18)]',
|
||||||
'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)]'
|
'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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between border-b px-4 py-2.5',
|
'flex items-center gap-1 border-b px-2 sm:gap-1.5 sm:px-3',
|
||||||
'border-border/40 bg-gray-50/80',
|
'border-border/50 dark:border-white/[0.05]'
|
||||||
'dark:border-white/[0.06] dark:bg-transparent'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-1.5'>
|
{API_DEMOS.map((item, index) => {
|
||||||
<div className='size-2.5 rounded-full bg-[#ff5f57]/80 dark:bg-[#ff5f57]' />
|
const tone = ACCENT_CLASSES[item.accent]
|
||||||
<div className='size-2.5 rounded-full bg-[#febc2e]/80 dark:bg-[#febc2e]' />
|
const isActive = index === activeIndex
|
||||||
<div className='size-2.5 rounded-full bg-[#28c840]/80 dark:bg-[#28c840]' />
|
return (
|
||||||
</div>
|
<button
|
||||||
<div className='flex items-center gap-2'>
|
key={item.id}
|
||||||
<ModelSelector
|
onClick={() => handleSelect(index)}
|
||||||
demos={API_DEMOS}
|
className={cn(
|
||||||
activeIndex={activeIndex}
|
'-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',
|
||||||
onSelect={(i) => {
|
isActive
|
||||||
clearInterval(intervalRef.current)
|
? `${tone.activeBorder} ${tone.activeText}`
|
||||||
setTransitioning(true)
|
: 'border-transparent text-foreground/40 hover:text-foreground/70'
|
||||||
setTimeout(() => {
|
)}
|
||||||
setActiveIndex(i)
|
>
|
||||||
setTransitioning(false)
|
{item.label}
|
||||||
}, 300)
|
</button>
|
||||||
}}
|
)
|
||||||
/>
|
})}
|
||||||
</div>
|
<div className='ml-auto flex items-center gap-2 pr-2 sm:pr-3'>
|
||||||
<div className='flex items-center gap-2'>
|
<span className='inline-block size-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.45)]' />
|
||||||
<span className='inline-block size-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400' />
|
<span className='font-mono text-[10px] tracking-wider text-foreground/40 uppercase'>
|
||||||
<span className='text-foreground/30 text-[10px]'>200 OK</span>
|
200 ok
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal body — fixed height */}
|
{/* Endpoint row */}
|
||||||
<div className='grid min-h-[280px] grid-rows-[auto_1fr] font-mono text-[12.5px] leading-[1.7]'>
|
<div
|
||||||
{/* Request */}
|
className={cn(
|
||||||
<div
|
'flex items-center gap-2.5 border-b px-5 py-3',
|
||||||
|
'border-border/40 dark:border-white/[0.04]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b px-5 py-3.5',
|
'rounded-md px-1.5 py-0.5 font-mono text-[10px] font-semibold tracking-wider',
|
||||||
'border-border/30',
|
accent.badge
|
||||||
'dark:border-white/[0.04]'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='mb-1.5 flex items-center gap-2'>
|
{demo.method}
|
||||||
<span className='text-[10px] font-medium tracking-wider text-blue-500/60 uppercase dark:text-blue-400/60'>
|
</span>
|
||||||
Request
|
<code
|
||||||
</span>
|
className={cn(
|
||||||
</div>
|
'truncate font-mono text-[12.5px] text-foreground/75 transition-opacity duration-200',
|
||||||
<RequestPreview demo={demo} transitioning={transitioning} />
|
transitioning ? 'opacity-0' : 'opacity-100'
|
||||||
</div>
|
)}
|
||||||
|
>
|
||||||
|
{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 */}
|
{/* Response */}
|
||||||
<div className='px-5 py-3.5'>
|
<ResponseBlock demo={demo} transitioning={transitioning} />
|
||||||
<div className='mb-2 flex items-center justify-between'>
|
</div>
|
||||||
<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'>
|
{/* Footer metrics */}
|
||||||
Response
|
<div
|
||||||
</span>
|
className={cn(
|
||||||
<span
|
'flex items-center justify-between border-t px-5 py-2.5',
|
||||||
className={cn(
|
'border-border/40 bg-muted/30 dark:border-white/[0.05] dark:bg-white/[0.02]'
|
||||||
'text-foreground/25 text-[10px] tabular-nums transition-opacity duration-300',
|
)}
|
||||||
transitioning ? 'opacity-0' : 'opacity-100'
|
>
|
||||||
)}
|
<div className='flex items-center gap-3 text-[10px] tabular-nums text-foreground/40'>
|
||||||
>
|
<span className='flex items-center gap-1'>
|
||||||
{demo.latency}ms
|
<span className='font-mono'>{demo.latency}</span>
|
||||||
</span>
|
<span className='tracking-wider uppercase'>ms</span>
|
||||||
</div>
|
</span>
|
||||||
<div
|
<span className='size-1 rounded-full bg-foreground/15' />
|
||||||
className={cn(
|
<span className='flex items-center gap-1'>
|
||||||
'text-foreground/25 flex items-center gap-3 text-[10px] tabular-nums transition-opacity duration-300',
|
<span className='font-mono'>{demo.tokens}</span>
|
||||||
transitioning ? 'opacity-0' : 'opacity-100'
|
<span className='tracking-wider uppercase'>tokens</span>
|
||||||
)}
|
</span>
|
||||||
>
|
<span className='size-1 rounded-full bg-foreground/15' />
|
||||||
<span>{demo.tokens} tokens</span>
|
<span className='flex items-center gap-1'>
|
||||||
<span>${(demo.tokens * 0.00003).toFixed(5)}</span>
|
<span className='tracking-wider uppercase'>cost</span>
|
||||||
</div>
|
<span className='font-mono'>
|
||||||
</div>
|
${(demo.tokens * 0.00003).toFixed(5)}
|
||||||
<ResponsePreview demo={demo} transitioning={transitioning} />
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className='font-mono text-[10px] tracking-wider text-foreground/30 uppercase'>
|
||||||
|
stream · sse
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequestPreview(props: {
|
function RequestBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) {
|
||||||
demo: ApiDemoConfig
|
|
||||||
transitioning: boolean
|
|
||||||
}) {
|
|
||||||
const { demo, transitioning } = props
|
const { demo, transitioning } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-0.5 text-foreground/80'>
|
<div className='relative px-5 py-4'>
|
||||||
<CodeLine>
|
<SectionLabel>Request</SectionLabel>
|
||||||
<Command>curl</Command> <Flag>-X POST</Flag>{' '}
|
<div
|
||||||
<AnimatedString transitioning={transitioning}>
|
className={cn(
|
||||||
"{demo.endpoint}"
|
'mt-2 transition-opacity duration-200',
|
||||||
</AnimatedString>{' '}
|
transitioning ? 'opacity-0' : 'opacity-100'
|
||||||
<Muted>{'\\'}</Muted>
|
)}
|
||||||
</CodeLine>
|
>
|
||||||
<CodeLine indent={2}>
|
<CodeLine>
|
||||||
<Flag>-H</Flag>{' '}
|
<Command>curl</Command> <Flag>-X</Flag> <Flag>POST</Flag>{' '}
|
||||||
<StringText>"Authorization: Bearer sk-••••"</StringText>{' '}
|
<StringText>"{demo.endpoint}"</StringText>{' '}
|
||||||
<Muted>{'\\'}</Muted>
|
<Muted>{'\\'}</Muted>
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={2}>
|
|
||||||
<Flag>-d</Flag> <StringText>'{'{'}</StringText>
|
|
||||||
</CodeLine>
|
|
||||||
{demo.requestBodyLines.map((line) => (
|
|
||||||
<CodeLine key={line} indent={4}>
|
|
||||||
<AnimatedString transitioning={transitioning}>
|
|
||||||
{line}
|
|
||||||
</AnimatedString>
|
|
||||||
</CodeLine>
|
</CodeLine>
|
||||||
))}
|
{demo.headers.map((header) => (
|
||||||
<CodeLine indent={2}>
|
<CodeLine key={header} indent={2}>
|
||||||
<StringText>{'}'}'</StringText>
|
<Flag>-H</Flag> <StringText>{header}</StringText>{' '}
|
||||||
</CodeLine>
|
<Muted>{'\\'}</Muted>
|
||||||
|
</CodeLine>
|
||||||
|
))}
|
||||||
|
<CodeLine indent={2}>
|
||||||
|
<Flag>-d</Flag> <StringText>'{'{'}</StringText>
|
||||||
|
</CodeLine>
|
||||||
|
{demo.request.map((line, i) => (
|
||||||
|
<CodeLine key={i} indent={4}>
|
||||||
|
{renderJsonLine(line)}
|
||||||
|
</CodeLine>
|
||||||
|
))}
|
||||||
|
<CodeLine indent={2}>
|
||||||
|
<StringText>{'}'}'</StringText>
|
||||||
|
</CodeLine>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponsePreview(props: {
|
function ResponseBlock(props: { demo: ApiDemoConfig; transitioning: boolean }) {
|
||||||
demo: ApiDemoConfig
|
|
||||||
transitioning: boolean
|
|
||||||
}) {
|
|
||||||
const { demo, transitioning } = props
|
const { demo, transitioning } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border px-3.5 py-3',
|
'relative border-t px-5 py-4',
|
||||||
'border-border/40 bg-muted/30',
|
'border-border/40 bg-muted/20 dark:border-white/[0.05] dark:bg-white/[0.015]'
|
||||||
'dark:border-white/[0.06] dark:bg-white/[0.02]'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{demo.responseKind === 'chat' && (
|
<SectionLabel>Response</SectionLabel>
|
||||||
<>
|
<div
|
||||||
<CodeLine>
|
className={cn(
|
||||||
<Muted>{'{'}</Muted>
|
'mt-2 transition-opacity duration-200',
|
||||||
|
transitioning ? 'opacity-0' : 'opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{demo.response.map((line, i) => (
|
||||||
|
<CodeLine key={i}>
|
||||||
|
{renderResponseLine(line, demo)}
|
||||||
</CodeLine>
|
</CodeLine>
|
||||||
<CodeLine indent={2}>
|
))}
|
||||||
<Key>"choices"</Key>
|
</div>
|
||||||
<Muted>: [</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={4}>
|
|
||||||
<Muted>{'{'} </Muted>
|
|
||||||
<Key>"message"</Key>
|
|
||||||
<Muted>: {'{'} </Muted>
|
|
||||||
<Key>"content"</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>"output"</Key>
|
|
||||||
<Muted>: [</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={4}>
|
|
||||||
<Muted>{'{'}</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={6}>
|
|
||||||
<Key>"type"</Key>
|
|
||||||
<Muted>: </Muted>
|
|
||||||
<StringText>"message"</StringText>
|
|
||||||
<Muted>,</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={6}>
|
|
||||||
<Key>"content"</Key>
|
|
||||||
<Muted>: [</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={8}>
|
|
||||||
<Muted>{'{'} </Muted>
|
|
||||||
<Key>"type"</Key>
|
|
||||||
<Muted>: </Muted>
|
|
||||||
<StringText>"output_text"</StringText>
|
|
||||||
<Muted>, </Muted>
|
|
||||||
<Key>"text"</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>"content"</Key>
|
|
||||||
<Muted>: [</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={4}>
|
|
||||||
<Muted>{'{'} </Muted>
|
|
||||||
<Key>"type"</Key>
|
|
||||||
<Muted>: </Muted>
|
|
||||||
<StringText>"text"</StringText>
|
|
||||||
<Muted>, </Muted>
|
|
||||||
<Key>"text"</Key>
|
|
||||||
<Muted>: </Muted>
|
|
||||||
<ResponseText demo={demo} transitioning={transitioning} />
|
|
||||||
<Muted> {'}'}</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={2}>
|
|
||||||
<Muted>],</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={2}>
|
|
||||||
<Key>"usage"</Key>
|
|
||||||
<Muted>: {'{'} </Muted>
|
|
||||||
<Key>"input_tokens"</Key>
|
|
||||||
<Muted>: </Muted>
|
|
||||||
<NumberText>{Math.floor(demo.tokens * 0.4)}</NumberText>
|
|
||||||
<Muted>, </Muted>
|
|
||||||
<Key>"output_tokens"</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>"candidates"</Key>
|
|
||||||
<Muted>: [</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={4}>
|
|
||||||
<Muted>{'{'}</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={6}>
|
|
||||||
<Key>"content"</Key>
|
|
||||||
<Muted>: {'{'}</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={8}>
|
|
||||||
<Key>"parts"</Key>
|
|
||||||
<Muted>: [</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
<CodeLine indent={10}>
|
|
||||||
<Muted>{'{'} </Muted>
|
|
||||||
<Key>"text"</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: {
|
function SectionLabel(props: { children: ReactNode }) {
|
||||||
container: string
|
|
||||||
name: string
|
|
||||||
value: number
|
|
||||||
indent: number
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<CodeLine indent={props.indent}>
|
<span className='font-sans text-[10px] font-semibold tracking-[0.18em] text-foreground/30 uppercase'>
|
||||||
<Key>"{props.container}"</Key>
|
{props.children}
|
||||||
<Muted>: {'{'} </Muted>
|
</span>
|
||||||
<Key>"{props.name}"</Key>
|
|
||||||
<Muted>: </Muted>
|
|
||||||
<NumberText>{props.value}</NumberText>
|
|
||||||
<Muted> {'}'}</Muted>
|
|
||||||
</CodeLine>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponseText(props: {
|
const STRING_RE = /"[^"]*"/g
|
||||||
demo: ApiDemoConfig
|
const PLACEHOLDER_RE = /<[a-z]+>/gi
|
||||||
transitioning: boolean
|
|
||||||
}) {
|
function renderJsonLine(line: string): ReactNode {
|
||||||
return (
|
if (!line.trim()) return <Muted> </Muted>
|
||||||
<span
|
return tokenize(line)
|
||||||
className={cn(
|
}
|
||||||
'text-emerald-600 transition-all duration-300 dark:text-emerald-400',
|
|
||||||
props.transitioning ? 'opacity-0' : 'opacity-100'
|
function renderResponseLine(line: string, demo: ApiDemoConfig): ReactNode {
|
||||||
)}
|
if (!line.trim()) return <Muted> </Muted>
|
||||||
>
|
|
||||||
"{props.demo.response}"
|
const segments: ReactNode[] = []
|
||||||
</span>
|
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 }) {
|
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 }) {
|
function Command(props: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className='text-emerald-600 dark:text-emerald-400'>
|
<span className='font-medium text-emerald-600 dark:text-emerald-400'>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@ -513,49 +501,29 @@ function Flag(props: { children: ReactNode }) {
|
|||||||
|
|
||||||
function Key(props: { children: ReactNode }) {
|
function Key(props: { children: ReactNode }) {
|
||||||
return (
|
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 }) {
|
function StringText(props: { children: ReactNode }) {
|
||||||
return (
|
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 }) {
|
function NumberText(props: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className='text-violet-600 dark:text-violet-400'>
|
<span className='font-medium text-violet-600 dark:text-violet-300'>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Muted(props: { children: ReactNode }) {
|
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: {
|
function Accent(props: { children: ReactNode; accent: AccentTone }) {
|
||||||
demos: ApiDemoConfig[]
|
const tone = ACCENT_CLASSES[props.accent]
|
||||||
activeIndex: number
|
return <span className={cn('font-medium', tone.activeText)}>{props.children}</span>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,13 +42,7 @@ export function HowItWorks() {
|
|||||||
</h2>
|
</h2>
|
||||||
</AnimateInView>
|
</AnimateInView>
|
||||||
|
|
||||||
<div className='relative grid gap-8 md:grid-cols-3 md:gap-12'>
|
<div className='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'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{steps.map((step, i) => (
|
{steps.map((step, i) => (
|
||||||
<AnimateInView
|
<AnimateInView
|
||||||
key={step.num}
|
key={step.num}
|
||||||
|
|||||||
35
web/default/src/hooks/use-sidebar-config.ts
vendored
35
web/default/src/hooks/use-sidebar-config.ts
vendored
@ -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
|
* Mapping from URL to configuration keys
|
||||||
*/
|
*/
|
||||||
@ -87,15 +112,7 @@ function parseSidebarConfig(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
|
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
|
||||||
// Ensure chat section and its modules are correctly initialized if missing
|
return mergeWithDefaultSidebarModules(parsed)
|
||||||
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
|
|
||||||
} catch {
|
} catch {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('Failed to parse sidebar modules configuration')
|
console.error('Failed to parse sidebar modules configuration')
|
||||||
|
|||||||
1
web/default/src/i18n/locales/en.json
vendored
1
web/default/src/i18n/locales/en.json
vendored
@ -1593,6 +1593,7 @@
|
|||||||
"footer.columns.related.links.oneApi": "One API",
|
"footer.columns.related.links.oneApi": "One API",
|
||||||
"footer.columns.related.title": "Related Projects",
|
"footer.columns.related.title": "Related Projects",
|
||||||
"footer.defaultCopyright": "All rights reserved.",
|
"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 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",
|
"For private deployments, format: https://fastgpt.run/api/openapi": "For private deployments, format: https://fastgpt.run/api/openapi",
|
||||||
"Force AUTH LOGIN": "Force AUTH LOGIN",
|
"Force AUTH LOGIN": "Force AUTH LOGIN",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/fr.json
vendored
1
web/default/src/i18n/locales/fr.json
vendored
@ -1593,6 +1593,7 @@
|
|||||||
"footer.columns.related.links.oneApi": "One API",
|
"footer.columns.related.links.oneApi": "One API",
|
||||||
"footer.columns.related.title": "Projets liés",
|
"footer.columns.related.title": "Projets liés",
|
||||||
"footer.defaultCopyright": "Tous droits réservé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 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",
|
"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",
|
"Force AUTH LOGIN": "Forcer AUTH LOGIN",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/ja.json
vendored
1
web/default/src/i18n/locales/ja.json
vendored
@ -1593,6 +1593,7 @@
|
|||||||
"footer.columns.related.links.oneApi": "1つのAPI",
|
"footer.columns.related.links.oneApi": "1つのAPI",
|
||||||
"footer.columns.related.title": "関連プロジェクト",
|
"footer.columns.related.title": "関連プロジェクト",
|
||||||
"footer.defaultCopyright": "すべての権利を留保します。",
|
"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 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",
|
"For private deployments, format: https://fastgpt.run/api/openapi": "プライベートデプロイメントの場合、形式: https://fastgpt.run/api/openapi",
|
||||||
"Force AUTH LOGIN": "AUTH LOGINを強制",
|
"Force AUTH LOGIN": "AUTH LOGINを強制",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/ru.json
vendored
1
web/default/src/i18n/locales/ru.json
vendored
@ -1593,6 +1593,7 @@
|
|||||||
"footer.columns.related.links.oneApi": "Один API",
|
"footer.columns.related.links.oneApi": "Один API",
|
||||||
"footer.columns.related.title": "Связанные проекты",
|
"footer.columns.related.title": "Связанные проекты",
|
||||||
"footer.defaultCopyright": "Все права защищены.",
|
"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 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",
|
"For private deployments, format: https://fastgpt.run/api/openapi": "Для частных развертываний, формат: https://fastgpt.run/api/openapi",
|
||||||
"Force AUTH LOGIN": "Принудительный AUTH LOGIN",
|
"Force AUTH LOGIN": "Принудительный AUTH LOGIN",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/vi.json
vendored
1
web/default/src/i18n/locales/vi.json
vendored
@ -1593,6 +1593,7 @@
|
|||||||
"footer.columns.related.links.oneApi": "One API",
|
"footer.columns.related.links.oneApi": "One API",
|
||||||
"footer.columns.related.title": "Các Dự Án Liên Quan",
|
"footer.columns.related.title": "Các Dự Án Liên Quan",
|
||||||
"footer.defaultCopyright": "Bản quyền được bảo lưu.",
|
"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 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",
|
"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",
|
"Force AUTH LOGIN": "Bắt buộc AUTH LOGIN",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/zh.json
vendored
1
web/default/src/i18n/locales/zh.json
vendored
@ -1593,6 +1593,7 @@
|
|||||||
"footer.columns.related.links.oneApi": "One API",
|
"footer.columns.related.links.oneApi": "One API",
|
||||||
"footer.columns.related.title": "相关项目",
|
"footer.columns.related.title": "相关项目",
|
||||||
"footer.defaultCopyright": "版权所有。",
|
"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 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",
|
"For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署,格式为:https://fastgpt.run/api/openapi",
|
||||||
"Force AUTH LOGIN": "强制 AUTH LOGIN",
|
"Force AUTH LOGIN": "强制 AUTH LOGIN",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user