mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(provider): support Codex subscriptions with ChatGPT sign-in (#2513)
* feat(provider): support Codex subscriptions with ChatGPT sign-in * style: format Codex live integration test * fix(provider): preserve Codex identity in temporary model tests * fix(web): portal provider selector without dialog overflow * fix(web): allow native scrolling in provider dropdown * fix(provider): surface safe Codex quota and upstream errors * fix(web): provide reliable Codex copy feedback in dialogs * feat(provider): confirm cascade deletion from edit dialog * fix(persistence): discard connections after failed commit * fix(web): polish provider loading and confirmation motion --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -486,6 +486,7 @@ export default function ModelsPanel({
|
||||
// Get the provider info
|
||||
const provider = providers.find((p) => p.uuid === providerUuid);
|
||||
const providerData = {
|
||||
uuid: providerUuid,
|
||||
requester: provider?.requester || '',
|
||||
base_url: provider?.base_url || '',
|
||||
api_keys: provider?.api_keys || [],
|
||||
@@ -495,7 +496,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testLLMModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
abilities,
|
||||
reasoning_config: reasoningConfig,
|
||||
@@ -505,7 +506,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
@@ -513,7 +514,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testRerankModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
@@ -536,6 +537,29 @@ export default function ModelsPanel({
|
||||
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
|
||||
}
|
||||
|
||||
async function handleProviderDeleted(providerUuid: string) {
|
||||
setProviders((prev) =>
|
||||
prev.filter((provider) => provider.uuid !== providerUuid),
|
||||
);
|
||||
setProviderModels((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[providerUuid];
|
||||
return next;
|
||||
});
|
||||
setExpandedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(providerUuid);
|
||||
return next;
|
||||
});
|
||||
await Promise.all([
|
||||
loadProviders(),
|
||||
...Array.from(expandedProviders)
|
||||
.filter((uuid) => uuid !== providerUuid)
|
||||
.map((uuid) => loadProviderModels(uuid)),
|
||||
]);
|
||||
setProviderFormOpen(false);
|
||||
}
|
||||
|
||||
function renderProviderCard(
|
||||
provider: ModelProvider,
|
||||
isLangBotModels: boolean = false,
|
||||
@@ -666,8 +690,14 @@ export default function ModelsPanel({
|
||||
)}
|
||||
</PanelBody>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
|
||||
<Dialog
|
||||
open={providerFormOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleFormClose();
|
||||
else setProviderFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto p-4 sm:max-w-[600px] sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProviderId
|
||||
@@ -675,11 +705,15 @@ export default function ModelsPanel({
|
||||
: t('models.addProvider')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ProviderForm
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={() => setProviderFormOpen(false)}
|
||||
/>
|
||||
{providerFormOpen && (
|
||||
<ProviderForm
|
||||
key={editingProviderId || 'new'}
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={handleFormClose}
|
||||
onProviderDeleted={canManage ? handleProviderDeleted : undefined}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { useCodexLogin } from './useCodexLogin';
|
||||
|
||||
export default function CodexAccountSection({
|
||||
login,
|
||||
providerId,
|
||||
}: {
|
||||
login: ReturnType<typeof useCodexLogin>;
|
||||
providerId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const { phase, device } = login;
|
||||
const copyGeneration = useRef(0);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const generation = copyGeneration;
|
||||
setCopied(false);
|
||||
setCopyFailed(false);
|
||||
return () => {
|
||||
generation.current++;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, [providerId, device?.authorization_id, device?.user_code, phase]);
|
||||
const handleCopy = async () => {
|
||||
if (!device) return;
|
||||
const generation = ++copyGeneration.current;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await copyToClipboard(device.user_code);
|
||||
} catch {
|
||||
// Clipboard failures are recoverable; never log device codes.
|
||||
}
|
||||
if (generation !== copyGeneration.current) return;
|
||||
setCopied(ok);
|
||||
setCopyFailed(!ok);
|
||||
if (ok) {
|
||||
toast.success(t('common.copySuccess'));
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
};
|
||||
const waiting = ['starting', 'loading', 'canceling'].includes(phase);
|
||||
return (
|
||||
<section
|
||||
data-testid="codex-account"
|
||||
aria-label={t('models.codex.account')}
|
||||
className="min-w-0 rounded-lg border p-3 space-y-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">{t('models.codex.account')}</h3>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('models.codex.description')}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
role={phase === 'error' ? 'alert' : 'status'}
|
||||
aria-live="polite"
|
||||
className={
|
||||
phase === 'error' ? 'text-destructive' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{t(`models.codex.${phase}`)}
|
||||
</p>
|
||||
{device && phase === 'pending' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
{t('models.codex.instructions')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="select-all break-all rounded border bg-muted px-3 py-2 text-base font-semibold tracking-wider">
|
||||
{device.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{t(copied ? 'models.codex.copied' : 'models.codex.copyCode')}
|
||||
</Button>
|
||||
</div>
|
||||
{copyFailed && (
|
||||
<p role="status" className="text-muted-foreground">
|
||||
{t('models.codex.copyManually')}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={device.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t('models.codex.continueAtOpenAI')}
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.codex.expiresAt', {
|
||||
time: new Date(device.expires_at * 1000).toLocaleTimeString(),
|
||||
})}
|
||||
</p>
|
||||
{login.retrying && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{t('models.codex.retrying')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => providerId && void login.cancel(providerId)}
|
||||
>
|
||||
{t('models.codex.cancelSignIn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{providerId && !waiting && phase !== 'pending' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phase !== 'connected' && (
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
{t(
|
||||
phase === 'error' || phase === 'expired'
|
||||
? 'models.codex.tryAgain'
|
||||
: 'models.codex.signIn',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'connected' && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
void login.start(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.reconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDisconnect(true)}
|
||||
>
|
||||
{t('models.codex.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmDisconnect && phase === 'connected' && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p>{t('models.codex.disconnectConfirm')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
if (providerId) void login.disconnect(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.confirmDisconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+380
-145
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -16,12 +16,31 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { providerPayload } from './codexPolicy';
|
||||
import { useCodexLogin } from './useCodexLogin';
|
||||
import CodexAccountSection from './CodexAccountSection';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -35,12 +54,14 @@ interface ProviderFormProps {
|
||||
providerId?: string;
|
||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||
onFormCancel: () => void;
|
||||
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ProviderForm({
|
||||
providerId,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
onProviderDeleted,
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -54,7 +75,31 @@ export default function ProviderForm({
|
||||
api_key: '',
|
||||
},
|
||||
});
|
||||
const { setValue } = form;
|
||||
const { reset } = form;
|
||||
const isCodex = form.watch('requester') === 'openai-codex';
|
||||
const [savedProviderId, setSavedProviderId] = useState(providerId);
|
||||
const savedId = useRef(providerId);
|
||||
const submitting = useRef(false);
|
||||
const deleting = useRef(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
const login = useCodexLogin(isCodex, providerId);
|
||||
const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes(
|
||||
login.phase,
|
||||
);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [requesterList, setRequesterList] = useState<
|
||||
{
|
||||
@@ -68,72 +113,59 @@ export default function ProviderForm({
|
||||
>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadRequesters = useCallback(async () => {
|
||||
const resp = await httpClient.getProviderRequesters();
|
||||
setRequesterList(
|
||||
resp.requesters
|
||||
.filter((item) => item.name !== 'space-chat-completions')
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadProvider = useCallback(
|
||||
async (id: string) => {
|
||||
const resp = await httpClient.getModelProvider(id);
|
||||
const provider = resp.provider;
|
||||
|
||||
setValue('name', provider.name);
|
||||
setValue('requester', provider.requester);
|
||||
setValue('base_url', provider.base_url);
|
||||
setValue('api_key', provider.api_keys?.[0] || '');
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Ignore both success and failure from a closed form or superseded attempt.
|
||||
let canceled = false;
|
||||
setLoadState('loading');
|
||||
setMutableProviderLoaded(false);
|
||||
|
||||
async function init() {
|
||||
await loadRequesters();
|
||||
if (providerId) {
|
||||
await loadProvider(providerId);
|
||||
try {
|
||||
const [requesters, detail] = await Promise.all([
|
||||
httpClient.getProviderRequesters(),
|
||||
providerId ? httpClient.getModelProvider(providerId) : null,
|
||||
]);
|
||||
if (canceled) return;
|
||||
setRequesterList(
|
||||
requesters.requesters
|
||||
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
if (detail) {
|
||||
const provider = detail.provider;
|
||||
reset({
|
||||
name: provider.name,
|
||||
requester: provider.requester,
|
||||
base_url: provider.base_url,
|
||||
api_key: provider.api_keys?.[0] || '',
|
||||
});
|
||||
setMutableProviderLoaded(
|
||||
provider.uuid === providerId &&
|
||||
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
);
|
||||
}
|
||||
setLoadState('ready');
|
||||
} catch {
|
||||
if (!canceled) setLoadState('error');
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [providerId, loadProvider, loadRequesters]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
void init();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [providerId, reset, loadAttempt]);
|
||||
|
||||
// Filter requesters based on search query
|
||||
const filteredRequesters = requesterList.filter(
|
||||
@@ -163,29 +195,105 @@ export default function ProviderForm({
|
||||
};
|
||||
|
||||
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const data = {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: values.base_url,
|
||||
api_keys: values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
submitting.current ||
|
||||
deleting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
submitting.current = true;
|
||||
const data = providerPayload(values);
|
||||
try {
|
||||
let savedProviderUuid = providerId;
|
||||
if (providerId) {
|
||||
await httpClient.updateModelProvider(providerId, data);
|
||||
toast.success(t('models.providerSaved'));
|
||||
if (savedId.current) {
|
||||
await httpClient.updateModelProvider(savedId.current, data);
|
||||
} else {
|
||||
const response = await httpClient.createModelProvider(data);
|
||||
savedProviderUuid = response.uuid;
|
||||
toast.success(t('models.providerCreated'));
|
||||
savedId.current = response.uuid;
|
||||
if (mounted.current) setSavedProviderId(response.uuid);
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
if (isCodex && login.phase !== 'connected') {
|
||||
await login.start(savedId.current);
|
||||
} else {
|
||||
toast.success(t('models.providerSaved'));
|
||||
await onFormSubmit(savedId.current);
|
||||
}
|
||||
await onFormSubmit(savedProviderUuid as string);
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||
if (mounted.current)
|
||||
toast.error(
|
||||
t('models.providerSaveError') + (err as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
!providerId ||
|
||||
!mutableProviderLoaded ||
|
||||
!onProviderDeleted ||
|
||||
deleting.current ||
|
||||
submitting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
deleting.current = true;
|
||||
setIsDeleting(true);
|
||||
setDeleteError('');
|
||||
try {
|
||||
await httpClient.deleteModelProvider(providerId, true);
|
||||
} catch (err) {
|
||||
const detail =
|
||||
(err as CustomApiError | null)?.msg ||
|
||||
(err instanceof Error ? err.message : '');
|
||||
setDeleteError(t('models.providerDeleteError') + detail);
|
||||
deleting.current = false;
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t('models.providerDeleted'));
|
||||
await onProviderDeleted(providerId);
|
||||
}
|
||||
|
||||
if (loadState !== 'ready') {
|
||||
return (
|
||||
<>
|
||||
{loadState === 'loading' ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('common.loading')}
|
||||
className="flex justify-center py-8"
|
||||
>
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="py-8 text-sm text-destructive">
|
||||
{t('models.loadError')}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{loadState === 'error' && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadState('loading');
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -202,7 +310,12 @@ export default function ProviderForm({
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -222,45 +335,65 @@ export default function ProviderForm({
|
||||
{t('models.requester')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setSearchQuery('');
|
||||
}}
|
||||
>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && (!!savedProviderId || loginActive))
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Dropdown */}
|
||||
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={8}
|
||||
className="flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-16px)] flex-col overflow-hidden p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b px-3">
|
||||
<div className="flex shrink-0 items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -275,7 +408,13 @@ export default function ProviderForm({
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||
<div
|
||||
className="min-h-0 max-h-[300px] overflow-y-auto overscroll-contain p-1"
|
||||
// The dialog's document-level scroll lock treats this portal as outside.
|
||||
// Keep native list scrolling without forwarding gestures to that lock.
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onTouchMove={(event) => event.stopPropagation()}
|
||||
>
|
||||
{Object.entries(groupedRequesters).map(
|
||||
([category, items]) => {
|
||||
if (items.length === 0) return null;
|
||||
@@ -288,6 +427,11 @@ export default function ProviderForm({
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
disabled={
|
||||
!!providerId &&
|
||||
r.value === 'openai-codex' &&
|
||||
!isCodex
|
||||
}
|
||||
onClick={() => {
|
||||
field.onChange(r.value);
|
||||
const req = requesterList.find(
|
||||
@@ -337,9 +481,9 @@ export default function ProviderForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
{selectedRequester?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -351,40 +495,131 @@ export default function ProviderForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCodex ? (
|
||||
<CodexAccountSection login={login} providerId={savedProviderId} />
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t('common.save')}</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
|
||||
{providerId && mutableProviderLoaded && onProviderDeleted && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
onClick={() => {
|
||||
setDeleteError('');
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex flex-col gap-2 sm:flex-row">
|
||||
{(!isCodex || !savedProviderId || login.phase === 'connected') && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
>
|
||||
{isCodex
|
||||
? t(
|
||||
login.phase === 'connected'
|
||||
? 'models.codex.done'
|
||||
: 'models.codex.saveAndSignIn',
|
||||
)
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDeleting}
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!deleting.current) setDeleteConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
{deleteConfirmOpen && (
|
||||
<AlertDialogContent className="max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('common.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('models.deleteProviderCascadeConfirmation')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm text-destructive break-words"
|
||||
>
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
aria-busy={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
)}
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Subscription credentials are server-owned, never API-key form values. */
|
||||
export function providerPayload(values: {
|
||||
name: string;
|
||||
requester: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
}) {
|
||||
const subscription = values.requester === 'openai-codex';
|
||||
return {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: subscription
|
||||
? 'https://chatgpt.com/backend-api/codex'
|
||||
: values.base_url,
|
||||
api_keys: subscription ? [] : values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function pollDelay(interval: number, failures = 0): number {
|
||||
const seconds = Number.isFinite(interval) && interval > 0 ? interval : 5;
|
||||
return Math.max(seconds, Math.min(60, seconds * 2 ** failures)) * 1000;
|
||||
}
|
||||
|
||||
export function isCodexVerificationUri(uri: string): boolean {
|
||||
return uri === 'https://auth.openai.com/codex/device';
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { CodexDeviceAuthorization } from '@/app/infra/entities/codex';
|
||||
import { isCodexVerificationUri, pollDelay } from './codexPolicy';
|
||||
|
||||
type Phase =
|
||||
| 'disconnected'
|
||||
| 'loading'
|
||||
| 'starting'
|
||||
| 'pending'
|
||||
| 'connected'
|
||||
| 'expired'
|
||||
| 'error'
|
||||
| 'canceling';
|
||||
|
||||
/** One in-memory authorization, sequential polls, and stale-response fencing. */
|
||||
export function useCodexLogin(enabled: boolean, providerId?: string) {
|
||||
const [phase, setPhase] = useState<Phase>('disconnected');
|
||||
const [device, setDevice] = useState<CodexDeviceAuthorization | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const attempt = useRef<{ uuid: string; authorizationId: string } | null>(
|
||||
null,
|
||||
);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const deadline = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
generation.current++;
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(deadline.current);
|
||||
request.current?.abort();
|
||||
busy.current = false;
|
||||
const pending = attempt.current;
|
||||
attempt.current = null;
|
||||
return pending;
|
||||
}, []);
|
||||
|
||||
const clearPending = useCallback(async () => {
|
||||
const pending = stop();
|
||||
if (pending)
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
pending.uuid,
|
||||
pending.authorizationId,
|
||||
);
|
||||
}, [stop]);
|
||||
|
||||
const loadStatus = useCallback(async (uuid: string) => {
|
||||
const version = generation.current;
|
||||
request.current = new AbortController();
|
||||
setPhase('loading');
|
||||
try {
|
||||
const status = await httpClient.getCodexAuthStatus(
|
||||
uuid,
|
||||
request.current.signal,
|
||||
);
|
||||
if (version === generation.current) setPhase(status.status);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDevice(null);
|
||||
setPhase('disconnected');
|
||||
if (enabled && providerId) void loadStatus(providerId);
|
||||
return () => {
|
||||
// Device creation is deliberately not aborted: its late response must be
|
||||
// canceled server-side even if this form has already unmounted.
|
||||
void clearPending().catch(() => {});
|
||||
};
|
||||
}, [enabled, providerId, loadStatus, clearPending]);
|
||||
|
||||
async function start(uuid: string) {
|
||||
if (busy.current) return;
|
||||
const old = stop();
|
||||
busy.current = true;
|
||||
const version = generation.current;
|
||||
setPhase('starting');
|
||||
setDevice(null);
|
||||
setRetrying(false);
|
||||
try {
|
||||
if (old)
|
||||
await httpClient.cancelCodexDeviceLogin(old.uuid, old.authorizationId);
|
||||
if (version !== generation.current) return;
|
||||
const authorization = await httpClient.startCodexDeviceLogin(uuid);
|
||||
if (version !== generation.current) {
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
attempt.current = {
|
||||
uuid,
|
||||
authorizationId: authorization.authorization_id,
|
||||
};
|
||||
if (
|
||||
!isCodexVerificationUri(authorization.verification_uri) ||
|
||||
!Number.isFinite(authorization.expires_at)
|
||||
) {
|
||||
await clearPending();
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setDevice(authorization);
|
||||
setPhase('pending');
|
||||
let interval = authorization.interval;
|
||||
let failures = 0;
|
||||
request.current = new AbortController();
|
||||
const signal = request.current.signal;
|
||||
const expire = () => {
|
||||
if (version !== generation.current) return;
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('expired');
|
||||
};
|
||||
deadline.current = setTimeout(
|
||||
expire,
|
||||
Math.max(0, authorization.expires_at * 1000 - Date.now()),
|
||||
);
|
||||
const poll = async () => {
|
||||
if (version !== generation.current) return;
|
||||
if (Date.now() >= authorization.expires_at * 1000) {
|
||||
expire();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await httpClient.pollCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
signal,
|
||||
);
|
||||
if (version !== generation.current) return;
|
||||
if (result.status !== 'pending') {
|
||||
attempt.current = null;
|
||||
stop();
|
||||
setDevice(null);
|
||||
setPhase(result.status);
|
||||
return;
|
||||
}
|
||||
interval = result.interval ?? interval;
|
||||
failures = 0;
|
||||
setRetrying(false);
|
||||
} catch (error) {
|
||||
if (version !== generation.current) return;
|
||||
const code = (error as { code?: number }).code;
|
||||
if (
|
||||
(code === -1 || (code !== undefined && code >= 500)) &&
|
||||
failures < 3
|
||||
) {
|
||||
failures++;
|
||||
setRetrying(true);
|
||||
} else {
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
timer.current = setTimeout(poll, pollDelay(interval, failures));
|
||||
};
|
||||
timer.current = setTimeout(poll, pollDelay(interval));
|
||||
} catch {
|
||||
if (version === generation.current) {
|
||||
busy.current = false;
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(uuid: string) {
|
||||
setPhase('canceling');
|
||||
setDevice(null);
|
||||
const pending = clearPending();
|
||||
const version = generation.current;
|
||||
try {
|
||||
await pending;
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(uuid: string) {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setPhase('loading');
|
||||
const version = generation.current;
|
||||
try {
|
||||
await httpClient.disconnectCodex(uuid);
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { phase, device, retrying, start, cancel, disconnect, loadStatus };
|
||||
}
|
||||
Reference in New Issue
Block a user