feat: multi-feature update

This commit is contained in:
chaos
2026-06-15 06:16:16 +08:00
parent 6f415428d3
commit 04d30f9dd1
58 changed files with 4610 additions and 419 deletions
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>New API</title>
<meta name="title" content="New API" />
<title>BBLBB</title>
<meta name="title" content="BBLBB" />
<meta
name="description"
content="Unified AI API gateway and admin dashboard."
+93 -51
View File
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
@@ -150,10 +151,101 @@ export function ComboboxInput({
item?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex])
const [dropdownPos, setDropdownPos] = React.useState<{
top: number
left: number
width: number
} | null>(null)
const updateDropdownPos = React.useCallback(() => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
setDropdownPos({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}, [])
// Update dropdown position when open
React.useEffect(() => {
if (!open) {
setDropdownPos(null)
return
}
updateDropdownPos()
const handleScroll = () => updateDropdownPos()
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleScroll)
}
}, [open, updateDropdownPos])
const showDropdown =
open &&
(filteredOptions.length > 0 || (allowCustomValue && searchValue.trim()))
const dropdownContent = showDropdown && dropdownPos ? (
<div
className='bg-popover text-popover-foreground fixed z-[100] rounded-md border shadow-md'
style={{
top: dropdownPos.top,
left: dropdownPos.left,
width: dropdownPos.width,
}}
>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && <span>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{t(emptyText)}
{allowCustomValue && searchValue.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', {
value: searchValue.trim(),
})}
</div>
)}
</div>
)}
</div>
) : null
return (
<div ref={containerRef} className='relative'>
<Input
@@ -184,57 +276,7 @@ export function ComboboxInput({
/>
<ChevronsUpDown className='pointer-events-none absolute top-1/2 right-3 size-4 shrink-0 -translate-y-1/2 opacity-50' />
{showDropdown && (
<div className='bg-popover text-popover-foreground absolute top-full z-100 mt-1 w-full rounded-md border shadow-md'>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && <span>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{t(emptyText)}
{allowCustomValue && searchValue.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', {
value: searchValue.trim(),
})}
</div>
)}
</div>
)}
</div>
)}
{dropdownContent && createPortal(dropdownContent, document.body)}
</div>
)
}
+35
View File
@@ -31,6 +31,31 @@ import {
} from '../lib/oauth'
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
/**
* Generate a random code verifier for PKCE
*/
function generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* Generate code challenge from code verifier using SHA-256
*/
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
type LogoutRequestConfig = AxiosRequestConfig & {
skipErrorHandler?: boolean
}
@@ -211,6 +236,16 @@ export function useOAuthLogin(status: SystemStatus | null) {
url.searchParams.set('scope', provider.scopes)
}
// Add PKCE support if enabled
if (provider.pkce_enabled) {
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
// Store code_verifier in sessionStorage keyed by state
sessionStorage.setItem(`pkce_verifier_${state}`, codeVerifier)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
}
window.open(url.toString(), '_self')
} catch (_error) {
toast.error(
+1
View File
@@ -195,6 +195,7 @@ export interface CustomOAuthProviderInfo {
client_id: string
authorization_endpoint: string
scopes: string
pkce_enabled: boolean
}
// ============================================================================
+9 -1
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type { HomePageContentResponse } from './types'
import type { HomePageContentResponse, HomeStatsResponse } from './types'
// ============================================================================
// Home Page APIs
@@ -31,3 +31,11 @@ export async function getHomePageContent(): Promise<HomePageContentResponse> {
const res = await api.get('/api/home_page_content')
return res.data
}
export async function getHomeStats(): Promise<HomeStatsResponse> {
const res = await api.get('/api/home_stats', {
skipBusinessError: true,
skipErrorHandler: true,
})
return res.data
}
+150 -202
View File
@@ -16,226 +16,174 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { CherryStudio } from '@lobehub/icons'
import { ArrowRight, BookOpen } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import { HeroTerminalDemo } from '../hero-terminal-demo'
import { cn } from '@/lib/utils'
import { getHomeStats } from '../../api'
import type { HomeStats } from '../../types'
import { HeroButtons } from '../hero-buttons'
interface HeroProps {
className?: string
isAuthenticated?: boolean
}
// Stylized three-dots indicator representing "More"
const MoreIcon = () => (
<svg
className='text-muted-foreground/60 group-hover:text-foreground size-6 shrink-0 transition-colors'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<circle cx='6' cy='12' r='2' fill='currentColor' />
<circle cx='12' cy='12' r='2' fill='currentColor' />
<circle cx='18' cy='12' r='2' fill='currentColor' />
</svg>
)
interface UsageMetricProps {
label: string
value: string
percent?: number
}
function clampPercent(value: number | undefined): number {
if (typeof value !== 'number' || Number.isNaN(value)) return 0
return Math.max(0, Math.min(100, value))
}
function formatPercent(value: number | undefined): string {
return `${clampPercent(value).toFixed(2)}%`
}
function formatBytes(value: number | undefined): string {
if (!value) return '--'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 10 ? 0 : 1)}${units[unitIndex]}`
}
function formatCompactNumber(value: number | undefined): string {
if (typeof value !== 'number') return '--'
return new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 2,
}).format(value)
}
function getUsageColor(percent: number): string {
if (percent >= 85) return 'bg-red-500'
if (percent >= 65) return 'bg-orange-400'
return 'bg-green-500'
}
function UsageMetric(props: UsageMetricProps) {
const percent = clampPercent(props.percent)
return (
<div className='flex flex-col whitespace-nowrap sm:w-20 lg:w-16'>
<p className='text-muted-foreground text-xs'>{props.label}</p>
<div className='flex items-center gap-1 text-xs font-semibold'>{props.value}</div>
{typeof props.percent === 'number' ? (
<div
aria-label={`${props.label} Server Usage Bar`}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={percent}
className='bg-secondary relative mt-1 h-[3px] w-full overflow-hidden rounded-sm'
role='progressbar'
>
<div
className={cn('h-full w-full flex-1 transition-all', getUsageColor(percent))}
style={{ transform: `translateX(-${100 - percent}%)` }}
/>
</div>
) : null}
</div>
)
}
function ServerCard(props: {
name: string
region: string
stats?: HomeStats
tokenValue: string
memoryValue: string
}) {
const cpuUsage = clampPercent(props.stats?.cpu_usage)
const memoryUsage = clampPercent(props.stats?.memory_usage)
return (
<div
className='bg-card text-card-foreground flex cursor-pointer flex-col items-center justify-start gap-3 rounded-lg p-3 shadow-md shadow-stone-200/50 ring ring-stone-200 transition-all hover:shadow-sm hover:ring-stone-300 md:px-5 dark:shadow-none dark:ring-stone-800 dark:hover:ring-stone-700 lg:flex-row'
>
<section className='grid items-center gap-2 lg:w-44 [grid-template-columns:auto_auto_1fr]'>
<span className='h-2 w-2 shrink-0 self-center rounded-full bg-green-500' />
<div className='flex min-w-[24px] items-center justify-center rounded-sm bg-muted px-1 text-[10px] font-semibold text-muted-foreground'>
{props.region}
</div>
<div className='relative flex flex-col'>
<p className='break-normal text-xs font-bold tracking-tight'>{props.name}</p>
<p className='hidden text-[11px] text-muted-foreground lg:block'>Online</p>
</div>
</section>
<div className='-mt-2 flex items-center gap-2 lg:hidden' />
<div className='flex flex-col items-center gap-2 lg:items-start'>
<section className='flex flex-wrap items-center gap-x-4 gap-y-2 sm:gap-x-5'>
<UsageMetric label='CPU' value={formatPercent(cpuUsage)} percent={cpuUsage} />
<UsageMetric label='MEM' value={formatPercent(memoryUsage)} percent={memoryUsage} />
<UsageMetric label='RAM' value={props.memoryValue} />
<UsageMetric label='Tokens' value={props.tokenValue} />
<UsageMetric label='Mode' value='Proxy' />
</section>
</div>
</div>
)
}
export function Hero(props: HeroProps) {
const { t } = useTranslation()
const { status } = useStatus()
const docsUrl =
(status?.docs_link as string | undefined) || 'https://docs.newapi.pro'
const renderDocsButton = () => {
const isExternal = docsUrl.startsWith('http')
if (isExternal) {
return (
<Button
variant='outline'
className='group border-border/50 hover:border-border hover:bg-muted/50 inline-flex h-11 items-center gap-1.5 rounded-lg px-5 text-sm font-medium'
render={
<a href={docsUrl} target='_blank' rel='noopener noreferrer' />
}
>
<BookOpen className='text-muted-foreground/80 group-hover:text-foreground size-4 transition-colors duration-200' />
<span>{t('Docs')}</span>
</Button>
)
}
return (
<Button
variant='outline'
className='group border-border/50 hover:border-border hover:bg-muted/50 inline-flex h-11 items-center gap-1.5 rounded-lg px-5 text-sm font-medium'
render={<Link to={docsUrl} />}
>
<BookOpen className='text-muted-foreground/80 group-hover:text-foreground size-4 transition-colors duration-200' />
<span>{t('Docs')}</span>
</Button>
)
}
const statsQuery = useQuery({
queryKey: ['home-stats'],
queryFn: async () => {
const response = await getHomeStats()
return response.data
},
refetchInterval: 3 * 1000,
staleTime: 2 * 1000,
})
const memoryValue = `${formatBytes(statsQuery.data?.memory_used)} / ${formatBytes(statsQuery.data?.memory_total)}`
const tokenValue = formatCompactNumber(statsQuery.data?.total_tokens)
return (
<section className='relative z-10 overflow-hidden px-6 pt-24 pb-16 md:pt-32 md:pb-24 lg:pt-36 lg:pb-28'>
{/* Radial gradient background */}
<div
aria-hidden
className='pointer-events-none absolute inset-0 -z-10 opacity-25 dark:opacity-[0.12]'
style={{
background: [
'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)',
'radial-gradient(ellipse 40% 35% at 40% 80%, oklch(0.70 0.12 280 / 40%) 0%, transparent 70%)',
].join(', '),
}}
/>
{/* Grid pattern */}
<div
aria-hidden
className='absolute inset-0 -z-10 bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_30%,black_20%,transparent_100%)] bg-[size:4rem_4rem] opacity-[0.08]'
/>
<div className='mx-auto grid max-w-6xl grid-cols-1 items-start gap-12 lg:grid-cols-12 lg:gap-8'>
{/* Left Column: Title, description, action buttons and application support */}
<div className='flex flex-col items-start text-left lg:col-span-6'>
{/* Top Pill Badge */}
<div
className='landing-animate-fade-up mb-5 inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-[11px] font-medium text-blue-600 opacity-0 shadow-xs dark:border-blue-400/20 dark:bg-blue-400/5 dark:text-blue-400'
style={{ animationDelay: '0ms' }}
>
<span className='relative flex size-1.5'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75' />
<span className='relative inline-flex size-1.5 rounded-full bg-blue-500 dark:bg-blue-400' />
</span>
<span>{t('AI Application Infrastructure Foundation')}</span>
<section
className={cn(
'relative min-h-screen overflow-hidden bg-stone-50 text-foreground dark:bg-stone-950',
props.className
)}
>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(120,113,108,0.16),transparent_34%)] dark:bg-[radial-gradient(circle_at_top,rgba(120,113,108,0.12),transparent_34%)]' />
<div className='container relative mx-auto flex min-h-screen flex-col items-center justify-center px-6 py-24'>
<div className='mx-auto max-w-3xl text-center'>
<div className='mx-auto mb-5 inline-flex items-center gap-2 rounded-full bg-card px-3 py-1 text-xs text-muted-foreground shadow-sm ring ring-stone-200 dark:ring-stone-800'>
<span className='h-2 w-2 rounded-full bg-green-500' />
{statsQuery.isLoading ? t('Syncing') : t('Server Status')}
</div>
<h1
className='landing-animate-fade-up text-[clamp(2.25rem,4.5vw,3.25rem)] leading-[1.15] font-bold tracking-tight'
style={{ animationDelay: '60ms' }}
>
{t('Unified API Gateway for')}
<br />
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
{t('Vast Range of AI Models')}
</span>
<h1 className='text-5xl font-black tracking-tight text-stone-950 md:text-7xl dark:text-stone-50'>
Universe Federation
</h1>
<p
className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-xl text-base leading-relaxed opacity-0 md:text-[15px]'
style={{ animationDelay: '120ms' }}
>
{t(
'Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.'
)}
<p className='mt-4 text-xl font-semibold text-stone-700 md:text-2xl dark:text-stone-300'>
{t('伟大无需多言')}
</p>
<div
className='landing-animate-fade-up mt-8 flex flex-wrap items-center gap-3 opacity-0'
style={{ animationDelay: '180ms' }}
>
{props.isAuthenticated ? (
<>
<Button
className='group h-11 rounded-lg px-5 text-sm font-medium'
render={<Link to='/dashboard' />}
>
{t('Go to Dashboard')}
<ArrowRight className='ml-1.5 size-4 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
{renderDocsButton()}
</>
) : (
<>
<Button
className='group h-11 rounded-lg px-5 text-sm font-medium'
render={<Link to='/sign-up' />}
>
{t('Get Started')}
<ArrowRight className='ml-1.5 size-4 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
<Button
variant='outline'
className='border-border/50 hover:border-border hover:bg-muted/50 h-11 rounded-lg px-5 text-sm font-medium'
render={<Link to='/pricing' />}
>
{t('View Pricing')}
</Button>
{renderDocsButton()}
</>
)}
</div>
{/* Supported Apps (参考图二样式,进行卡片化和信息扩充设计,增加视觉高度) */}
<div
className='landing-animate-fade-up mt-10 w-full max-w-xl opacity-0'
style={{ animationDelay: '240ms' }}
>
<div className='mb-4 flex flex-col gap-1'>
<span className='text-muted-foreground/50 text-[10px] font-bold tracking-[0.15em] uppercase'>
{t('Supported Applications')}
</span>
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
{t(
'Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.'
)}
</p>
</div>
<div className='flex flex-wrap items-center gap-3'>
{/* Cherry Studio */}
<a
href='https://cherry-ai.com'
target='_blank'
rel='noopener noreferrer'
className='group border-border/40 bg-muted/15 text-foreground/80 hover:border-border hover:bg-muted/30 hover:text-foreground flex items-center gap-3 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]'
>
<CherryStudio.Color size={24} className='shrink-0' />
<span>Cherry Studio</span>
</a>
{/* CC Switch */}
<a
href='https://ccswitch.io'
target='_blank'
rel='noopener noreferrer'
className='group border-border/40 bg-muted/15 text-foreground/80 hover:border-border hover:bg-muted/30 hover:text-foreground flex items-center gap-3 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]'
>
<img
src='https://ccswitch.io/favicon.png'
alt='CC Switch'
className='size-6 shrink-0 rounded-md object-contain'
onError={(e) => {
// Fallback to a styled text avatar if the remote favicon fails to load in sandbox or local environments
e.currentTarget.style.display = 'none'
const fallback = e.currentTarget.nextSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
<span
style={{ display: 'none' }}
className='size-6 shrink-0 items-center justify-center rounded-md bg-blue-500/10 text-[10px] font-bold text-blue-600 dark:bg-blue-400/10 dark:text-blue-400'
>
CC
</span>
<span>CC Switch</span>
</a>
{/* "更多" */}
<div className='group border-border/40 bg-muted/15 text-foreground/55 hover:border-border hover:bg-muted/30 hover:text-foreground flex cursor-default items-center gap-2.5 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]'>
<MoreIcon />
<span>{t('More Apps')}</span>
</div>
</div>
<div className='mt-7 flex flex-col justify-center gap-3 sm:flex-row'>
<HeroButtons isAuthenticated={!!props.isAuthenticated} />
</div>
</div>
{/* Right Column: Hero Terminal API Demo */}
<div
className='landing-animate-fade-up flex w-full justify-center opacity-0 lg:col-span-6'
style={{ animationDelay: '320ms' }}
>
<HeroTerminalDemo className='mt-8 lg:mt-0' />
<div className='mt-12 grid w-full max-w-4xl gap-3'>
<ServerCard
name='BBLBB'
region='CN'
stats={statsQuery.data}
tokenValue={tokenValue}
memoryValue={memoryValue}
/>
</div>
</div>
</section>
+1 -7
View File
@@ -20,8 +20,7 @@ import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { Markdown } from '@/components/ui/markdown'
import { PublicLayout } from '@/components/layout'
import { Footer } from '@/components/layout/components/footer'
import { CTA, Features, Hero, HowItWorks, Stats } from './components'
import { Hero } from './components'
import { useHomePageContent } from './hooks'
export function Home() {
@@ -63,11 +62,6 @@ export function Home() {
return (
<PublicLayout showMainContainer={false}>
<Hero isAuthenticated={isAuthenticated} />
<Stats />
<Features />
<HowItWorks />
<CTA isAuthenticated={isAuthenticated} />
<Footer />
</PublicLayout>
)
}
+14
View File
@@ -37,3 +37,17 @@ export interface HomePageContentResult {
isLoaded: boolean
isUrl: boolean
}
export interface HomeStats {
cpu_usage: number
memory_usage: number
memory_total: number
memory_used: number
total_tokens: number
}
export interface HomeStatsResponse {
success: boolean
message?: string
data?: HomeStats
}
@@ -96,6 +96,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
email_field: '',
well_known: '',
auth_style: 0,
pkce_enabled: false,
access_policy: '',
access_denied_message: '',
},
@@ -120,6 +121,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
email_field: props.provider.email_field || '',
well_known: props.provider.well_known || '',
auth_style: props.provider.auth_style ?? 0,
pkce_enabled: props.provider.pkce_enabled ?? false,
access_policy: props.provider.access_policy || '',
access_denied_message: props.provider.access_denied_message || '',
})
@@ -141,6 +143,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
email_field: '',
well_known: '',
auth_style: 0,
pkce_enabled: false,
access_policy: '',
access_denied_message: '',
})
@@ -373,6 +376,27 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name='pkce_enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable PKCE')}</FormLabel>
<FormDescription>
{t('Use PKCE (Proof Key for Code Exchange) for enhanced security. Required for some providers like Mastodon/Akkoma.')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</div>
<Separator />
@@ -40,6 +40,7 @@ export interface CustomOAuthProvider {
email_field: string
well_known: string
auth_style: number // 0=auto, 1=params, 2=header
pkce_enabled: boolean
access_policy: string
access_denied_message: string
}
@@ -73,6 +74,7 @@ export const customOAuthFormSchema = z.object({
email_field: z.string().optional().default(''),
well_known: z.string().optional().default(''),
auth_style: z.number().int().min(0).max(2).default(0),
pkce_enabled: z.boolean().default(false),
access_policy: z.string().optional().default(''),
access_denied_message: z.string().optional().default(''),
})
+20 -3
View File
@@ -93,6 +93,7 @@
"30 Days": "30 Days",
"30 days ago": "30 days ago",
"30d change": "30d change",
"30s refresh": "30s refresh",
"5 minutes": "5 minutes",
"5-Hour Window": "5-Hour Window",
"50 / page": "50 / page",
@@ -101,6 +102,7 @@
"80,443,8080": "80,443,8080",
"A billing multiplier. Lower ratios mean lower API call costs.": "A billing multiplier. Lower ratios mean lower API call costs.",
"A focused home for keys, balance, routing, and service health.": "A focused home for keys, balance, routing, and service health.",
"A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.": "A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.",
"About": "About",
"About {{days}} days left": "About {{days}} days left",
"Accept Unpriced Models": "Accept Unpriced Models",
@@ -462,6 +464,7 @@
"Availability (last 24h)": "Availability (last 24h)",
"Available": "Available",
"Available disk space": "Available disk space",
"Available headroom": "Available headroom",
"Available Models": "Available Models",
"Available Rewards": "Available Rewards",
"Average latency": "Average latency",
@@ -815,6 +818,7 @@
"Compliance confirmation required": "Compliance confirmation required",
"Compliance confirmed": "Compliance confirmed",
"Compliance confirmed successfully": "Compliance confirmed successfully",
"Compute usage": "Compute usage",
"Concatenate channel system prompt with user&apos;s prompt": "Concatenate channel system prompt with user&apos;s prompt",
"Condition Path": "Condition Path",
"Condition Settings": "Condition Settings",
@@ -1348,6 +1352,7 @@
"edit_this": "edit_this",
"Editor mode": "Editor mode",
"Education": "Education",
"Elastic compute headroom": "Elastic compute headroom",
"Email": "Email",
"Email (required for verification)": "Email (required for verification)",
"Email Address": "Email Address",
@@ -1528,8 +1533,8 @@
"Exists": "Exists",
"Expand": "Expand",
"Expand All": "Expand All",
"Expected a JSON array.": "Expected a JSON array.",
"Expected a JSON array of group identifiers": "Expected a JSON array of group identifiers",
"Expected a JSON array.": "Expected a JSON array.",
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
"Expiration Time": "Expiration Time",
"expired": "expired",
@@ -1816,6 +1821,7 @@
"Full width": "Full width",
"Function calling": "Function calling",
"Functions": "Functions",
"Gateway Load": "Gateway Load",
"GC Count": "GC Count",
"GC executed": "GC executed",
"GC execution failed": "GC execution failed",
@@ -1925,6 +1931,7 @@
"header. Anthropic-formatted endpoints accept the": "header. Anthropic-formatted endpoints accept the",
"Health": "Health",
"Healthy": "Healthy",
"HHHL AI Gateway": "Universe Federation",
"Hidden — verify to reveal": "Hidden — verify to reveal",
"Hide": "Hide",
"Hide API key": "Hide API key",
@@ -2198,6 +2205,9 @@
"List of models supported by this channel. Use comma to separate multiple models.": "List of models supported by this channel. Use comma to separate multiple models.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "List of origins (one per line) allowed for Passkey registration and authentication.",
"List view": "List view",
"Live capacity telemetry": "Live capacity telemetry",
"Live resource telemetry": "Live resource telemetry",
"Live Status": "Live Status",
"LLM Leaderboard": "LLM Leaderboard",
"LLM prompt helper": "LLM prompt helper",
"Load Balancing": "Load Balancing",
@@ -2295,6 +2305,7 @@
"Media pricing": "Media pricing",
"Median time-to-first-token (TTFT) sampled hourly per group": "Median time-to-first-token (TTFT) sampled hourly per group",
"Medical Q&A, mental health support": "Medical Q&A, mental health support",
"Memory Capacity": "Memory Capacity",
"Memory Hits": "Memory Hits",
"Memory Threshold (%)": "Memory Threshold (%)",
"Merchant ID": "Merchant ID",
@@ -2646,6 +2657,7 @@
"No Users Found": "No Users Found",
"No vendor data available": "No vendor data available",
"No X Found": "No X Found",
"Node": "Node",
"Node Name": "Node Name",
"Non-stream": "Non-stream",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.",
@@ -3193,6 +3205,7 @@
"Reasoning Effort": "Reasoning Effort",
"Receive Upstream Model Update Notifications": "Receive Upstream Model Update Notifications",
"Received": "Received",
"Received amount": "Received amount",
"Recently launched models": "Recently launched models",
"Recently launched models gaining traction": "Recently launched models gaining traction",
"Recharge": "Recharge",
@@ -3209,7 +3222,6 @@
"Redeem codes": "Redeem codes",
"Redeemed By": "Redeemed By",
"Redeemed:": "Redeemed:",
"Received amount": "Received amount",
"redemption code": "redemption code",
"Redemption Code": "Redemption Code",
"Redemption code deleted successfully": "Redemption code deleted successfully",
@@ -3611,6 +3623,8 @@
"Server IP": "Server IP",
"Server Log Management": "Server Log Management",
"Server logging is not enabled (log directory not configured)": "Server logging is not enabled (log directory not configured)",
"Server Power Core": "Server Power Core",
"Server Status": "Server Status",
"Server Token": "Server Token",
"Service account JSON file(s)": "Service account JSON file(s)",
"Session expired!": "Session expired!",
@@ -3828,6 +3842,7 @@
"Sync Upstream": "Sync Upstream",
"Sync Upstream Models": "Sync Upstream Models",
"Synchronize models and vendors from an upstream source": "Synchronize models and vendors from an upstream source",
"Syncing": "Syncing",
"Syncing prices, please wait...": "Syncing prices, please wait...",
"Syncing...": "Syncing...",
"System": "System",
@@ -4108,6 +4123,7 @@
"Total requests made": "Total requests made",
"Total tokens": "Total tokens",
"Total Tokens": "Total Tokens",
"Total Tokens Burned": "Total Tokens Burned",
"Total Usage": "Total Usage",
"Total:": "Total:",
"TPM": "TPM",
@@ -4538,6 +4554,7 @@
"Zero retention": "Zero retention",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"伟大无需多言": "Greatness Needs No Words"
}
}
+22 -5
View File
@@ -93,6 +93,7 @@
"30 Days": "30 jours",
"30 days ago": "Il y a 30 jours",
"30d change": "Variation 30 j",
"30s refresh": "Actualisation 30 s",
"5 minutes": "5 minutes",
"5-Hour Window": "Fenêtre de 5 heures",
"50 / page": "50 / page",
@@ -101,6 +102,7 @@
"80,443,8080": "80,443,8080",
"A billing multiplier. Lower ratios mean lower API call costs.": "Un multiplicateur de facturation. Plus le ratio est faible, plus le coût des appels API est bas.",
"A focused home for keys, balance, routing, and service health.": "Un accueil dédié aux clés, au solde, au routage et à l'état du service.",
"A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.": "Une passerelle API IA à haut débit avec capacité en temps réel, routage résilient et consommation de tokens transparente dès le premier regard.",
"About": "À propos",
"About {{days}} days left": "Environ {{days}} jours restants",
"Accept Unpriced Models": "Accepter les modèles non tarifés",
@@ -462,6 +464,7 @@
"Availability (last 24h)": "Disponibilité (dernières 24 h)",
"Available": "Disponible",
"Available disk space": "Espace disque disponible",
"Available headroom": "Marge disponible",
"Available Models": "Modèles disponibles",
"Available Rewards": "Récompenses disponibles",
"Average latency": "Latence moyenne",
@@ -815,6 +818,7 @@
"Compliance confirmation required": "Confirmation de conformité requise",
"Compliance confirmed": "Conformité confirmée",
"Compliance confirmed successfully": "Conformité confirmée avec succès",
"Compute usage": "Utilisation du calcul",
"Concatenate channel system prompt with user&apos;s prompt": "Concaténer l'invite système du canal avec l'invite de l'utilisateur",
"Condition Path": "Chemin de condition",
"Condition Settings": "Paramètres de condition",
@@ -1348,6 +1352,7 @@
"edit_this": "modifier_ceci",
"Editor mode": "Mode éditeur",
"Education": "Éducation",
"Elastic compute headroom": "Marge de calcul élastique",
"Email": "E-mail",
"Email (required for verification)": "E-mail (requis pour la vérification)",
"Email Address": "Adresse e-mail",
@@ -1528,8 +1533,8 @@
"Exists": "Existe",
"Expand": "Développer",
"Expand All": "Tout développer",
"Expected a JSON array.": "Un tableau JSON est attendu.",
"Expected a JSON array of group identifiers": "Un tableau JSON d'identifiants de groupe est attendu",
"Expected a JSON array.": "Un tableau JSON est attendu.",
"Experiment with prompts and models in real time.": "Expérimentez avec des prompts et des modèles en temps réel.",
"Expiration Time": "Heure d'expiration",
"expired": "expiré",
@@ -1816,6 +1821,7 @@
"Full width": "Pleine largeur",
"Function calling": "Appel de fonction",
"Functions": "Fonctions",
"Gateway Load": "Charge de la passerelle",
"GC Count": "Nombre de GC",
"GC executed": "GC exécuté",
"GC execution failed": "Échec de l'exécution du GC",
@@ -1924,7 +1930,8 @@
"Header Value (supports string or JSON mapping)": "Valeur de l'en-tête (chaîne ou mappage JSON)",
"header. Anthropic-formatted endpoints accept the": ". Les points de terminaison au format Anthropic acceptent à la place",
"Health": "Santé",
"Healthy": "Normal",
"Healthy": "Sain",
"HHHL AI Gateway": "Fédération de l'Univers",
"Hidden — verify to reveal": "Masqué — vérifiez pour révéler",
"Hide": "Masquer",
"Hide API key": "Masquer la clé API",
@@ -2198,6 +2205,9 @@
"List of models supported by this channel. Use comma to separate multiple models.": "Liste des modèles pris en charge par ce canal. Utilisez une virgule pour séparer plusieurs modèles.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Liste des origines (une par ligne) autorisées pour l'enregistrement et l'authentification des clés d'accès (Passkey).",
"List view": "Vue en liste",
"Live capacity telemetry": "Télémétrie de capacité en direct",
"Live resource telemetry": "Télémétrie des ressources en direct",
"Live Status": "État en direct",
"LLM Leaderboard": "Classement des LLM",
"LLM prompt helper": "Assistant prompt LLM",
"Load Balancing": "Équilibrage de charge",
@@ -2295,6 +2305,7 @@
"Media pricing": "Tarification multimédia",
"Median time-to-first-token (TTFT) sampled hourly per group": "Latence médiane jusqu'au premier jeton (TTFT) échantillonnée par heure et par groupe",
"Medical Q&A, mental health support": "Q&R médicales, soutien en santé mentale",
"Memory Capacity": "Capacité mémoire",
"Memory Hits": "Hits mémoire",
"Memory Threshold (%)": "Seuil mémoire (%)",
"Merchant ID": "ID du commerçant",
@@ -2646,6 +2657,7 @@
"No Users Found": "Aucun utilisateur trouvé",
"No vendor data available": "Aucune donnée de fournisseur disponible",
"No X Found": "Aucun X trouvé",
"Node": "Nœud",
"Node Name": "Nom du nœud",
"Non-stream": "Non-streaming",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Les récompenses dinvitation non nulles nécessitent une confirmation de conformité dans les paramètres de la passerelle de paiement.",
@@ -3193,6 +3205,7 @@
"Reasoning Effort": "Effort de raisonnement",
"Receive Upstream Model Update Notifications": "Recevoir les notifications de mise à jour des modèles en amont",
"Received": "Reçu",
"Received amount": "Montant reçu",
"Recently launched models": "Modèles récemment lancés",
"Recently launched models gaining traction": "Modèles récemment publiés et en forte progression",
"Recharge": "Recharger",
@@ -3209,7 +3222,6 @@
"Redeem codes": "Échanger des codes",
"Redeemed By": "Utilisé par",
"Redeemed:": "Utilisé :",
"Received amount": "Montant reçu",
"redemption code": "code d'échange",
"Redemption Code": "Code d'échange",
"Redemption code deleted successfully": "Code d'échange supprimé avec succès",
@@ -3232,7 +3244,7 @@
"Referral Program": "Programme de parrainage",
"Referral reward transfer is disabled until the administrator confirms compliance terms.": "Le transfert des récompenses de parrainage est désactivé jusqu’à ce que ladministrateur confirme les conditions de conformité.",
"Refine models by provider, group, type, and tags.": "Affinez les modèles par fournisseur, groupe, type et tags.",
"Refresh": "Actualiser",
"Refresh": "Actualisation",
"Refresh Cache": "Actualiser le cache",
"Refresh credential": "Actualiser l'identifiant",
"Refresh failed": "Échec de l'actualisation",
@@ -3611,6 +3623,8 @@
"Server IP": "IP du serveur",
"Server Log Management": "Gestion des journaux serveur",
"Server logging is not enabled (log directory not configured)": "La journalisation serveur n'est pas activée (répertoire non configuré)",
"Server Power Core": "Cœur de puissance serveur",
"Server Status": "État du serveur",
"Server Token": "Jeton de serveur",
"Service account JSON file(s)": "Fichier(s) JSON de compte de service",
"Session expired!": "Session expirée !",
@@ -3828,6 +3842,7 @@
"Sync Upstream": "Synchroniser l'amont",
"Sync Upstream Models": "Synchroniser les modèles amont",
"Synchronize models and vendors from an upstream source": "Synchroniser les modèles et les fournisseurs à partir d'une source amont",
"Syncing": "Synchronisation",
"Syncing prices, please wait...": "Synchronisation des prix, veuillez patienter...",
"Syncing...": "Synchronisation...",
"System": "Système",
@@ -4108,6 +4123,7 @@
"Total requests made": "Requêtes totales effectuées",
"Total tokens": "Jetons totaux",
"Total Tokens": "Jetons totaux",
"Total Tokens Burned": "Total des tokens consommés",
"Total Usage": "Utilisation totale",
"Total:": "Total :",
"TPM": "TPM",
@@ -4538,6 +4554,7 @@
"Zero retention": "Aucune rétention",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"伟大无需多言": "La grandeur se passe de mots"
}
}
+20 -3
View File
@@ -93,6 +93,7 @@
"30 Days": "30日",
"30 days ago": "30日前",
"30d change": "30日変化",
"30s refresh": "30秒更新",
"5 minutes": "5 分",
"5-Hour Window": "5時間ウィンドウ",
"50 / page": "50 / ページ",
@@ -101,6 +102,7 @@
"80,443,8080": "80,443,8080",
"A billing multiplier. Lower ratios mean lower API call costs.": "課金倍率です。倍率が低いほど API 呼び出しコストは低くなります。",
"A focused home for keys, balance, routing, and service health.": "キー、残高、ルーティング、サービス状態を集約したホームです。",
"A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.": "リアルタイム容量、耐障害ルーティング、明瞭なトークン消費をファーストビューで示す高スループット AI API ゲートウェイです。",
"About": "このサービスについて",
"About {{days}} days left": "約 {{days}} 日分",
"Accept Unpriced Models": "価格設定されていないモデルを許可",
@@ -462,6 +464,7 @@
"Availability (last 24h)": "可用性(過去 24 時間)",
"Available": "空き",
"Available disk space": "利用可能なディスク容量",
"Available headroom": "利用可能な余力",
"Available Models": "利用可能なモデル",
"Available Rewards": "利用可能な報酬",
"Average latency": "平均レイテンシ",
@@ -815,6 +818,7 @@
"Compliance confirmation required": "コンプライアンス確認が必要です",
"Compliance confirmed": "コンプライアンス確認済み",
"Compliance confirmed successfully": "コンプライアンス確認が完了しました",
"Compute usage": "計算使用率",
"Concatenate channel system prompt with user&apos;s prompt": "チャネルのシステムプロンプトをユーザーのプロンプトと連結する",
"Condition Path": "条件パス",
"Condition Settings": "条件設定",
@@ -1348,6 +1352,7 @@
"edit_this": "edit_this",
"Editor mode": "エディターモード",
"Education": "教育",
"Elastic compute headroom": "弾力的な計算余力",
"Email": "メールアドレス",
"Email (required for verification)": "メールアドレス(認証に必須)",
"Email Address": "メールアドレス",
@@ -1528,8 +1533,8 @@
"Exists": "存在",
"Expand": "展開",
"Expand All": "すべて展開",
"Expected a JSON array.": "JSON 配列が必要です。",
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
"Expected a JSON array.": "JSON 配列が必要です。",
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
"Expiration Time": "有効期限",
"expired": "期限切れ",
@@ -1816,6 +1821,7 @@
"Full width": "全幅",
"Function calling": "関数呼び出し",
"Functions": "関数",
"Gateway Load": "ゲートウェイ負荷",
"GC Count": "GC 回数",
"GC executed": "GC 実行完了",
"GC execution failed": "GC 実行失敗",
@@ -1925,6 +1931,7 @@
"header. Anthropic-formatted endpoints accept the": " ヘッダーが必要です。Anthropic 形式のエンドポイントでは",
"Health": "ヘルスケア",
"Healthy": "正常",
"HHHL AI Gateway": "ユニバースフェデレーション",
"Hidden — verify to reveal": "非表示 — 確認して表示",
"Hide": "非表示にする",
"Hide API key": "APIキーを非表示",
@@ -2198,6 +2205,9 @@
"List of models supported by this channel. Use comma to separate multiple models.": "このチャネルがサポートするモデルのリストです。複数のモデルはカンマで区切ってください。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Passkeyの登録と認証が許可されているオリジン(1行に1つ)のリスト。",
"List view": "リスト表示",
"Live capacity telemetry": "ライブ容量テレメトリ",
"Live resource telemetry": "ライブリソーステレメトリ",
"Live Status": "ライブ状態",
"LLM Leaderboard": "LLM リーダーボード",
"LLM prompt helper": "LLMプロンプトヘルパー",
"Load Balancing": "ロードバランシング",
@@ -2295,6 +2305,7 @@
"Media pricing": "メディア料金",
"Median time-to-first-token (TTFT) sampled hourly per group": "グループ別に毎時サンプリングした最初のトークンまでの中央値レイテンシ (TTFT)",
"Medical Q&A, mental health support": "医療Q&A・メンタルヘルスサポート",
"Memory Capacity": "メモリ容量",
"Memory Hits": "メモリヒット",
"Memory Threshold (%)": "メモリ閾値 (%)",
"Merchant ID": "マーチャントID",
@@ -2646,6 +2657,7 @@
"No Users Found": "ユーザーが見つかりません",
"No vendor data available": "ベンダーデータがありません",
"No X Found": "X が見つかりません",
"Node": "ノード",
"Node Name": "ノード名",
"Non-stream": "非ストリーミング",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。",
@@ -3193,6 +3205,7 @@
"Reasoning Effort": "推論強度",
"Receive Upstream Model Update Notifications": "アップストリームモデル更新通知を受け取る",
"Received": "受信済み",
"Received amount": "受け取り額",
"Recently launched models": "最近リリースされたモデル",
"Recently launched models gaining traction": "最近リリースされ勢いのあるモデル",
"Recharge": "チャージ",
@@ -3209,7 +3222,6 @@
"Redeem codes": "コードを交換",
"Redeemed By": "引き換え元",
"Redeemed:": "引き換え済み:",
"Received amount": "受け取り額",
"redemption code": "引き換えコード",
"Redemption Code": "引き換えコード",
"Redemption code deleted successfully": "引き換えコードを正常に削除しました",
@@ -3611,6 +3623,8 @@
"Server IP": "サーバー IP",
"Server Log Management": "サーバーログ管理",
"Server logging is not enabled (log directory not configured)": "サーバーログが有効になっていません(ログディレクトリが未設定)",
"Server Power Core": "サーバーパワーコア",
"Server Status": "サーバー状態",
"Server Token": "サーバートークン",
"Service account JSON file(s)": "サービスアカウントJSONファイル",
"Session expired!": "セッションが期限切れです!",
@@ -3828,6 +3842,7 @@
"Sync Upstream": "アップストリームを同期",
"Sync Upstream Models": "アップストリームモデルを同期",
"Synchronize models and vendors from an upstream source": "アップストリームソースからモデルとベンダーを同期",
"Syncing": "同期中",
"Syncing prices, please wait...": "価格を同期中、しばらくお待ちください...",
"Syncing...": "同期中...",
"System": "システム",
@@ -4108,6 +4123,7 @@
"Total requests made": "合計リクエスト数",
"Total tokens": "合計トークン",
"Total Tokens": "合計トークン",
"Total Tokens Burned": "総消費トークン",
"Total Usage": "総使用量",
"Total:": "合計:",
"TPM": "TPM",
@@ -4538,6 +4554,7 @@
"Zero retention": "データ保持なし",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V 4",
"Zoom": "ズーム"
"Zoom": "ズーム",
"伟大无需多言": "偉大さに言葉はいらない"
}
}
+21 -4
View File
@@ -93,6 +93,7 @@
"30 Days": "30 дней",
"30 days ago": "30 дней назад",
"30d change": "Изменение за 30 дней",
"30s refresh": "Обновление 30 с",
"5 minutes": "5 минут",
"5-Hour Window": "5-часовое окно",
"50 / page": "50 / страница",
@@ -101,6 +102,7 @@
"80,443,8080": "80,443,8080",
"A billing multiplier. Lower ratios mean lower API call costs.": "Множитель тарификации. Чем ниже коэффициент, тем ниже стоимость вызовов API.",
"A focused home for keys, balance, routing, and service health.": "Единый экран для ключей, баланса, маршрутов и состояния сервиса.",
"A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.": "Высокопроизводительный AI API-шлюз с емкостью в реальном времени, отказоустойчивой маршрутизацией и прозрачным расходом токенов уже на первом экране.",
"About": "О проекте",
"About {{days}} days left": "Примерно {{days}} дней",
"Accept Unpriced Models": "Принимать модели без цены",
@@ -462,6 +464,7 @@
"Availability (last 24h)": "Доступность (последние 24 ч)",
"Available": "Доступно",
"Available disk space": "Доступное дисковое пространство",
"Available headroom": "Доступный резерв",
"Available Models": "Доступные модели",
"Available Rewards": "Доступные награды",
"Average latency": "Средняя задержка",
@@ -815,6 +818,7 @@
"Compliance confirmation required": "Требуется подтверждение соответствия",
"Compliance confirmed": "Соответствие подтверждено",
"Compliance confirmed successfully": "Соответствие успешно подтверждено",
"Compute usage": "Использование вычислений",
"Concatenate channel system prompt with user&apos;s prompt": "Объединить системный промпт канала с промптом пользователя",
"Condition Path": "Путь условия",
"Condition Settings": "Настройки условия",
@@ -1348,6 +1352,7 @@
"edit_this": "изменить_это",
"Editor mode": "Режим редактора",
"Education": "Образование",
"Elastic compute headroom": "Эластичный запас вычислений",
"Email": "Электронная почта",
"Email (required for verification)": "Email (требуется для верификации)",
"Email Address": "Адрес электронной почты",
@@ -1528,8 +1533,8 @@
"Exists": "Существует",
"Expand": "Развернуть",
"Expand All": "Развернуть все",
"Expected a JSON array.": "Ожидается JSON-массив.",
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
"Expected a JSON array.": "Ожидается JSON-массив.",
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
"Expiration Time": "Время истечения срока действия",
"expired": "истек",
@@ -1816,6 +1821,7 @@
"Full width": "Полная ширина",
"Function calling": "Вызов функций",
"Functions": "Функции",
"Gateway Load": "Нагрузка шлюза",
"GC Count": "Кол-во GC",
"GC executed": "GC выполнен",
"GC execution failed": "Ошибка выполнения GC",
@@ -1925,6 +1931,7 @@
"header. Anthropic-formatted endpoints accept the": ". Эндпоинты формата Anthropic вместо этого принимают",
"Health": "Здоровье",
"Healthy": "В норме",
"HHHL AI Gateway": "Вселенская Федерация",
"Hidden — verify to reveal": "Скрыто — подтвердите, чтобы показать",
"Hide": "Скрыть",
"Hide API key": "Скрыть API ключ",
@@ -2198,6 +2205,9 @@
"List of models supported by this channel. Use comma to separate multiple models.": "Список моделей, поддерживаемых этим каналом. Используйте запятую для разделения нескольких моделей.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Список источников (один на строку), разрешенных для регистрации и аутентификации Passkey.",
"List view": "Вид списка",
"Live capacity telemetry": "Телеметрия емкости в реальном времени",
"Live resource telemetry": "Телеметрия ресурсов в реальном времени",
"Live Status": "Статус в реальном времени",
"LLM Leaderboard": "Рейтинг LLM",
"LLM prompt helper": "Помощник с промптом для LLM",
"Load Balancing": "Балансировка нагрузки",
@@ -2295,6 +2305,7 @@
"Media pricing": "Цены для медиа",
"Median time-to-first-token (TTFT) sampled hourly per group": "Медианная задержка первого токена (TTFT), измеряемая ежечасно по группам",
"Medical Q&A, mental health support": "Медицинские Q&A, поддержка ментального здоровья",
"Memory Capacity": "Объем памяти",
"Memory Hits": "Попаданий памяти",
"Memory Threshold (%)": "Порог памяти (%)",
"Merchant ID": "ID мерчанта",
@@ -2646,6 +2657,7 @@
"No Users Found": "Пользователи не найдены",
"No vendor data available": "Данных по поставщикам нет",
"No X Found": "X не найдено",
"Node": "Узел",
"Node Name": "Имя узла",
"Non-stream": "Не потоковый",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Ненулевые награды за приглашения требуют подтверждения соответствия в настройках платежного шлюза.",
@@ -3193,6 +3205,7 @@
"Reasoning Effort": "Интенсивность рассуждения",
"Receive Upstream Model Update Notifications": "Получать уведомления об обновлениях вышестоящих моделей",
"Received": "Получено",
"Received amount": "Полученная сумма",
"Recently launched models": "Недавно запущенные модели",
"Recently launched models gaining traction": "Недавно вышедшие модели, набирающие популярность",
"Recharge": "Пополнение",
@@ -3209,7 +3222,6 @@
"Redeem codes": "Активировать коды",
"Redeemed By": "Активировано",
"Redeemed:": "Активировано:",
"Received amount": "Полученная сумма",
"redemption code": "код активации",
"Redemption Code": "Код активации",
"Redemption code deleted successfully": "Код активации успешно удален",
@@ -3232,7 +3244,7 @@
"Referral Program": "Реферальная программа",
"Referral reward transfer is disabled until the administrator confirms compliance terms.": "Перевод реферальных наград отключен, пока администратор не подтвердит условия соответствия.",
"Refine models by provider, group, type, and tags.": "Уточняйте список моделей по поставщику, группе, типу и тегам.",
"Refresh": "Обновить",
"Refresh": "Обновление",
"Refresh Cache": "Обновить кэш",
"Refresh credential": "Обновить учётные данные",
"Refresh failed": "Ошибка обновления",
@@ -3611,6 +3623,8 @@
"Server IP": "IP сервера",
"Server Log Management": "Управление журналами сервера",
"Server logging is not enabled (log directory not configured)": "Журналирование сервера не включено (каталог журналов не настроен)",
"Server Power Core": "Силовое ядро сервера",
"Server Status": "Статус сервера",
"Server Token": "Токен сервера",
"Service account JSON file(s)": "JSON-файл сервисного аккаунта",
"Session expired!": "Сессия истекла!",
@@ -3828,6 +3842,7 @@
"Sync Upstream": "Синхронизировать Upstream",
"Sync Upstream Models": "Синхронизировать модели Upstream",
"Synchronize models and vendors from an upstream source": "Синхронизировать модели и поставщиков из upstream источника",
"Syncing": "Синхронизация",
"Syncing prices, please wait...": "Синхронизация цен, подождите...",
"Syncing...": "Синхронизация...",
"System": "Система",
@@ -4108,6 +4123,7 @@
"Total requests made": "Всего сделанных запросов",
"Total tokens": "Всего токенов",
"Total Tokens": "Всего токенов",
"Total Tokens Burned": "Всего израсходовано токенов",
"Total Usage": "Общее использование",
"Total:": "Всего:",
"TPM": "TPM",
@@ -4538,6 +4554,7 @@
"Zero retention": "Без хранения данных",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"伟大无需多言": "Величие не требует слов"
}
}
+21 -4
View File
@@ -93,6 +93,7 @@
"30 Days": "30 ngày",
"30 days ago": "30 ngày trước",
"30d change": "Thay đổi 30 ngày",
"30s refresh": "Làm mới 30 giây",
"5 minutes": "5 phút",
"5-Hour Window": "Cửa sổ 5 giờ",
"50 / page": "50 / trang",
@@ -101,6 +102,7 @@
"80,443,8080": "80,443,8080",
"A billing multiplier. Lower ratios mean lower API call costs.": "Hệ số tính phí. Tỷ lệ càng thấp thì chi phí gọi API càng thấp.",
"A focused home for keys, balance, routing, and service health.": "Trang tổng quan tập trung cho khóa, số dư, định tuyến và trạng thái dịch vụ.",
"A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.": "Cổng AI API thông lượng cao hiển thị ngay từ màn hình đầu tiên năng lực thời gian thực, định tuyến bền bỉ và mức tiêu thụ token minh bạch.",
"About": "Giới thiệu",
"About {{days}} days left": "Còn khoảng {{days}} ngày",
"Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá",
@@ -462,6 +464,7 @@
"Availability (last 24h)": "Khả dụng (24 giờ qua)",
"Available": "Khả dụng",
"Available disk space": "Dung lượng đĩa khả dụng",
"Available headroom": "Dư địa khả dụng",
"Available Models": "Mô hình khả dụng",
"Available Rewards": "Phần thưởng hiện có",
"Average latency": "Độ trễ trung bình",
@@ -815,6 +818,7 @@
"Compliance confirmation required": "Cần xác nhận tuân thủ",
"Compliance confirmed": "Đã xác nhận tuân thủ",
"Compliance confirmed successfully": "Xác nhận tuân thủ thành công",
"Compute usage": "Mức dùng tính toán",
"Concatenate channel system prompt with user&apos;s prompt": "Nối lời nhắc hệ thống kênh với lời nhắc của người dùng",
"Condition Path": "Đường dẫn điều kiện",
"Condition Settings": "Cài đặt điều kiện",
@@ -1348,6 +1352,7 @@
"edit_this": "edit_this",
"Editor mode": "Chế độ trình sửa",
"Education": "Giáo dục",
"Elastic compute headroom": "Dư địa tính toán đàn hồi",
"Email": "Email",
"Email (required for verification)": "Email (bắt buộc để xác minh)",
"Email Address": "Địa chỉ email",
@@ -1528,8 +1533,8 @@
"Exists": "Tồn tại",
"Expand": "Mở rộng",
"Expand All": "Mở rộng tất cả",
"Expected a JSON array.": "Cần là một mảng JSON.",
"Expected a JSON array of group identifiers": "Cần là một mảng JSON gồm các định danh nhóm",
"Expected a JSON array.": "Cần là một mảng JSON.",
"Experiment with prompts and models in real time.": "Thử nghiệm với prompt và mô hình theo thời gian thực.",
"Expiration Time": "Thời gian hết hạn",
"expired": "Đã hết hạn",
@@ -1816,6 +1821,7 @@
"Full width": "Toàn chiều rộng",
"Function calling": "Gọi hàm",
"Functions": "Hàm",
"Gateway Load": "Tải cổng",
"GC Count": "Số lần GC",
"GC executed": "GC đã thực thi",
"GC execution failed": "Thực thi GC thất bại",
@@ -1924,7 +1930,8 @@
"Header Value (supports string or JSON mapping)": "Giá trị header (hỗ trợ chuỗi hoặc ánh xạ JSON)",
"header. Anthropic-formatted endpoints accept the": ". Các endpoint định dạng Anthropic chấp nhận header",
"Health": "Sức khỏe",
"Healthy": "Bình thường",
"Healthy": "Khỏe mạnh",
"HHHL AI Gateway": "Liên bang Vũ trụ",
"Hidden — verify to reveal": "Ẩn — xác minh để hiển thị",
"Hide": "Ẩn",
"Hide API key": "Ẩn khóa API",
@@ -2198,6 +2205,9 @@
"List of models supported by this channel. Use comma to separate multiple models.": "Danh sách các mô hình được hỗ trợ bởi kênh này. Sử dụng dấu phẩy để phân tách nhiều mô hình.",
"List of origins (one per line) allowed for Passkey registration and authentication.": "Danh sách các nguồn gốc (mỗi dòng một mục) được phép đăng ký và xác thực Passkey.",
"List view": "Xem dạng danh sách",
"Live capacity telemetry": "Đo lường năng lực trực tiếp",
"Live resource telemetry": "Đo lường tài nguyên trực tiếp",
"Live Status": "Trạng thái trực tiếp",
"LLM Leaderboard": "Bảng xếp hạng LLM",
"LLM prompt helper": "Trợ lý prompt LLM",
"Load Balancing": "Tải cân bằng",
@@ -2295,6 +2305,7 @@
"Media pricing": "Giá phương tiện",
"Median time-to-first-token (TTFT) sampled hourly per group": "Độ trễ token đầu tiên trung vị (TTFT) lấy mẫu mỗi giờ theo nhóm",
"Medical Q&A, mental health support": "Hỏi đáp y tế, hỗ trợ sức khỏe tinh thần",
"Memory Capacity": "Dung lượng bộ nhớ",
"Memory Hits": "Lượt truy cập bộ nhớ",
"Memory Threshold (%)": "Ngưỡng bộ nhớ (%)",
"Merchant ID": "Mã thương gia",
@@ -2646,6 +2657,7 @@
"No Users Found": "Không tìm thấy người dùng nào",
"No vendor data available": "Không có dữ liệu nhà cung cấp",
"No X Found": "Không tìm thấy X",
"Node": "Nút",
"Node Name": "Tên nút",
"Non-stream": "Không phát trực tuyến",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Phần thưởng mời khác 0 yêu cầu xác nhận tuân thủ trong cài đặt Cổng thanh toán.",
@@ -3193,6 +3205,7 @@
"Reasoning Effort": "Cường độ suy luận",
"Receive Upstream Model Update Notifications": "Nhận thông báo cập nhật mô hình nguồn",
"Received": "Đã nhận",
"Received amount": "Số tiền đã nhận",
"Recently launched models": "Các mô hình ra mắt gần đây",
"Recently launched models gaining traction": "Mô hình mới phát hành đang được ưa chuộng",
"Recharge": "Nạp lại",
@@ -3209,7 +3222,6 @@
"Redeem codes": "Đổi mã",
"Redeemed By": "Được chuộc bởi",
"Redeemed:": "Đã đổi:",
"Received amount": "Số tiền đã nhận",
"redemption code": "mã đổi thưởng",
"Redemption Code": "Mã đổi thưởng",
"Redemption code deleted successfully": "Mã đổi thưởng đã xóa thành công",
@@ -3611,6 +3623,8 @@
"Server IP": "IP máy chủ",
"Server Log Management": "Quản lý nhật ký máy chủ",
"Server logging is not enabled (log directory not configured)": "Nhật ký máy chủ chưa được bật (chưa cấu hình thư mục nhật ký)",
"Server Power Core": "Lõi sức mạnh máy chủ",
"Server Status": "Trạng thái máy chủ",
"Server Token": "Mã thông báo máy chủ",
"Service account JSON file(s)": "Tệp JSON tài khoản dịch vụ",
"Session expired!": "Phiên hết hạn!",
@@ -3828,6 +3842,7 @@
"Sync Upstream": "Đồng bộ nguồn",
"Sync Upstream Models": "Đồng bộ các mô hình nguồn",
"Synchronize models and vendors from an upstream source": "Đồng bộ hóa các mô hình và nhà cung cấp từ một nguồn thượng nguồn",
"Syncing": "Đang đồng bộ",
"Syncing prices, please wait...": "Đang đồng bộ giá, vui lòng đợi...",
"Syncing...": "Đang đồng bộ...",
"System": "Hệ thống",
@@ -4108,6 +4123,7 @@
"Total requests made": "Tổng lượt yêu cầu",
"Total tokens": "Tổng số token",
"Total Tokens": "Tổng số token",
"Total Tokens Burned": "Tổng token đã tiêu thụ",
"Total Usage": "Tổng Mức Sử dụng",
"Total:": "Tổng cộng:",
"TPM": "TPM",
@@ -4538,6 +4554,7 @@
"Zero retention": "Không lưu dữ liệu",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
"Zoom": "Zoom"
"Zoom": "Zoom",
"伟大无需多言": "Vĩ đại không cần nhiều lời"
}
}
+21 -4
View File
@@ -93,6 +93,7 @@
"30 Days": "30 天",
"30 days ago": "30 天前",
"30d change": "30 天变化",
"30s refresh": "30 秒刷新",
"5 minutes": "5 分钟",
"5-Hour Window": "5小时窗口",
"50 / page": "50 条/页",
@@ -101,6 +102,7 @@
"80,443,8080": "80,443,8080",
"A billing multiplier. Lower ratios mean lower API call costs.": "计费乘数,倍率越低,API 调用费用越低。",
"A focused home for keys, balance, routing, and service health.": "集中展示密钥、余额、路由和服务健康状态。",
"A high-throughput AI API gateway with real-time capacity, resilient routing, and transparent token consumption at the very first glance.": "高吞吐 AI API 网关,首屏即可展示实时容量、弹性路由与透明 token 消耗。",
"About": "关于",
"About {{days}} days left": "约剩 {{days}} 天",
"Accept Unpriced Models": "接受未定价模型",
@@ -462,6 +464,7 @@
"Availability (last 24h)": "可用率(最近 24 小时)",
"Available": "可用",
"Available disk space": "可用磁盘空间",
"Available headroom": "可用余量",
"Available Models": "可用模型",
"Available Rewards": "可用奖励",
"Average latency": "平均延迟",
@@ -815,6 +818,7 @@
"Compliance confirmation required": "需要确认合规条款",
"Compliance confirmed": "合规已确认",
"Compliance confirmed successfully": "合规确认成功",
"Compute usage": "计算资源占用",
"Concatenate channel system prompt with user&apos;s prompt": "将渠道系统提示与用户的提示连接起来",
"Condition Path": "条件路径",
"Condition Settings": "条件项设置",
@@ -1348,6 +1352,7 @@
"edit_this": "edit_this",
"Editor mode": "编辑器模式",
"Education": "教育",
"Elastic compute headroom": "弹性算力余量",
"Email": "邮箱",
"Email (required for verification)": "电子邮件(验证必需)",
"Email Address": "电子邮件地址",
@@ -1528,8 +1533,8 @@
"Exists": "存在",
"Expand": "展开",
"Expand All": "全部展开",
"Expected a JSON array.": "应为 JSON 数组。",
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
"Expected a JSON array.": "应为 JSON 数组。",
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
"Expiration Time": "过期时间",
"expired": "已过期",
@@ -1816,6 +1821,7 @@
"Full width": "全宽",
"Function calling": "函数调用",
"Functions": "函数",
"Gateway Load": "网关负载",
"GC Count": "GC 次数",
"GC executed": "GC 已执行",
"GC execution failed": "GC 执行失败",
@@ -1924,7 +1930,8 @@
"Header Value (supports string or JSON mapping)": "请求头值(支持字符串或 JSON 映射)",
"header. Anthropic-formatted endpoints accept the": " 请求头。Anthropic 格式的端点也接受",
"Health": "健康",
"Healthy": "正常",
"Healthy": "健康",
"HHHL AI Gateway": "宇宙联邦",
"Hidden — verify to reveal": "隐藏 — 验证以显示",
"Hide": "隐藏",
"Hide API key": "隐藏 API 密钥",
@@ -2198,6 +2205,9 @@
"List of models supported by this channel. Use comma to separate multiple models.": "此渠道支持的模型列表。使用逗号分隔多个模型。",
"List of origins (one per line) allowed for Passkey registration and authentication.": "允许用于 Passkey 注册和身份验证的来源列表(每行一个)。",
"List view": "列表视图",
"Live capacity telemetry": "实时容量遥测",
"Live resource telemetry": "实时资源遥测",
"Live Status": "实时状态",
"LLM Leaderboard": "LLM 排行榜",
"LLM prompt helper": "LLM 辅助设计提示词",
"Load Balancing": "负载均衡",
@@ -2295,6 +2305,7 @@
"Media pricing": "媒体定价",
"Median time-to-first-token (TTFT) sampled hourly per group": "按小时采样的各分组首 token 延迟(TTFT)中位数",
"Medical Q&A, mental health support": "医疗问答与心理健康支持",
"Memory Capacity": "内存容量",
"Memory Hits": "内存命中",
"Memory Threshold (%)": "内存阈值 (%)",
"Merchant ID": "商户 ID",
@@ -2646,6 +2657,7 @@
"No Users Found": "未找到用户",
"No vendor data available": "暂无厂商数据",
"No X Found": "未找到 X",
"Node": "节点",
"Node Name": "节点名称",
"Non-stream": "非流式",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。",
@@ -3193,6 +3205,7 @@
"Reasoning Effort": "推理强度",
"Receive Upstream Model Update Notifications": "接收上游模型更新通知",
"Received": "获得",
"Received amount": "已收额度",
"Recently launched models": "近期发布的模型",
"Recently launched models gaining traction": "近期发布并快速增长的模型",
"Recharge": "充值",
@@ -3209,7 +3222,6 @@
"Redeem codes": "兑换码",
"Redeemed By": "兑换人",
"Redeemed:": "已兑换:",
"Received amount": "已收额度",
"redemption code": "兑换码",
"Redemption Code": "兑换码",
"Redemption code deleted successfully": "兑换码删除成功",
@@ -3611,6 +3623,8 @@
"Server IP": "服务器 IP",
"Server Log Management": "服务器日志管理",
"Server logging is not enabled (log directory not configured)": "服务器日志功能未启用(未配置日志目录)",
"Server Power Core": "服务器动力核心",
"Server Status": "服务器状态",
"Server Token": "服务器 Token",
"Service account JSON file(s)": "服务账号 JSON 文件",
"Session expired!": "会话已过期!",
@@ -3828,6 +3842,7 @@
"Sync Upstream": "同步上游",
"Sync Upstream Models": "同步上游模型",
"Synchronize models and vendors from an upstream source": "从上游源同步模型和供应商",
"Syncing": "同步中",
"Syncing prices, please wait...": "正在同步价格,请稍候...",
"Syncing...": "同步中...",
"System": "系统",
@@ -4108,6 +4123,7 @@
"Total requests made": "总请求数",
"Total tokens": "总 Token",
"Total Tokens": "总 Token 数",
"Total Tokens Burned": "总消耗 Tokens",
"Total Usage": "总用量",
"Total:": "总计:",
"TPM": "TPM",
@@ -4538,6 +4554,7 @@
"Zero retention": "零数据保留",
"Zhipu": "智谱",
"Zhipu V4": "智谱 V4",
"Zoom": "缩放"
"Zoom": "缩放",
"伟大无需多言": "伟大无需多言"
}
}
+6
View File
@@ -167,6 +167,12 @@ function OAuthCallback() {
params: { code: search.code, state: search.state },
skipBusinessError: true,
}
// Add PKCE code_verifier if present in sessionStorage
const codeVerifier = sessionStorage.getItem(`pkce_verifier_${search.state}`)
if (codeVerifier) {
config.params.code_verifier = codeVerifier
sessionStorage.removeItem(`pkce_verifier_${search.state}`)
}
const res = await api.get(`/api/oauth/${provider}`, config)
if (res?.data?.success) {
const { message } = res.data
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.DS_Store
*.local
.env.local
node_modules
dist
.DS_Store
*.local
.vite
node_modules/.tmp
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%236366f1'/%3E%3Ctext x='50' y='65' font-size='52' text-anchor='middle' fill='white' font-family='system-ui'%3E🖼%3C/text%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>newapi-image-gen</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2627
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "newapi-image-gen",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Minimal web image-generation frontend for new-api. Login with your new-api account, generate via /v1/images/generations, billing flows through new-api.",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@types/node": "^20.19.43",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import SettingsPanel from './components/SettingsPanel.vue'
import ImageGenerator from './components/ImageGenerator.vue'
import Gallery from './components/Gallery.vue'
import { probeSession, listModels, getSelf, type NewApiUser, type Auth } from './lib/api'
import { loadSettings } from './lib/settings'
const user = ref<NewApiUser | null>(null)
const auth = ref<Auth>({ kind: 'session' })
const baseUrl = ref<string>(loadSettings().baseUrl)
const models = ref<string[]>([])
const error = ref<string>('')
const settingsRef = ref<InstanceType<typeof SettingsPanel> | null>(null)
const galleryRef = ref<InstanceType<typeof Gallery> | null>(null)
const checking = ref<boolean>(true)
async function refreshSession() {
checking.value = true
try {
const u = await probeSession(baseUrl.value)
user.value = u
error.value = ''
if (u) {
try {
models.value = await listModels(baseUrl.value, { kind: 'session' })
} catch (e) {
error.value = `已登录,但拉取模型失败: ${e}`
}
} else {
models.value = []
}
} finally {
checking.value = false
}
}
onMounted(refreshSession)
// Re-probe when the tab regains focus (e.g. user came back from /sign-in).
// `visibilitychange` covers mobile / tab-switch too; `focus` covers desktop.
window.addEventListener('focus', refreshSession)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') refreshSession()
})
async function onSessionChanged(u: NewApiUser | null) {
user.value = u
if (u) {
try {
models.value = await listModels(baseUrl.value, { kind: 'session' })
} catch (e) {
error.value = `已登录,但拉取模型失败: ${e}`
}
} else {
models.value = []
}
}
function onAuthOverridden(a: Auth) {
auth.value = a
if (a.kind === 'sk') {
// refresh user info using the new auth
getSelf(baseUrl.value, a)
.then((u) => {
user.value = u
})
.catch((e) => {
error.value = `sk-key 无效: ${e}`
})
}
}
function onGenerated(img: { prompt: string; model: string; urls: string[] }) {
galleryRef.value?.add(img)
// refresh quota after a generation (best-effort)
if (user.value) {
probeSession(baseUrl.value)
.then((u) => {
if (u) user.value = u
})
.catch(() => {
/* ignore */
})
}
}
function onError(msg: string) {
error.value = msg
}
</script>
<template>
<div class="min-h-full max-w-5xl mx-auto p-6 space-y-5">
<header class="flex items-baseline justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">newapi-image-gen</h1>
<p class="text-xs text-zinc-500 mt-0.5">
new-api <code class="text-zinc-400">/v1/images/generations</code>,额度扣 new-api 账户
</p>
</div>
<a
v-if="user"
href="/"
class="text-xs text-zinc-500 hover:text-zinc-300"
> 返回 new-api 主页</a>
</header>
<p v-if="error" class="rounded-md border border-rose-800 bg-rose-950/40 px-3 py-2 text-sm text-rose-300">
{{ error }}
</p>
<SettingsPanel
ref="settingsRef"
:user="user"
@session-changed="onSessionChanged"
@auth-overridden="onAuthOverridden"
/>
<div v-if="checking" class="text-sm text-zinc-500">检查登录态</div>
<ImageGenerator
v-else-if="user"
:base-url="baseUrl"
:auth="auth"
:models="models"
@generated="onGenerated"
@error="onError"
/>
<div v-else class="rounded-xl border border-dashed border-zinc-800 p-6 text-center text-sm text-zinc-500">
先在上方登录,登录后会自动出现生图面板
</div>
<Gallery ref="galleryRef" :base-url="baseUrl" />
<footer class="pt-4 pb-2 text-center text-xs text-zinc-600">
登录走 new-api 自己的 OAuth,会话 cookie new-api 主站共享;本前端不会碰你的密码或 token
</footer>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
interface Item {
id: string
prompt: string
model: string
urls: string[]
createdAt: number
}
const props = defineProps<{ baseUrl: string }>()
const items = ref<Item[]>([])
const STORAGE_KEY = 'newapi-image-gen:gallery:v1'
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
items.value = raw ? (JSON.parse(raw) as Item[]) : []
} catch {
items.value = []
}
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.value.slice(0, 200)))
}
function add(item: Omit<Item, 'id' | 'createdAt'>) {
items.value.unshift({
...item,
id: crypto.randomUUID(),
createdAt: Date.now(),
})
save()
}
defineExpose({ add })
onMounted(load)
watch(() => props.baseUrl, () => {
// gallery is base-agnostic; nothing to reload
})
</script>
<template>
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-semibold text-zinc-200">3. 本地作品集</h2>
<span class="text-xs text-zinc-500">本浏览器保留,最多 200 </span>
</div>
<p v-if="!items.length" class="text-sm text-zinc-500">还没生成过图片</p>
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="it in items" :key="it.id" class="space-y-1">
<a
v-for="(u, i) in it.urls"
:key="i"
:href="u"
target="_blank"
rel="noreferrer"
class="block aspect-square overflow-hidden rounded-md border border-zinc-800 bg-zinc-950"
>
<img :src="u" class="w-full h-full object-cover" referrerpolicy="no-referrer" loading="lazy" />
</a>
<p class="text-xs text-zinc-400 line-clamp-2" :title="it.prompt">{{ it.prompt }}</p>
<p class="text-[10px] text-zinc-600">{{ it.model }} · {{ new Date(it.createdAt).toLocaleString() }}</p>
</div>
</div>
</div>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { generateImage, type GeneratedImage, type Auth } from '../lib/api'
const props = defineProps<{
baseUrl: string
auth: Auth
models: string[]
}>()
const emit = defineEmits<{
(e: 'generated', img: { prompt: string; model: string; urls: string[] }): void
(e: 'error', msg: string): void
}>()
const prompt = ref<string>('')
const negativePrompt = ref<string>('')
const model = ref<string>('')
const n = ref<number>(1)
const size = ref<string>('1024x1024')
const running = ref<boolean>(false)
const preview = ref<GeneratedImage[]>([])
watch(
() => props.models,
(m) => {
if (m.length && !model.value) model.value = m[0]
},
{ immediate: true },
)
async function go() {
if (!prompt.value.trim() || !model.value) return
running.value = true
preview.value = []
try {
const imgs = await generateImage(props.baseUrl, props.auth, {
model: model.value,
prompt: prompt.value,
negative_prompt: negativePrompt.value || undefined,
n: n.value,
size: size.value,
response_format: 'url',
})
preview.value = imgs
const urls = imgs.map((i) => i.url).filter((u): u is string => !!u)
emit('generated', { prompt: prompt.value, model: model.value, urls })
} catch (e) {
emit('error', String(e))
} finally {
running.value = false
}
}
</script>
<template>
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
<h2 class="font-semibold text-zinc-200">2. 写提示词生图</h2>
<label class="block">
<span class="text-xs text-zinc-400">正向提示词</span>
<textarea
v-model="prompt"
rows="3"
placeholder="a cat astronaut floating in space, ultra-detailed, cinematic lighting"
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
/>
</label>
<label class="block">
<span class="text-xs text-zinc-400">负向提示词(可选,部分模型支持)</span>
<input
v-model="negativePrompt"
placeholder="blurry, low quality, watermark"
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
/>
</label>
<div class="grid grid-cols-3 gap-3">
<label class="block">
<span class="text-xs text-zinc-400">模型</span>
<select
v-model="model"
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm"
>
<option v-if="!models.length" disabled>暂无可用模型</option>
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
</select>
</label>
<label class="block">
<span class="text-xs text-zinc-400">张数</span>
<input
v-model.number="n"
type="number"
min="1"
max="4"
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="text-xs text-zinc-400">尺寸</span>
<select v-model="size" class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm">
<option value="256x256">256×256</option>
<option value="512x512">512×512</option>
<option value="1024x1024">1024×1024</option>
<option value="1024x1792">1024×1792</option>
<option value="1792x1024">1792×1024</option>
</select>
</label>
</div>
<button
@click="go"
:disabled="running || !prompt.trim() || !model"
class="w-full rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:hover:bg-indigo-600 py-2 text-sm font-medium"
>
{{ running ? '生成中…' : '生成' }}
</button>
<div v-if="preview.length" class="grid grid-cols-2 gap-2 pt-2">
<a
v-for="(img, i) in preview"
:key="i"
:href="img.url || '#'"
target="_blank"
rel="noreferrer"
class="block aspect-square overflow-hidden rounded-md border border-zinc-800 bg-zinc-950"
>
<img
v-if="img.url"
:src="img.url"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
loading="lazy"
/>
</a>
</div>
</div>
</template>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { loadSettings, saveSettings, type Settings } from '../lib/settings'
import { getSelf, probeSession, type NewApiUser, type Auth } from '../lib/api'
const props = defineProps<{
user: NewApiUser | null
}>()
const emit = defineEmits<{
(e: 'session-changed', user: NewApiUser | null): void
(e: 'auth-overridden', auth: Auth): void
}>()
const s = ref<Settings>(loadSettings())
const checking = ref<boolean>(false)
const skKeyInput = ref<string>('')
const usingSk = ref<boolean>(false)
const skTestMessage = ref<string>('')
const skTestStatus = ref<'idle' | 'ok' | 'fail'>('idle')
onMounted(() => {
// If the URL contains ?session=ok (the /sign-in page can be configured
// to bounce back here on success), re-probe immediately.
if (window.location.search.includes('session=ok')) {
recheck()
// strip the param so a refresh doesn't re-trigger
const u = new URL(window.location.href)
u.searchParams.delete('session')
window.history.replaceState({}, '', u.toString())
}
})
async function recheck() {
checking.value = true
try {
const u = await probeSession(s.value.baseUrl)
emit('session-changed', u)
} finally {
checking.value = false
}
}
function goSignIn() {
// new-api's /sign-in handles OAuth (LinuxDO / GitHub / Discord / OIDC / 自定义)
// and username+password. We just bounce through it; on success the user can
// click "返回 newapi-image-gen" or simply navigate back to /image-gen.
const returnTo = '/image-gen?session=ok'
window.location.href = `/sign-in?redirect=${encodeURIComponent(returnTo)}`
}
async function testSk() {
skTestStatus.value = 'idle'
skTestMessage.value = ''
if (!skKeyInput.value.trim().startsWith('sk-')) {
skTestStatus.value = 'fail'
skTestMessage.value = 'sk-key 应该以 sk- 开头'
return
}
try {
const auth: Auth = { kind: 'sk', token: skKeyInput.value.trim() }
const u = await getSelf(s.value.baseUrl, auth)
skTestStatus.value = 'ok'
skTestMessage.value = `已验证: ${u.username} · 剩余 ${u.quota.toLocaleString()}`
emit('auth-overridden', auth)
} catch (e) {
skTestStatus.value = 'fail'
skTestMessage.value = String(e)
}
}
function clearSk() {
skKeyInput.value = ''
skTestStatus.value = 'idle'
skTestMessage.value = ''
emit('auth-overridden', { kind: 'session' })
}
onBeforeUnmount(() => {
// persist any edits the user made
saveSettings(s.value)
})
</script>
<template>
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-zinc-200">1. 登录态</h2>
<button
@click="recheck"
:disabled="checking"
class="text-xs text-zinc-500 hover:text-zinc-300 disabled:opacity-50"
>{{ checking ? '检查中…' : '刷新状态' }}</button>
</div>
<!-- 登录态展示 -->
<div v-if="props.user" class="flex items-center gap-3 text-sm">
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-400"></span>
<span class="text-zinc-300">{{ props.user.display_name || props.user.username }}</span>
<span class="text-xs text-zinc-500">@{{ props.user.username }}</span>
<span class="text-xs text-zinc-500">· 剩余额度</span>
<span class="text-emerald-400 font-mono">{{ props.user.quota.toLocaleString() }}</span>
<span class="text-xs text-zinc-600">· : {{ props.user.group }}</span>
</div>
<div v-else class="space-y-2">
<div class="flex items-center gap-3 text-sm">
<span class="inline-flex h-2 w-2 rounded-full bg-zinc-600"></span>
<span class="text-zinc-400">未登录</span>
</div>
<div class="flex flex-wrap gap-2">
<button
@click="goSignIn"
class="rounded-md bg-indigo-600 hover:bg-indigo-500 px-4 py-1.5 text-sm font-medium"
>前往 new-api 登录</button>
<a
href="/sign-in"
class="rounded-md border border-zinc-700 hover:border-zinc-500 px-3 py-1.5 text-sm text-zinc-300"
>在新窗口打开登录页</a>
</div>
<p class="text-xs text-zinc-500">
登录会跳到 new-api 自家登录页(OAuth / 用户名密码,看你 new-api 怎么配的),登完回到这里
</p>
</div>
<!-- 高级:sk-key 兜底 -->
<details class="pt-1">
<summary class="text-xs text-zinc-500 cursor-pointer select-none hover:text-zinc-300">
高级: sk-key 登录(不推荐,只在 cookie 不灵时用)
</summary>
<div class="pt-2 space-y-2">
<input
v-model="skKeyInput"
type="password"
placeholder="sk-..."
class="w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:outline-none"
/>
<div class="flex items-center gap-2">
<button
@click="testSk"
class="rounded-md border border-zinc-700 hover:border-zinc-500 px-3 py-1 text-xs"
>验证</button>
<button
v-if="usingSk || skTestStatus === 'ok'"
@click="clearSk"
class="rounded-md border border-zinc-700 hover:border-zinc-500 px-3 py-1 text-xs"
>清除,回到 session</button>
<span v-if="skTestStatus === 'ok'" class="text-xs text-emerald-400">{{ skTestMessage }}</span>
<span v-else-if="skTestStatus === 'fail'" class="text-xs text-rose-400">{{ skTestMessage }}</span>
</div>
<p class="text-[10px] text-zinc-600">
注意:sk-key 不会存到 localStorage,刷新页面就丢
</p>
</div>
</details>
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Thin client for new-api. All API paths are RELATIVE because image-gen is
* served from the same origin as new-api (embedded in the Go binary at
* `/image-gen/`). Cookies are sent automatically with `credentials: 'include'`.
*
* Auth model after the "同源" integration:
* - new-api sets a `session` cookie on login (Path=/, SameSite=Strict,
* HttpOnly, 30 days). All /api/* and /v1/* endpoints accept the cookie.
* - We do NOT handle the login form ourselves. We just call /api/user/self
* to see whether the cookie is present, and if not, redirect the user to
* new-api's own /sign-in page.
* - As a power-user escape hatch, an `sk-` key still works the same way it
* did in v1: paste it in the settings panel, and we add it as
* `Authorization: Bearer ...`. Sk-keys are NOT persisted (security).
*/
export type Auth =
| { kind: 'session' } // uses the new-api session cookie
| { kind: 'sk'; token: string } // explicit sk-key fallback
| { kind: 'none' }
export interface NewApiUser {
id: number
username: string
display_name?: string
quota: number
used_quota: number
group: string
role: string
status: string
[k: string]: unknown
}
export interface GenerateParams {
model: string
prompt: string
n?: number
size?: string
response_format?: 'url' | 'b64_json'
negative_prompt?: string
}
export interface GeneratedImage {
url?: string
b64_json?: string
revised_prompt?: string
}
export class NewApiError extends Error {
status: number
body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.status = status
this.body = body
}
}
function joinUrl(base: string, path: string): string {
return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path)
}
function authHeader(auth: Auth): Record<string, string> {
if (auth.kind === 'sk') return { Authorization: `Bearer ${auth.token}` }
return {}
}
/**
* `base` should usually be `''` (same-origin) since image-gen is served
* from inside new-api. A non-empty base is still allowed for local dev where
* you run `npm run dev` on :5174 and proxy /api to :3000.
*/
async function request<T>(
base: string,
path: string,
init: RequestInit & { auth?: Auth } = {},
): Promise<T> {
const { auth = { kind: 'session' }, headers, ...rest } = init
const useCredentials = auth.kind !== 'sk' // session auth = rely on cookie
const res = await fetch(joinUrl(base, path), {
...rest,
credentials: useCredentials ? 'include' : rest.credentials,
headers: {
'Content-Type': 'application/json',
...authHeader(auth),
...(headers ?? {}),
},
})
const text = await res.text()
let body: unknown
try {
body = text ? JSON.parse(text) : null
} catch {
body = text
}
if (!res.ok) {
const msg =
(body && typeof body === 'object' && 'message' in body && (body as { message?: string }).message) ||
(body && typeof body === 'object' && 'error' in body && (body as { error?: { message?: string } }).error?.message) ||
`HTTP ${res.status}`
throw new NewApiError(String(msg), res.status, body)
}
return body as T
}
/** Probe the current session. Returns the user if a valid cookie is present, else throws. */
export async function getSelf(base: string, auth: Auth): Promise<NewApiUser> {
const r = await request<{ success: boolean; data: NewApiUser; message?: string }>(
base,
'/api/user/self',
{ auth },
)
if (!r.success) throw new NewApiError(r.message || 'not logged in', 401, r)
return r.data
}
/** Best-effort session probe. Returns the user or null. Never throws. */
export async function probeSession(base: string): Promise<NewApiUser | null> {
try {
return await getSelf(base, { kind: 'session' })
} catch {
return null
}
}
/** List models the user can access. */
export async function listModels(base: string, auth: Auth): Promise<string[]> {
const r = await request<{ data: Array<{ id: string }> } | Array<{ id: string }>>(
base,
'/v1/models',
{ auth },
)
const arr = Array.isArray(r) ? r : r.data
return arr.map((m) => m.id)
}
/** Call /v1/images/generations. */
export async function generateImage(
base: string,
auth: Auth,
params: GenerateParams,
): Promise<GeneratedImage[]> {
const r = await request<{ data: GeneratedImage[]; created: number }>(
base,
'/v1/images/generations',
{
method: 'POST',
auth,
body: JSON.stringify({
model: params.model,
prompt: params.prompt,
n: params.n ?? 1,
size: params.size ?? '1024x1024',
response_format: params.response_format ?? 'url',
...(params.negative_prompt ? { negative_prompt: params.negative_prompt } : {}),
}),
},
)
return r.data
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Settings for image-gen.
*
* After the "同源" integration (image-gen is served from new-api at /image-gen/),
* the only persistent setting is the base URL (mostly empty / same-origin).
* The session cookie is managed by the browser; we don't see or store it.
*
* `skKey` is intentionally NOT persisted: if a power user pastes one, it
* lives only in memory for the tab session and is wiped on refresh.
*/
const KEY = 'newapi-image-gen:settings:v2'
export interface Settings {
baseUrl: string
}
const DEFAULTS: Settings = {
// Empty = same origin (image-gen is at /image-gen/, new-api at /).
// The dev override (Vite on :5174) sets this via .env / runtime config.
baseUrl: '',
}
export function loadSettings(): Settings {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return { ...DEFAULTS }
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<Settings>) }
} catch {
return { ...DEFAULTS }
}
}
export function saveSettings(s: Settings): void {
localStorage.setItem(KEY, JSON.stringify(s))
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#app {
height: 100%;
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"include": ["vite.config.*"],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"composite": true,
"strict": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
/**
* image-gen is served from inside new-api at `/image-gen/`. In production the
* built `dist/` is embedded in the Go binary and served by the same origin
* as new-api (so /api/* and /v1/* work via session cookies, no CORS).
*
* In dev, you typically run `npm run dev` here and the Vite server proxies
* `/api`, `/mj`, `/pg` to new-api. We default to http://localhost:3000
* (new-api dev backend), but you can override with VITE_NEWAPI_BASE.
*/
const newapiBase = process.env.VITE_NEWAPI_BASE ?? 'http://localhost:3000'
export default defineConfig({
plugins: [vue()],
// The built assets reference absolute paths under /image-gen/ so the
// Go server (which embeds the dist and serves from any /image-gen* URL,
// including the no-slash form that gin-contrib/static may redirect to)
// can always find them at /image-gen/assets/*.
base: '/image-gen/',
server: {
port: 5174,
host: '0.0.0.0',
proxy: {
'/api': { target: newapiBase, changeOrigin: true },
'/v1': { target: newapiBase, changeOrigin: true },
'/mj': { target: newapiBase, changeOrigin: true },
'/pg': { target: newapiBase, changeOrigin: true },
'/assets': { target: newapiBase, changeOrigin: true },
},
},
})