227 lines
6.7 KiB
TypeScript
Vendored
227 lines
6.7 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 { useState, useEffect, useMemo } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { getUserModels } from '@/lib/api'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ComboboxInput } from '@/components/ui/combobox-input'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Label } from '@/components/ui/label'
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
|
|
|
const APP_CONFIGS = {
|
|
claude: {
|
|
label: 'Claude',
|
|
defaultName: 'My Claude',
|
|
modelFields: [
|
|
{ key: 'model', labelKey: 'Primary Model', required: true },
|
|
{ key: 'haikuModel', labelKey: 'Haiku Model', required: false },
|
|
{ key: 'sonnetModel', labelKey: 'Sonnet Model', required: false },
|
|
{ key: 'opusModel', labelKey: 'Opus Model', required: false },
|
|
],
|
|
},
|
|
codex: {
|
|
label: 'Codex',
|
|
defaultName: 'My Codex',
|
|
modelFields: [{ key: 'model', labelKey: 'Primary Model', required: true }],
|
|
},
|
|
gemini: {
|
|
label: 'Gemini',
|
|
defaultName: 'My Gemini',
|
|
modelFields: [{ key: 'model', labelKey: 'Primary Model', required: true }],
|
|
},
|
|
} as const
|
|
|
|
type AppType = keyof typeof APP_CONFIGS
|
|
|
|
function getServerAddress(): string {
|
|
try {
|
|
const raw = localStorage.getItem('status')
|
|
if (raw) {
|
|
const status = JSON.parse(raw)
|
|
if (status.server_address) return status.server_address
|
|
}
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
return window.location.origin
|
|
}
|
|
|
|
function buildCCSwitchURL(
|
|
app: string,
|
|
name: string,
|
|
models: Record<string, string>,
|
|
apiKey: string
|
|
): string {
|
|
const serverAddress = getServerAddress()
|
|
const endpoint = app === 'codex' ? serverAddress + '/v1' : serverAddress
|
|
const params = new URLSearchParams()
|
|
params.set('resource', 'provider')
|
|
params.set('app', app)
|
|
params.set('name', name)
|
|
params.set('endpoint', endpoint)
|
|
params.set('apiKey', apiKey)
|
|
for (const [k, v] of Object.entries(models)) {
|
|
if (v) params.set(k, v)
|
|
}
|
|
params.set('homepage', serverAddress)
|
|
params.set('enabled', 'true')
|
|
return `ccswitch://v1/import?${params.toString()}`
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
tokenKey: string
|
|
}
|
|
|
|
export function CCSwitchDialog(props: Props) {
|
|
const { t } = useTranslation()
|
|
const [app, setApp] = useState<AppType>('claude')
|
|
const [name, setName] = useState<string>(APP_CONFIGS.claude.defaultName)
|
|
const [models, setModels] = useState<Record<string, string>>({})
|
|
|
|
const { data: modelsData } = useQuery({
|
|
queryKey: ['user-models-ccswitch'],
|
|
queryFn: getUserModels,
|
|
enabled: props.open,
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
|
|
const modelOptions = useMemo(() => {
|
|
const items = modelsData?.data ?? []
|
|
return items.map((m) => ({ value: m, label: m }))
|
|
}, [modelsData?.data])
|
|
|
|
useEffect(() => {
|
|
if (props.open) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setModels({})
|
|
|
|
setApp('claude')
|
|
|
|
setName(APP_CONFIGS.claude.defaultName)
|
|
}
|
|
}, [props.open])
|
|
|
|
const currentConfig = APP_CONFIGS[app]
|
|
|
|
const handleAppChange = (val: string) => {
|
|
const appVal = val as AppType
|
|
setApp(appVal)
|
|
setName(APP_CONFIGS[appVal].defaultName)
|
|
setModels({})
|
|
}
|
|
|
|
const handleSubmit = () => {
|
|
if (!models.model) {
|
|
toast.warning(t('Please select a primary model'))
|
|
return
|
|
}
|
|
const key = props.tokenKey.startsWith('sk-')
|
|
? props.tokenKey
|
|
: `sk-${props.tokenKey}`
|
|
const url = buildCCSwitchURL(app, name, models, key)
|
|
window.open(url, '_blank')
|
|
props.onOpenChange(false)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
<DialogContent className='sm:max-w-md'>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('Import to CC Switch')}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className='space-y-4'>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Application')}</Label>
|
|
<RadioGroup
|
|
value={app}
|
|
onValueChange={handleAppChange}
|
|
className='flex gap-4'
|
|
>
|
|
{(
|
|
Object.entries(APP_CONFIGS) as [
|
|
AppType,
|
|
(typeof APP_CONFIGS)[AppType],
|
|
][]
|
|
).map(([key, cfg]) => (
|
|
<div key={key} className='flex items-center gap-2'>
|
|
<RadioGroupItem value={key} id={`app-${key}`} />
|
|
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
|
|
{cfg.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div className='space-y-2'>
|
|
<Label>{t('Name')}</Label>
|
|
<ComboboxInput
|
|
options={[]}
|
|
value={name}
|
|
onValueChange={setName}
|
|
placeholder={currentConfig.defaultName}
|
|
emptyText=''
|
|
allowCustomValue={true}
|
|
/>
|
|
</div>
|
|
|
|
{currentConfig.modelFields.map((field) => (
|
|
<div key={field.key} className='space-y-2'>
|
|
<Label>
|
|
{t(field.labelKey)}
|
|
{field.required && (
|
|
<span className='text-destructive ml-0.5'>*</span>
|
|
)}
|
|
</Label>
|
|
<ComboboxInput
|
|
options={modelOptions}
|
|
value={models[field.key] || ''}
|
|
onValueChange={(v) =>
|
|
setModels((prev) => ({ ...prev, [field.key]: v }))
|
|
}
|
|
placeholder={t('Select or enter model name')}
|
|
emptyText={t('No models found')}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|