mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files. The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
256 lines
9.1 KiB
TypeScript
Vendored
256 lines
9.1 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useEffect, useState } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { Loader2, RefreshCw } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { cn } from '@/lib/utils'
|
|
import { useIsMobile } from '@/hooks/use-mobile'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Label } from '@/components/ui/label'
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { syncUpstream, previewUpstreamDiff } from '../../api'
|
|
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
|
|
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
|
|
import type { SyncLocale, SyncSource } from '../../types'
|
|
import { useModels } from '../models-provider'
|
|
|
|
type SyncWizardDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
|
|
export function SyncWizardDialog({
|
|
open,
|
|
onOpenChange,
|
|
}: SyncWizardDialogProps) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const {
|
|
setOpen,
|
|
setUpstreamConflicts,
|
|
setSyncWizardOptions,
|
|
syncWizardOptions,
|
|
} = useModels()
|
|
const isMobile = useIsMobile()
|
|
const [locale, setLocale] = useState<SyncLocale>('zh')
|
|
const [source, setSource] = useState<SyncSource>('official')
|
|
const [isSyncing, setIsSyncing] = useState(false)
|
|
|
|
// Get translated options
|
|
const SYNC_SOURCE_OPTIONS = getSyncSourceOptions(t)
|
|
const SYNC_LOCALE_OPTIONS = getSyncLocaleOptions(t)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocale(syncWizardOptions.locale || 'zh')
|
|
const preferredSource = SYNC_SOURCE_OPTIONS.find(
|
|
(option) => option.value === syncWizardOptions.source
|
|
)
|
|
setSource(
|
|
preferredSource && !preferredSource.disabled
|
|
? (preferredSource.value as SyncSource)
|
|
: 'official'
|
|
)
|
|
}
|
|
}, [open, syncWizardOptions, SYNC_SOURCE_OPTIONS])
|
|
|
|
const handleSync = async () => {
|
|
setIsSyncing(true)
|
|
try {
|
|
setSyncWizardOptions({ locale, source })
|
|
const previewRes = await previewUpstreamDiff({ locale, source })
|
|
|
|
if (!previewRes.success) {
|
|
throw new Error(previewRes.message || 'Failed to preview upstream diff')
|
|
}
|
|
|
|
const conflicts = previewRes.data?.conflicts || []
|
|
|
|
if (conflicts.length > 0) {
|
|
toast.warning(
|
|
`Found ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}. Please resolve them first.`
|
|
)
|
|
setUpstreamConflicts(conflicts)
|
|
setOpen('upstream-conflict')
|
|
return
|
|
}
|
|
|
|
// No conflicts, proceed with sync
|
|
const response = await syncUpstream({ locale, source })
|
|
|
|
if (response.success) {
|
|
const { created_models, created_vendors, updated_models } =
|
|
response.data || {}
|
|
toast.success(
|
|
`Sync completed! Created ${created_models || 0} models, updated ${updated_models || 0}, and added ${created_vendors || 0} vendors.`
|
|
)
|
|
queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
|
queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() })
|
|
onOpenChange(false)
|
|
} else {
|
|
toast.error(response.message || 'Sync failed')
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error((error as Error)?.message || 'Sync failed')
|
|
} finally {
|
|
setIsSyncing(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
|
|
initialFocus={!isMobile}
|
|
>
|
|
<DialogHeader className='flex-shrink-0 text-start'>
|
|
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('Synchronize models and vendors from an upstream source')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
|
|
<div className='space-y-3'>
|
|
<div>
|
|
<Label className='text-base'>{t('Select Sync Source')}</Label>
|
|
<p className='text-muted-foreground text-sm'>
|
|
{t('Choose where to fetch upstream metadata.')}
|
|
</p>
|
|
</div>
|
|
<RadioGroup
|
|
value={source}
|
|
onValueChange={(value) => {
|
|
const selected = SYNC_SOURCE_OPTIONS.find(
|
|
(option) => option.value === value
|
|
)
|
|
if (!selected || selected.disabled) return
|
|
setSource(selected.value)
|
|
}}
|
|
className='grid gap-3 md:grid-cols-2'
|
|
>
|
|
{SYNC_SOURCE_OPTIONS.map((option) => {
|
|
const isActive = source === option.value
|
|
const isDisabled = option.disabled
|
|
return (
|
|
<Label
|
|
key={option.value}
|
|
htmlFor={`sync-source-${option.value}`}
|
|
className={cn(
|
|
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
|
|
isActive && 'border-primary ring-primary ring-1',
|
|
isDisabled
|
|
? 'cursor-not-allowed opacity-60'
|
|
: 'hover:border-primary/60 cursor-pointer'
|
|
)}
|
|
>
|
|
<div className='flex items-start gap-3'>
|
|
<RadioGroupItem
|
|
value={option.value}
|
|
id={`sync-source-${option.value}`}
|
|
disabled={isDisabled}
|
|
/>
|
|
<div className='space-y-1'>
|
|
<div className='flex items-center gap-2'>
|
|
<span className='font-medium'>{option.label}</span>
|
|
{option.value === 'official' && (
|
|
<StatusBadge
|
|
label='Default'
|
|
variant='neutral'
|
|
copyable={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className='text-muted-foreground text-sm'>
|
|
{option.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Label>
|
|
)
|
|
})}
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className='space-y-2'>
|
|
<Label className='text-base'>{t('Select Language')}</Label>
|
|
<RadioGroup
|
|
value={locale}
|
|
onValueChange={(v) => setLocale(v as SyncLocale)}
|
|
className='grid gap-3 sm:grid-cols-3'
|
|
>
|
|
{SYNC_LOCALE_OPTIONS.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className='flex items-center space-x-2 rounded-lg border p-3'
|
|
>
|
|
<RadioGroupItem
|
|
value={option.value}
|
|
id={`locale-${option.value}`}
|
|
/>
|
|
<Label
|
|
htmlFor={`locale-${option.value}`}
|
|
className='cursor-pointer font-normal'
|
|
>
|
|
{option.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className='bg-muted/50 rounded-lg border p-4'>
|
|
<p className='text-muted-foreground text-sm'>
|
|
{t(
|
|
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isSyncing}
|
|
>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleSync} disabled={isSyncing}>
|
|
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
|
<RefreshCw className='mr-2 h-4 w-4' />
|
|
{isSyncing ? 'Syncing...' : 'Sync Now'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|