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:
Hyu
2026-09-06 23:31:02 +08:00
committed by GitHub
parent ec63978ecf
commit 0f216a0d4d
52 changed files with 5490 additions and 360 deletions
@@ -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>
</>
@@ -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>
);
}
@@ -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 };
}
+19
View File
@@ -0,0 +1,19 @@
/** Public device-login responses only. OAuth credentials stay on the server. */
export interface CodexAuthStatus {
status: 'connected' | 'disconnected' | 'expired';
connected: boolean;
expires_at: number | null;
}
export interface CodexDeviceAuthorization {
authorization_id: string;
user_code: string;
verification_uri: string;
interval: number;
expires_at: number;
}
export interface CodexDevicePoll {
status: 'pending' | 'connected' | 'expired';
interval?: number;
}
+51 -2
View File
@@ -1,4 +1,9 @@
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import type {
CodexAuthStatus,
CodexDeviceAuthorization,
CodexDevicePoll,
} from '@/app/infra/entities/codex';
import {
ApiRespProviderRequesters,
ApiRespProviderRequester,
@@ -126,8 +131,52 @@ export class BackendClient extends BaseHttpClient {
return this.put(`/api/v1/provider/providers/${uuid}`, provider);
}
public deleteModelProvider(uuid: string): Promise<object> {
return this.delete(`/api/v1/provider/providers/${uuid}`);
public deleteModelProvider(uuid: string, cascade = false): Promise<object> {
return this.delete(
`/api/v1/provider/providers/${uuid}${cascade ? '?cascade=true' : ''}`,
);
}
public getCodexAuthStatus(
uuid: string,
signal?: AbortSignal,
): Promise<CodexAuthStatus> {
return this.get(
`/api/v1/provider/providers/${uuid}/codex/status`,
undefined,
{ signal },
);
}
public startCodexDeviceLogin(
uuid: string,
): Promise<CodexDeviceAuthorization> {
return this.post(`/api/v1/provider/providers/${uuid}/codex/device`, {});
}
public pollCodexDeviceLogin(
uuid: string,
authorizationId: string,
signal?: AbortSignal,
): Promise<CodexDevicePoll> {
return this.post(
`/api/v1/provider/providers/${uuid}/codex/device/poll`,
{ authorization_id: authorizationId },
{ signal },
);
}
public cancelCodexDeviceLogin(
uuid: string,
authorizationId: string,
): Promise<object> {
return this.delete(
`/api/v1/provider/providers/${uuid}/codex/device/${encodeURIComponent(authorizationId)}`,
);
}
public disconnectCodex(uuid: string): Promise<object> {
return this.delete(`/api/v1/provider/providers/${uuid}/codex/auth`);
}
public scanProviderModels(
+24 -24
View File
@@ -1,39 +1,39 @@
/**
* Copy text to clipboard with fallback support
* Tries to use modern Clipboard API first, falls back to execCommand if not available
*
* @param text - The text to copy to clipboard
* @returns Promise<boolean> - true if successful, false otherwise
*/
/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */
export async function copyToClipboard(text: string): Promise<boolean> {
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('[Clipboard] Modern API failed, trying fallback:', err);
// Fall through to legacy method
}
} catch {
// Permission/security errors can include sensitive text; do not log them.
}
// Fallback to legacy execCommand method
const previousFocus = document.activeElement as HTMLElement | null;
const textArea = document.createElement('textarea');
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
// Radix modal focus scopes reject focus on elements appended to body.
const container =
previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ??
document.body;
container.appendChild(textArea);
textArea.focus({ preventScroll: true });
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('[Clipboard] Fallback method failed:', err);
if (
document.activeElement !== textArea ||
textArea.selectionEnd !== text.length
)
return false;
return document.execCommand('copy');
} catch {
return false;
} finally {
textArea.remove();
if (previousFocus?.isConnected)
previousFocus.focus({ preventScroll: true });
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className,
)}
{...props}
+33
View File
@@ -182,6 +182,37 @@ const enUS = {
help: 'Get Help',
},
models: {
codex: {
account: 'ChatGPT subscription',
description:
'Sign in with your ChatGPT account. Subscription access is separate from OpenAI API billing; model availability and usage limits depend on your plan.',
disconnected: 'Not connected',
loading: 'Checking connection…',
starting: 'Starting sign-in…',
pending: 'Waiting for authorization',
connected: 'Connected',
expired: 'Sign-in expired. Start again to get a new code.',
error: 'Unable to sign in. Check your connection and try again.',
canceling: 'Canceling sign-in…',
saveAndSignIn: 'Save and sign in',
done: 'Done',
instructions:
'Enter this code on the OpenAI page. Keep this dialog open until sign-in completes.',
copyCode: 'Copy code',
copied: 'Copied',
copyManually: 'Select and copy the code manually.',
continueAtOpenAI: 'Continue at OpenAI',
expiresAt: 'Code expires at {{time}}.',
retrying: 'Connection interrupted. Retrying automatically…',
cancelSignIn: 'Cancel sign-in',
tryAgain: 'Try again',
signIn: 'Sign in',
reconnect: 'Reconnect',
disconnect: 'Disconnect',
disconnectConfirm:
'Disconnect this provider? Its models will stop working until you sign in again. This does not cancel your ChatGPT subscription.',
confirmDisconnect: 'Confirm disconnect',
},
title: 'Models',
description: 'Configure and manage models that can be used in pipelines',
createModel: 'Create Model',
@@ -316,6 +347,8 @@ const enUS = {
providerSaveError: 'Failed to save provider: ',
providerDeleted: 'Provider deleted',
providerDeleteError: 'Failed to delete provider: ',
deleteProviderCascadeConfirmation:
'Delete this provider and ALL models it contains? This action is irreversible and cannot be undone.',
deleteProviderConfirmation:
'Are you sure you want to delete this provider?',
loadError: 'Failed to load data',
+34
View File
@@ -187,6 +187,38 @@ const esES = {
help: 'Obtener ayuda',
},
models: {
codex: {
account: 'Suscripción de ChatGPT',
description:
'Inicia sesión con tu cuenta de ChatGPT. La suscripción es independiente de la facturación de la API de OpenAI; los modelos y límites dependen de tu plan.',
disconnected: 'Sin conexión',
loading: 'Comprobando conexión…',
starting: 'Iniciando sesión…',
pending: 'Esperando autorización',
connected: 'Conectado',
expired: 'El inicio de sesión ha caducado. Solicita un nuevo código.',
error:
'No se pudo iniciar sesión. Comprueba la conexión e inténtalo de nuevo.',
canceling: 'Cancelando inicio de sesión…',
saveAndSignIn: 'Guardar e iniciar sesión',
done: 'Listo',
instructions:
'Introduce este código en la página de OpenAI. Mantén este diálogo abierto hasta completar el inicio de sesión.',
copyCode: 'Copiar código',
copied: 'Copiado',
copyManually: 'Selecciona y copia el código manualmente.',
continueAtOpenAI: 'Continuar en OpenAI',
expiresAt: 'El código caduca a las {{time}}.',
retrying: 'Conexión interrumpida. Reintentando automáticamente…',
cancelSignIn: 'Cancelar inicio de sesión',
tryAgain: 'Reintentar',
signIn: 'Iniciar sesión',
reconnect: 'Reconectar',
disconnect: 'Desconectar',
disconnectConfirm:
'¿Desconectar este proveedor? Sus modelos dejarán de funcionar hasta que vuelvas a iniciar sesión. Esto no cancela tu suscripción de ChatGPT.',
confirmDisconnect: 'Confirmar desconexión',
},
title: 'Modelos',
description:
'Configura y gestiona los modelos que se pueden usar en los Pipelines',
@@ -324,6 +356,8 @@ const esES = {
providerSaveError: 'Error al guardar el proveedor: ',
providerDeleted: 'Proveedor eliminado',
providerDeleteError: 'Error al eliminar el proveedor: ',
deleteProviderCascadeConfirmation:
'¿Eliminar este proveedor y TODOS los modelos que contiene? Esta acción es irreversible y no se puede deshacer.',
deleteProviderConfirmation:
'¿Estás seguro de que deseas eliminar este proveedor?',
loadError: 'Error al cargar datos',
+34
View File
@@ -185,6 +185,38 @@ const jaJP = {
help: 'ヘルプドキュメントを見る',
},
models: {
codex: {
account: 'ChatGPT サブスクリプション',
description:
'ChatGPT アカウントでログインします。サブスクリプションと OpenAI API の課金は別です。利用可能なモデルと使用制限はプランによって異なります。',
disconnected: '未接続',
loading: '接続を確認中…',
starting: 'ログインを開始中…',
pending: '認証を待機中',
connected: '接続済み',
expired:
'ログインの有効期限が切れました。新しいコードを取得してください。',
error: 'ログインできません。接続を確認して再試行してください。',
canceling: 'ログインをキャンセル中…',
saveAndSignIn: '保存してログイン',
done: '完了',
instructions:
'OpenAI のページでこのコードを入力してください。ログインが完了するまでこの画面を開いたままにしてください。',
copyCode: 'コードをコピー',
copied: 'コピー済み',
copyManually: 'コードを選択して手動でコピーしてください。',
continueAtOpenAI: 'OpenAI で続行',
expiresAt: 'コードの有効期限: {{time}}',
retrying: '接続が切れました。自動的に再試行しています…',
cancelSignIn: 'ログインをキャンセル',
tryAgain: '再試行',
signIn: 'ログイン',
reconnect: '再接続',
disconnect: '切断',
disconnectConfirm:
'このプロバイダーを切断しますか?再ログインするまでモデルは使用できません。ChatGPT のサブスクリプションは解約されません。',
confirmDisconnect: '切断を確認',
},
title: 'モデル設定',
description: 'パイプラインで使用できるモデルを設定・管理',
createModel: 'モデルを作成',
@@ -322,6 +354,8 @@ const jaJP = {
providerSaveError: 'プロバイダーの保存に失敗しました:',
providerDeleted: 'プロバイダーを削除しました',
providerDeleteError: 'プロバイダーの削除に失敗しました:',
deleteProviderCascadeConfirmation:
'このプロバイダーと、その中のすべてのモデルを削除しますか?この操作は取り消せず、元に戻せません。',
deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか?',
loadError: 'データの読み込みに失敗しました',
chat: 'チャット',
+33
View File
@@ -184,6 +184,37 @@ const ruRU = {
help: 'Помощь',
},
models: {
codex: {
account: 'Подписка ChatGPT',
description:
'Войдите в аккаунт ChatGPT. Подписка не связана с оплатой API OpenAI; доступные модели и лимиты зависят от тарифа.',
disconnected: 'Не подключено',
loading: 'Проверка подключения…',
starting: 'Начало входа…',
pending: 'Ожидание авторизации',
connected: 'Подключено',
expired: 'Срок входа истёк. Получите новый код.',
error: 'Не удалось войти. Проверьте подключение и повторите попытку.',
canceling: 'Отмена входа…',
saveAndSignIn: 'Сохранить и войти',
done: 'Готово',
instructions:
'Введите этот код на странице OpenAI. Не закрывайте это окно до завершения входа.',
copyCode: 'Копировать код',
copied: 'Скопировано',
copyManually: 'Выделите и скопируйте код вручную.',
continueAtOpenAI: 'Продолжить в OpenAI',
expiresAt: 'Код действителен до {{time}}.',
retrying: 'Соединение прервано. Автоматическая повторная попытка…',
cancelSignIn: 'Отменить вход',
tryAgain: 'Повторить',
signIn: 'Войти',
reconnect: 'Переподключить',
disconnect: 'Отключить',
disconnectConfirm:
'Отключить этого провайдера? Его модели перестанут работать до повторного входа. Подписка ChatGPT не будет отменена.',
confirmDisconnect: 'Подтвердить отключение',
},
title: 'Модели',
description: 'Настройка и управление моделями, используемыми в конвейерах',
createModel: 'Создать модель',
@@ -322,6 +353,8 @@ const ruRU = {
providerSaveError: 'Ошибка сохранения провайдера: ',
providerDeleted: 'Провайдер удалён',
providerDeleteError: 'Ошибка удаления провайдера: ',
deleteProviderCascadeConfirmation:
'Удалить этого провайдера и ВСЕ содержащиеся в нём модели? Это действие необратимо, его нельзя отменить.',
deleteProviderConfirmation:
'Вы уверены, что хотите удалить этого провайдера?',
loadError: 'Не удалось загрузить данные',
+33
View File
@@ -181,6 +181,37 @@ const thTH = {
help: 'ขอความช่วยเหลือ',
},
models: {
codex: {
account: 'การสมัครสมาชิก ChatGPT',
description:
'ลงชื่อเข้าใช้ด้วยบัญชี ChatGPT การใช้งานผ่านการสมัครสมาชิกแยกจากการเรียกเก็บเงิน OpenAI API รุ่นโมเดลและขีดจำกัดการใช้งานขึ้นอยู่กับแพ็กเกจของคุณ',
disconnected: 'ยังไม่ได้เชื่อมต่อ',
loading: 'กำลังตรวจสอบการเชื่อมต่อ…',
starting: 'กำลังเริ่มลงชื่อเข้าใช้…',
pending: 'กำลังรอการอนุญาต',
connected: 'เชื่อมต่อแล้ว',
expired: 'การลงชื่อเข้าใช้หมดอายุ เริ่มใหม่เพื่อรับรหัสใหม่',
error: 'ไม่สามารถลงชื่อเข้าใช้ได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง',
canceling: 'กำลังยกเลิกการลงชื่อเข้าใช้…',
saveAndSignIn: 'บันทึกและลงชื่อเข้าใช้',
done: 'เสร็จสิ้น',
instructions:
'ป้อนรหัสนี้บนหน้า OpenAI เปิดกล่องโต้ตอบนี้ไว้จนกว่าจะลงชื่อเข้าใช้เสร็จ',
copyCode: 'คัดลอกรหัส',
copied: 'คัดลอกแล้ว',
copyManually: 'เลือกรหัสและคัดลอกด้วยตนเอง',
continueAtOpenAI: 'ดำเนินการต่อที่ OpenAI',
expiresAt: 'รหัสหมดอายุเวลา {{time}}',
retrying: 'การเชื่อมต่อขัดข้อง กำลังลองใหม่โดยอัตโนมัติ…',
cancelSignIn: 'ยกเลิกการลงชื่อเข้าใช้',
tryAgain: 'ลองอีกครั้ง',
signIn: 'ลงชื่อเข้าใช้',
reconnect: 'เชื่อมต่อใหม่',
disconnect: 'ยกเลิกการเชื่อมต่อ',
disconnectConfirm:
'ยกเลิกการเชื่อมต่อผู้ให้บริการนี้หรือไม่? โมเดลจะหยุดทำงานจนกว่าคุณจะลงชื่อเข้าใช้อีกครั้ง การดำเนินการนี้ไม่ได้ยกเลิกการสมัครสมาชิก ChatGPT',
confirmDisconnect: 'ยืนยันการยกเลิกการเชื่อมต่อ',
},
title: 'โมเดล',
description: 'กำหนดค่าและจัดการโมเดลที่สามารถใช้ใน Pipeline',
createModel: 'สร้างโมเดล',
@@ -310,6 +341,8 @@ const thTH = {
providerSaveError: 'บันทึกผู้ให้บริการล้มเหลว: ',
providerDeleted: 'ลบผู้ให้บริการแล้ว',
providerDeleteError: 'ลบผู้ให้บริการล้มเหลว: ',
deleteProviderCascadeConfirmation:
'ลบผู้ให้บริการนี้และโมเดลทั้งหมดที่อยู่ภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับหรือยกเลิกได้',
deleteProviderConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการนี้?',
loadError: 'โหลดข้อมูลล้มเหลว',
chat: 'แชท',
+33
View File
@@ -184,6 +184,37 @@ const viVN = {
help: 'Trợ giúp',
},
models: {
codex: {
account: 'Gói đăng ký ChatGPT',
description:
'Đăng nhập bằng tài khoản ChatGPT. Gói đăng ký độc lập với thanh toán API OpenAI; mô hình và giới hạn sử dụng tùy thuộc vào gói của bạn.',
disconnected: 'Chưa kết nối',
loading: 'Đang kiểm tra kết nối…',
starting: 'Đang bắt đầu đăng nhập…',
pending: 'Đang chờ cấp quyền',
connected: 'Đã kết nối',
expired: 'Phiên đăng nhập đã hết hạn. Hãy lấy mã mới.',
error: 'Không thể đăng nhập. Kiểm tra kết nối và thử lại.',
canceling: 'Đang hủy đăng nhập…',
saveAndSignIn: 'Lưu và đăng nhập',
done: 'Xong',
instructions:
'Nhập mã này trên trang OpenAI. Giữ hộp thoại này mở cho đến khi đăng nhập hoàn tất.',
copyCode: 'Sao chép mã',
copied: 'Đã sao chép',
copyManually: 'Chọn và sao chép mã thủ công.',
continueAtOpenAI: 'Tiếp tục tại OpenAI',
expiresAt: 'Mã hết hạn lúc {{time}}.',
retrying: 'Kết nối bị gián đoạn. Đang tự động thử lại…',
cancelSignIn: 'Hủy đăng nhập',
tryAgain: 'Thử lại',
signIn: 'Đăng nhập',
reconnect: 'Kết nối lại',
disconnect: 'Ngắt kết nối',
disconnectConfirm:
'Ngắt kết nối nhà cung cấp này? Các mô hình sẽ ngừng hoạt động cho đến khi bạn đăng nhập lại. Thao tác này không hủy gói ChatGPT của bạn.',
confirmDisconnect: 'Xác nhận ngắt kết nối',
},
title: 'Mô hình',
description:
'Cấu hình và quản lý các mô hình có thể sử dụng trong Pipeline',
@@ -318,6 +349,8 @@ const viVN = {
providerSaveError: 'Lưu nhà cung cấp thất bại: ',
providerDeleted: 'Đã xóa nhà cung cấp',
providerDeleteError: 'Xóa nhà cung cấp thất bại: ',
deleteProviderCascadeConfirmation:
'Xóa nhà cung cấp này và TẤT CẢ mô hình bên trong? Hành động này không thể đảo ngược hoặc hoàn tác.',
deleteProviderConfirmation:
'Bạn có chắc chắn muốn xóa nhà cung cấp này không?',
loadError: 'Tải dữ liệu thất bại',
+33
View File
@@ -172,6 +172,37 @@ const zhHans = {
help: '查看帮助文档',
},
models: {
codex: {
account: 'ChatGPT 订阅',
description:
'使用 ChatGPT 账号登录。订阅权限与 OpenAI API 计费相互独立,可用模型和使用额度取决于你的订阅方案。',
disconnected: '未连接',
loading: '正在检查连接…',
starting: '正在开始登录…',
pending: '等待授权',
connected: '已连接',
expired: '登录已过期,请重试以获取新验证码。',
error: '无法登录,请检查网络连接后重试。',
canceling: '正在取消登录…',
saveAndSignIn: '保存并登录',
done: '完成',
instructions:
'在 OpenAI 页面输入此验证码,登录完成前请保持此对话框打开。',
copyCode: '复制验证码',
copied: '已复制',
copyManually: '请选中并手动复制验证码。',
continueAtOpenAI: '前往 OpenAI 继续',
expiresAt: '验证码将于 {{time}} 过期。',
retrying: '连接中断,正在自动重试…',
cancelSignIn: '取消登录',
tryAgain: '重试',
signIn: '登录',
reconnect: '重新连接',
disconnect: '断开连接',
disconnectConfirm:
'断开此供应商的连接?重新登录前,其模型将无法使用。此操作不会取消你的 ChatGPT 订阅。',
confirmDisconnect: '确认断开',
},
title: '模型配置',
description: '配置和管理可在流水线中使用的模型',
createModel: '创建对话模型',
@@ -302,6 +333,8 @@ const zhHans = {
providerSaveError: '保存供应商失败:',
providerDeleted: '供应商已删除',
providerDeleteError: '删除供应商失败:',
deleteProviderCascadeConfirmation:
'确定删除此供应商及其包含的所有模型吗?此操作不可逆,无法撤销。',
deleteProviderConfirmation: '你确定要删除这个供应商吗?',
loadError: '加载数据失败',
chat: '对话',
+33
View File
@@ -173,6 +173,37 @@ const zhHant = {
help: '查看說明文件',
},
models: {
codex: {
account: 'ChatGPT 訂閱',
description:
'使用 ChatGPT 帳號登入。訂閱權限與 OpenAI API 計費相互獨立,可用模型和使用額度取決於你的訂閱方案。',
disconnected: '未連線',
loading: '正在檢查連線…',
starting: '正在開始登入…',
pending: '等待授權',
connected: '已連線',
expired: '登入已過期,請重試以取得新驗證碼。',
error: '無法登入,請檢查網路連線後重試。',
canceling: '正在取消登入…',
saveAndSignIn: '儲存並登入',
done: '完成',
instructions:
'在 OpenAI 頁面輸入此驗證碼,登入完成前請保持此對話框開啟。',
copyCode: '複製驗證碼',
copied: '已複製',
copyManually: '請選取並手動複製驗證碼。',
continueAtOpenAI: '前往 OpenAI 繼續',
expiresAt: '驗證碼將於 {{time}} 過期。',
retrying: '連線中斷,正在自動重試…',
cancelSignIn: '取消登入',
tryAgain: '重試',
signIn: '登入',
reconnect: '重新連線',
disconnect: '中斷連線',
disconnectConfirm:
'中斷此供應商的連線?重新登入前,其模型將無法使用。此操作不會取消你的 ChatGPT 訂閱。',
confirmDisconnect: '確認中斷',
},
title: '模型設定',
description: '設定和管理可在流程線中使用的模型',
createModel: '建立模型',
@@ -299,6 +330,8 @@ const zhHant = {
providerSaveError: '儲存供應商失敗:',
providerDeleted: '供應商已刪除',
providerDeleteError: '刪除供應商失敗:',
deleteProviderCascadeConfirmation:
'確定刪除此供應商及其包含的所有模型嗎?此操作不可逆,無法復原。',
deleteProviderConfirmation: '您確定要刪除這個供應商嗎?',
loadError: '載入資料失敗',
chat: '對話',