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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'チャット',
|
||||
|
||||
@@ -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: 'Не удалось загрузить данные',
|
||||
|
||||
@@ -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: 'แชท',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '对话',
|
||||
|
||||
@@ -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: '對話',
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
// Isolated real React/Radix fixture. No backend or OAuth requests are made.
|
||||
async function mount(page: Page, mode: string) {
|
||||
page.on('pageerror', (error) => console.error(error.message));
|
||||
await page.route('**/copy-harness', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div id="root"></div><script type="module">
|
||||
import RefreshRuntime from '/@react-refresh';
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script><script type="module">
|
||||
import React from '/node_modules/.vite/deps/react.js';
|
||||
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
|
||||
const {createRoot} = ReactDOM;
|
||||
import i18n from '/node_modules/.vite/deps/i18next.js';
|
||||
import {initReactI18next} from '/node_modules/.vite/deps/react-i18next.js';
|
||||
import {Toaster} from '/src/components/ui/sonner.tsx';
|
||||
import '/src/app/global.css';
|
||||
import {Dialog, DialogContent, DialogTitle} from '/src/components/ui/dialog.tsx';
|
||||
import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx';
|
||||
await i18n.use(initReactI18next).init({lng:'en', resources:{en:{translation:{}}}, interpolation:{escapeValue:false}});
|
||||
const root=createRoot(document.getElementById('root'));
|
||||
window.renderCode=(code='FIXTURE-1234',attempt='attempt-1')=>root.render(React.createElement(Dialog,{open:true},
|
||||
React.createElement(DialogContent,{},React.createElement(DialogTitle,{},'Copy fixture'),React.createElement(Section,{providerId:'fixture',login:{phase:'pending',device:{user_code:code,authorization_id:attempt,verification_uri:'https://example.invalid',expires_at:9999999999}}})),React.createElement(Toaster)));
|
||||
window.renderCode();
|
||||
</script>`,
|
||||
}),
|
||||
);
|
||||
await page.addInitScript((mode) => {
|
||||
const w = window as any;
|
||||
w.copyEvents = [];
|
||||
document.addEventListener('copy', () => {
|
||||
const el = document.activeElement as HTMLTextAreaElement;
|
||||
w.copyEvents.push({
|
||||
tag: el.tagName,
|
||||
selected: el.value?.slice(el.selectionStart, el.selectionEnd),
|
||||
});
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value:
|
||||
mode === 'unavailable'
|
||||
? undefined
|
||||
: {
|
||||
writeText: (text: string) => {
|
||||
if (mode === 'success') {
|
||||
w.written = text;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (mode === 'delayed')
|
||||
return new Promise((resolve) => {
|
||||
w.resolveCopy = resolve;
|
||||
});
|
||||
return Promise.reject(new Error('denied'));
|
||||
},
|
||||
},
|
||||
});
|
||||
if (mode === 'false') document.execCommand = () => false;
|
||||
if (mode === 'throw')
|
||||
document.execCommand = () => {
|
||||
throw new Error('denied');
|
||||
};
|
||||
}, mode);
|
||||
await page.goto('/copy-harness');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'models.codex.copyCode', exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
const copy = (page: Page) =>
|
||||
page.getByRole('button', { name: 'models.codex.copyCode', exact: true });
|
||||
const copied = (page: Page) =>
|
||||
page.getByRole('button', { name: 'models.codex.copied', exact: true });
|
||||
|
||||
test('Clipboard API success shows icon, toast and transient feedback', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, 'success');
|
||||
await expect(copy(page).locator('svg.lucide-copy')).toBeVisible();
|
||||
await copy(page).click();
|
||||
await expect(copied(page).locator('svg.lucide-check')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('common.copySuccess', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).written)).toBe(
|
||||
'FIXTURE-1234',
|
||||
);
|
||||
await expect(copy(page)).toBeVisible({ timeout: 4000 });
|
||||
});
|
||||
for (const mode of ['unavailable', 'rejected'])
|
||||
test(`${mode} API performs a real selected-text copy inside modal`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, mode);
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([
|
||||
{ tag: 'TEXTAREA', selected: 'FIXTURE-1234' },
|
||||
]);
|
||||
await expect(copied(page)).toBeFocused();
|
||||
await expect(page.locator('textarea')).toHaveCount(0);
|
||||
});
|
||||
for (const mode of ['false', 'throw'])
|
||||
test(`${mode} fallback reports failure and manual guidance`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, mode);
|
||||
await copy(page).click();
|
||||
await expect(
|
||||
page.getByText('common.copyFailed', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('models.codex.copyManually', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await expect(page.locator('textarea')).toHaveCount(0);
|
||||
await expect(copy(page)).toBeFocused();
|
||||
});
|
||||
test('new code or attempt clears copied feedback', async ({ page }) => {
|
||||
await mount(page, 'success');
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||
);
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-3'),
|
||||
);
|
||||
await expect(copy(page)).toBeVisible();
|
||||
});
|
||||
test('completion from an old attempt cannot mark the new code copied', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, 'delayed');
|
||||
await copy(page).click();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||
);
|
||||
await expect(page.getByText('FIXTURE-5678')).toBeVisible();
|
||||
await page.evaluate(() => (window as any).resolveCopy());
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await expect(copied(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// All OAuth, provider and model responses here are explicit UI fixtures.
|
||||
// These tests never authenticate with OpenAI or use a real subscription.
|
||||
async function fixture(page: Page) {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const state = {
|
||||
providers: [] as Record<string, unknown>[],
|
||||
creates: 0,
|
||||
starts: 0,
|
||||
polls: 0,
|
||||
cancels: 0,
|
||||
disconnects: 0,
|
||||
connected: false,
|
||||
failStart: false,
|
||||
pollStatus: 'pending',
|
||||
interval: 1,
|
||||
expiresIn: 600,
|
||||
};
|
||||
const ok = (route: Route, data: unknown) =>
|
||||
route.fulfill({ json: { code: 0, data } });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
const method = route.request().method();
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#555"/></svg>',
|
||||
});
|
||||
if (path.endsWith('/requesters'))
|
||||
return ok(route, {
|
||||
requesters: ['openai-codex', 'openai'].map((name) => ({
|
||||
name,
|
||||
label: {
|
||||
en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API',
|
||||
},
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm'],
|
||||
config: [
|
||||
{ name: 'base_url', default: 'https://api.openai.com/v1' },
|
||||
],
|
||||
},
|
||||
})),
|
||||
});
|
||||
if (path.endsWith('/providers')) {
|
||||
if (method === 'POST') {
|
||||
state.creates++;
|
||||
const provider = {
|
||||
...route.request().postDataJSON(),
|
||||
uuid: `provider-${state.creates}`,
|
||||
};
|
||||
state.providers.push(provider);
|
||||
return ok(route, { uuid: provider.uuid });
|
||||
}
|
||||
return ok(route, { providers: state.providers });
|
||||
}
|
||||
if (path.endsWith('/codex/status'))
|
||||
return ok(route, {
|
||||
status: state.connected ? 'connected' : 'disconnected',
|
||||
connected: state.connected,
|
||||
expires_at: null,
|
||||
});
|
||||
if (path.endsWith('/codex/device') && method === 'POST') {
|
||||
state.starts++;
|
||||
if (state.failStart)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { code: 400, msg: 'Fixture start failure' },
|
||||
});
|
||||
return ok(route, {
|
||||
authorization_id: `attempt-${state.starts}`,
|
||||
user_code: 'TEST-1234',
|
||||
verification_uri: 'https://auth.openai.com/codex/device',
|
||||
interval: state.interval,
|
||||
expires_at: Date.now() / 1000 + state.expiresIn,
|
||||
});
|
||||
}
|
||||
if (path.endsWith('/codex/device/poll')) {
|
||||
state.polls++;
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
authorization_id: `attempt-${state.starts}`,
|
||||
});
|
||||
if (state.pollStatus === 'connected') state.connected = true;
|
||||
return ok(route, { status: state.pollStatus, interval: state.interval });
|
||||
}
|
||||
if (path.includes('/codex/device/') && method === 'DELETE') {
|
||||
state.cancels++;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/codex/auth') && method === 'DELETE') {
|
||||
state.disconnects++;
|
||||
state.connected = false;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (/\/providers\/provider-\d+$/.test(path)) {
|
||||
const provider = state.providers.find((p) =>
|
||||
path.endsWith(String(p.uuid)),
|
||||
);
|
||||
if (method === 'PUT')
|
||||
Object.assign(provider!, route.request().postDataJSON());
|
||||
return ok(route, { provider });
|
||||
}
|
||||
if (path.includes('/models/')) return ok(route, { models: [] });
|
||||
return ok(route, {});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openModels(page: Page) {
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
}
|
||||
async function choose(page: Page, name: string) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Select Provider Type', exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: new RegExp(name) }).click();
|
||||
}
|
||||
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openModels(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.locator('input[name="name"]').fill('My Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(page.locator('input[name="base_url"]')).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copied', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Copy Successfully', { exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
expect(state.creates).toBe(1);
|
||||
expect(state.providers[0]).toMatchObject({
|
||||
requester: 'openai-codex',
|
||||
api_keys: [],
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
});
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Continue at OpenAI' }),
|
||||
).toHaveAttribute('href', 'https://auth.openai.com/codex/device');
|
||||
const geometry = await page.getByTestId('codex-account').evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return {
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
width: innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.right).toBeLessThanOrEqual(width);
|
||||
expect(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||
if (process.env.CODEX_EVIDENCE_DIR) {
|
||||
await page.locator('[data-sonner-toast]').evaluate(async (el) => {
|
||||
await Promise.all(
|
||||
el
|
||||
.getAnimations({ subtree: true })
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`;
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
writeFileSync(
|
||||
`${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
evidence: 'UI fixture only; not live OpenAI sign-in',
|
||||
viewport: { width, height: 900 },
|
||||
geometry,
|
||||
screenshot,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
state.pollStatus = 'connected';
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await expect(page.getByText('My Codex', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Add Model', exact: true }),
|
||||
).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
expect(
|
||||
await page.evaluate(() => JSON.stringify({ ...localStorage })),
|
||||
).not.toContain('attempt-');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed start retries reuse saved provider; cancellation refreshes list', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.failStart = true;
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Retry Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
|
||||
state.failStart = false;
|
||||
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => state.cancels).toBe(1);
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
});
|
||||
|
||||
test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.pollStatus = 'connected';
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Managed Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
state.pollStatus = 'pending';
|
||||
await page.getByRole('button', { name: 'Reconnect', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
expect(state.disconnects).toBe(0);
|
||||
await page.getByRole('button', { name: 'Disconnect', exact: true }).click();
|
||||
expect(state.disconnects).toBe(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Confirm disconnect', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Not connected', { exact: true })).toBeVisible();
|
||||
expect(state.disconnects).toBe(1);
|
||||
expect(state.creates).toBe(1);
|
||||
});
|
||||
|
||||
test('expiration permits retry without duplicate provider and closing cancels pending login', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.expiresIn = 1;
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Expired Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Sign-in expired. Start again to get a new code.'),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => state.cancels).toBe(1);
|
||||
state.expiresIn = 600;
|
||||
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => state.cancels).toBe(2);
|
||||
await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await page.locator('input[name="name"]').fill('Second Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
expect(state.creates).toBe(2);
|
||||
expect(state.providers.map((provider) => provider.name)).toEqual([
|
||||
'Expired Codex',
|
||||
'Second Codex',
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => state.cancels).toBe(3);
|
||||
});
|
||||
|
||||
test('model test retains the connected provider identity', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
state.connected = true;
|
||||
state.providers.push({
|
||||
uuid: 'provider-1',
|
||||
name: 'Connected Codex',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
api_keys: [],
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Model', exact: true }).click();
|
||||
await page
|
||||
.getByPlaceholder('Model Name', { exact: true })
|
||||
.fill('fixture-codex-model');
|
||||
const requestPromise = page.waitForRequest('**/models/llm/_/test');
|
||||
await page.getByRole('button', { name: 'Test', exact: true }).click();
|
||||
const payload = (await requestPromise).postDataJSON();
|
||||
expect(payload.provider_uuid).toBe('provider-1');
|
||||
expect(payload.provider.uuid).toBe('provider-1');
|
||||
expect(payload.provider.api_keys).toEqual([]);
|
||||
});
|
||||
|
||||
test('ordinary API-key provider still saves and closes', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('My API');
|
||||
await choose(page, 'OpenAI API');
|
||||
await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real');
|
||||
await page
|
||||
.locator('input[name="base_url"]')
|
||||
.fill('https://api.example.test/v1');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('My API', { exact: true })).toBeVisible();
|
||||
expect(state.providers[0]).toMatchObject({
|
||||
requester: 'openai',
|
||||
api_keys: ['fixture-api-key-not-real'],
|
||||
base_url: 'https://api.example.test/v1',
|
||||
});
|
||||
expect(state.starts).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// UI fixtures only: no real provider/model deletion or subscription authentication.
|
||||
async function fixture(page: Page, requester = 'openai', empty = false) {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const provider = {
|
||||
uuid: 'provider-delete-fixture',
|
||||
name: 'Delete fixture provider',
|
||||
requester,
|
||||
base_url: 'https://example.test/v1',
|
||||
api_keys: [],
|
||||
llm_count: empty ? 0 : 1,
|
||||
embedding_count: empty ? 0 : 1,
|
||||
rerank_count: empty ? 0 : 1,
|
||||
};
|
||||
const state = {
|
||||
deleted: false,
|
||||
fail: false,
|
||||
deletes: [] as string[],
|
||||
reads: [] as string[],
|
||||
release: undefined as (() => void) | undefined,
|
||||
hold: false,
|
||||
};
|
||||
const ok = (route: Route, data: unknown) =>
|
||||
route.fulfill({ json: { code: 0, data } });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
const method = route.request().method();
|
||||
if (method === 'DELETE') {
|
||||
state.deletes.push(path + url.search);
|
||||
if (state.hold)
|
||||
await new Promise<void>((resolve) => {
|
||||
state.release = resolve;
|
||||
});
|
||||
if (state.fail)
|
||||
return route.fulfill({
|
||||
status: 409,
|
||||
json: { code: 409, msg: 'Fixture deletion blocked; try again.' },
|
||||
});
|
||||
state.deleted = true;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
if (path.endsWith('/requesters'))
|
||||
return ok(route, {
|
||||
requesters: ['openai', 'openai-codex'].map((name) => ({
|
||||
name,
|
||||
label: { en_US: name },
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm', 'embedding', 'rerank'],
|
||||
config: [],
|
||||
},
|
||||
})),
|
||||
});
|
||||
if (method === 'GET') state.reads.push(path + url.search);
|
||||
if (path.endsWith('/providers'))
|
||||
return ok(route, { providers: state.deleted ? [] : [provider] });
|
||||
if (path.endsWith('/codex/status'))
|
||||
return ok(route, {
|
||||
status: 'connected',
|
||||
connected: true,
|
||||
expires_at: null,
|
||||
});
|
||||
if (path.includes('/models/')) {
|
||||
const type = path.split('/').pop();
|
||||
return ok(route, {
|
||||
models: state.deleted
|
||||
? []
|
||||
: [
|
||||
{
|
||||
uuid: `fixture-${type}`,
|
||||
name: `Fixture ${type} model`,
|
||||
provider_uuid: provider.uuid,
|
||||
provider,
|
||||
abilities: [],
|
||||
extra_args: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path.endsWith(provider.uuid)) return ok(route, { provider });
|
||||
return ok(route, {});
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
return state;
|
||||
}
|
||||
const editDialog = (page: Page) =>
|
||||
page.locator('[role="dialog"]').filter({
|
||||
has: page.locator('[data-slot="dialog-title"]', {
|
||||
hasText: /^Edit Provider$/,
|
||||
}),
|
||||
});
|
||||
async function edit(page: Page) {
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: 'Delete fixture provider' });
|
||||
await card.getByRole('button', { name: 'Expand', exact: true }).click();
|
||||
await expect(
|
||||
card.getByText('Fixture llm model', { exact: true }),
|
||||
).toBeVisible();
|
||||
await card
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-settings') })
|
||||
.click();
|
||||
await expect(editDialog(page).locator('input[name="name"]')).toHaveValue(
|
||||
'Delete fixture provider',
|
||||
);
|
||||
}
|
||||
|
||||
for (const width of [1280, 320]) {
|
||||
test(`confirmation stays centered throughout entry (${width}px)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await edit(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
// Trigger without Playwright's post-click wait so the browser animation is
|
||||
// still live. Sample its actual keyframes, not only the final screenshot.
|
||||
await editDialog(page)
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.evaluate((el) => (el as HTMLButtonElement).click());
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
for (const phase of ['entry']) {
|
||||
const samples = await confirmation.evaluate(async (el) => {
|
||||
const animations = el.getAnimations();
|
||||
if (!animations.length)
|
||||
throw new Error('Expected the real dialog animation');
|
||||
await Promise.all(animations.map((a) => a.ready));
|
||||
animations.forEach((a) => a.pause());
|
||||
const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => {
|
||||
animations.forEach((a) => {
|
||||
a.currentTime = Number(a.effect!.getTiming().duration) * fraction;
|
||||
});
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: r.x + r.width / 2,
|
||||
y: r.y + r.height / 2,
|
||||
left: r.left,
|
||||
right: r.right,
|
||||
};
|
||||
});
|
||||
animations.forEach((a) => a.finish());
|
||||
return samples;
|
||||
});
|
||||
for (const sample of samples) {
|
||||
expect(
|
||||
Math.abs(sample.x - width / 2),
|
||||
`${phase} horizontal center`,
|
||||
).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(sample.y - 450),
|
||||
`${phase} vertical center`,
|
||||
).toBeLessThan(1);
|
||||
expect(sample.left).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.right).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const requester of ['openai', 'openai-codex']) {
|
||||
for (const width of [1280, 320]) {
|
||||
test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, requester);
|
||||
await edit(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const dialog = editDialog(page);
|
||||
const footer = dialog.locator('[data-slot="dialog-footer"]');
|
||||
const remove = footer.getByRole('button', {
|
||||
name: 'Delete',
|
||||
exact: true,
|
||||
});
|
||||
await expect(remove).toBeVisible();
|
||||
for (const button of await footer.getByRole('button').all()) {
|
||||
await expect(button).toBeInViewport({ ratio: 1 });
|
||||
const box = await button.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
}
|
||||
const left = await remove.boundingBox();
|
||||
const cancel = await footer
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.boundingBox();
|
||||
expect(left!.x + left!.width).toBeLessThan(cancel!.x);
|
||||
await remove.click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await expect(confirmation).toContainText('this provider and ALL models');
|
||||
await expect(confirmation).toContainText('cannot be undone');
|
||||
await expect(confirmation).toBeInViewport({ ratio: 1 });
|
||||
await confirmation.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element.getAnimations().map((animation) => animation.finished),
|
||||
);
|
||||
});
|
||||
const box = await confirmation.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
}
|
||||
test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, requester);
|
||||
await edit(page);
|
||||
state.hold = true;
|
||||
await editDialog(page)
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => state.deletes.length).toBe(1);
|
||||
await expect(
|
||||
confirmation.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
confirmation.getByRole('button', { name: 'Cancel', exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
editDialog(page).getByRole('button', {
|
||||
name: requester === 'openai' ? 'Save' : 'Done',
|
||||
exact: true,
|
||||
includeHidden: true,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(confirmation).toBeVisible();
|
||||
state.reads = [];
|
||||
state.release!();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Delete fixture provider', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Fixture llm model', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([
|
||||
'/api/v1/provider/providers/provider-delete-fixture?cascade=true',
|
||||
]);
|
||||
expect(state.reads).toContain('/api/v1/provider/providers');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed cascade retains readable error and can retry', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await edit(page);
|
||||
state.fail = true;
|
||||
await editDialog(page)
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await expect(confirmation.getByRole('alert')).toContainText(
|
||||
'Fixture deletion blocked; try again.',
|
||||
);
|
||||
await expect(
|
||||
confirmation.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(editDialog(page)).toBeVisible();
|
||||
state.fail = false;
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
expect(state.deletes).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('new providers do not expose footer deletion', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('dialog', { name: 'Add Provider', exact: true })
|
||||
.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
test('system-managed provider has no edit or delete entry', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, 'space-chat-completions');
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: 'Delete fixture provider' });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('svg.lucide-settings')).toHaveCount(0);
|
||||
await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
test('existing empty-provider card delete keeps its non-cascade request', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, 'openai', true);
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: 'Delete fixture provider' });
|
||||
await card
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Are you sure you want to delete this provider?', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([
|
||||
'/api/v1/provider/providers/provider-delete-fixture',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// UI fixtures only: never authenticate or write a real provider.
|
||||
test.use({ hasTouch: true });
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`provider dropdown bounded without dialog growth (${width}px)`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
const data = path.endsWith('/requesters')
|
||||
? {
|
||||
requesters: Array.from({ length: 30 }, (_, i) => ({
|
||||
name: i === 0 ? 'openai-codex' : `provider-${i}`,
|
||||
label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` },
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
config: [],
|
||||
support_type: ['llm'],
|
||||
},
|
||||
})),
|
||||
}
|
||||
: { providers: [], models: [] };
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Add Provider', exact: true })
|
||||
.click();
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
const trigger = page.getByRole('button', {
|
||||
name: 'Select Provider Type',
|
||||
exact: true,
|
||||
});
|
||||
const dialog = page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ has: page.locator('input[name="name"]') });
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
const before = await dialog.evaluate((el) => ({
|
||||
height: el.clientHeight,
|
||||
scroll: el.scrollHeight,
|
||||
}));
|
||||
await trigger.click();
|
||||
const search = page.getByPlaceholder('Search providers...');
|
||||
await expect(search).toBeFocused();
|
||||
const menu = search.locator('../..');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Provider 29', exact: false }),
|
||||
).toBeAttached();
|
||||
await menu.evaluate(async (el) => {
|
||||
await Promise.all(el.getAnimations().map((a) => a.finished));
|
||||
});
|
||||
const options = menu.locator(':scope > div').last();
|
||||
await options.hover();
|
||||
await page.mouse.wheel(0, 1200);
|
||||
await expect
|
||||
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
if (width < 1280) {
|
||||
await page.mouse.wheel(0, -1200);
|
||||
await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
const box = (await options.boundingBox())!;
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height - 30;
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x, y }],
|
||||
});
|
||||
for (let step = 1; step <= 10; step++) {
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x, y: y - step * 18 }],
|
||||
});
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
await session.detach();
|
||||
await expect
|
||||
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const geometry = await menu.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const list = el.lastElementChild as HTMLElement;
|
||||
const clipped: string[] = [];
|
||||
for (
|
||||
let parent = el.parentElement;
|
||||
parent;
|
||||
parent = parent.parentElement
|
||||
) {
|
||||
const bounds = parent.getBoundingClientRect();
|
||||
if (
|
||||
/(auto|scroll|hidden|clip)/.test(
|
||||
getComputedStyle(parent).overflowY,
|
||||
) &&
|
||||
(rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1)
|
||||
)
|
||||
clipped.push(parent.tagName);
|
||||
}
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
clipped,
|
||||
listHeight: list.clientHeight,
|
||||
listScroll: list.scrollHeight,
|
||||
scrollTop: list.scrollTop,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
const after = await dialog.evaluate((el) => ({
|
||||
height: el.clientHeight,
|
||||
scroll: el.scrollHeight,
|
||||
}));
|
||||
const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${dir}/dropdown-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
writeFileSync(
|
||||
`${dir}/dropdown-${width}.json`,
|
||||
JSON.stringify(
|
||||
{ evidence: 'UI fixture only', width, before, after, geometry },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
expect.soft(after).toEqual(before);
|
||||
expect.soft(geometry.clipped).toEqual([]);
|
||||
expect.soft(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(geometry.right).toBeLessThanOrEqual(width);
|
||||
expect.soft(geometry.top).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(geometry.bottom).toBeLessThanOrEqual(720);
|
||||
expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||
expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight);
|
||||
expect(geometry.scrollTop).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(search).toBeHidden();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
await trigger.click();
|
||||
await search.fill('Provider 29');
|
||||
await page.locator('input[name="name"]').click();
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.locator('input[name="name"]')).toBeFocused();
|
||||
await trigger.click();
|
||||
await expect(search).toHaveValue('');
|
||||
await search.fill('Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'OpenAI Codex', exact: false })
|
||||
.click();
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save and sign in', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'OpenAI Codex', exact: false }),
|
||||
).toBeFocused();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// All API traffic is intercepted; no real provider secrets or mutations.
|
||||
async function fixture(page: Page, requester = 'openai') {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const providers = ['alpha', 'beta'].map((id) => ({
|
||||
uuid: `loading-${id}`,
|
||||
name: `Loading fixture ${id}`,
|
||||
requester,
|
||||
base_url: `https://${id}.example.test/v1`,
|
||||
api_keys: [`fixture-key-${id}`],
|
||||
llm_count: 0,
|
||||
embedding_count: 0,
|
||||
rerank_count: 0,
|
||||
}));
|
||||
const state = {
|
||||
hold: '' as '' | 'detail' | 'requesters',
|
||||
fail: '' as '' | 'detail' | 'requesters',
|
||||
held: [] as { release: () => void; finished: Promise<void> }[],
|
||||
reads: [] as string[],
|
||||
mutations: [] as string[],
|
||||
errors: [] as string[],
|
||||
};
|
||||
page.on('pageerror', (error) => state.errors.push(error.message));
|
||||
const ok = (route: Route, data: unknown) =>
|
||||
route.fulfill({ json: { code: 0, data } });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (route.request().method() !== 'GET') {
|
||||
state.mutations.push(route.request().method() + ' ' + path);
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
state.reads.push(path);
|
||||
const provider = providers.find((p) => path.endsWith('/' + p.uuid));
|
||||
const dependency = path.endsWith('/requesters')
|
||||
? 'requesters'
|
||||
: provider
|
||||
? 'detail'
|
||||
: '';
|
||||
const fail = dependency && state.fail === dependency;
|
||||
let finish: (() => void) | undefined;
|
||||
if (dependency && state.hold === dependency) {
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
await new Promise<void>((release) =>
|
||||
state.held.push({ release, finished }),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (fail)
|
||||
return await route.fulfill({
|
||||
status: 503,
|
||||
json: { code: 503, msg: `Fixture ${dependency} unavailable` },
|
||||
});
|
||||
if (dependency === 'requesters')
|
||||
return await ok(route, {
|
||||
requesters: [
|
||||
{
|
||||
name: requester,
|
||||
label: {
|
||||
en_US:
|
||||
requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture',
|
||||
},
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm'],
|
||||
config: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
if (provider) return await ok(route, { provider });
|
||||
if (path.endsWith('/providers')) return await ok(route, { providers });
|
||||
if (path.endsWith('/codex/status'))
|
||||
return await ok(route, {
|
||||
status: 'connected',
|
||||
connected: true,
|
||||
expires_at: null,
|
||||
});
|
||||
return await ok(route, { models: [] });
|
||||
} finally {
|
||||
finish?.();
|
||||
}
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(providers[0].name, { exact: true }),
|
||||
).toBeVisible();
|
||||
// Let the panel's independent requester-support read finish before gating the form.
|
||||
await expect
|
||||
.poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
return state;
|
||||
}
|
||||
|
||||
const dialog = (page: Page) =>
|
||||
page.getByRole('dialog', { name: 'Edit Provider', exact: true });
|
||||
const editButton = (page: Page, id = 'alpha') =>
|
||||
page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: `Loading fixture ${id}` })
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-settings') });
|
||||
|
||||
async function expectLoading(page: Page) {
|
||||
const form = dialog(page);
|
||||
await expect(form.getByRole('status')).toContainText('Loading...');
|
||||
await expect(
|
||||
form.getByRole('status').locator('svg.animate-spin'),
|
||||
).toBeVisible();
|
||||
await expect(form.locator('input')).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Cancel', exact: true }),
|
||||
).toBeEnabled();
|
||||
}
|
||||
|
||||
async function expectReady(page: Page, id = 'alpha', requester = 'openai') {
|
||||
const form = dialog(page);
|
||||
await expect(form.locator('input[name="name"]')).toHaveValue(
|
||||
`Loading fixture ${id}`,
|
||||
);
|
||||
await expect(
|
||||
form.getByRole('status', { name: 'Loading...', exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
form.getByRole('button', {
|
||||
name: requester === 'openai' ? 'Save' : 'Done',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeEnabled();
|
||||
if (requester === 'openai') {
|
||||
await expect(form.locator('input[name="base_url"]')).toHaveValue(
|
||||
`https://${id}.example.test/v1`,
|
||||
);
|
||||
await expect(form.locator('input[name="api_key"]')).toHaveValue(
|
||||
`fixture-key-${id}`,
|
||||
);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /OpenAI fixture/ }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(form.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /Codex fixture/ }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
for (const requester of ['openai', 'openai-codex']) {
|
||||
for (const dependency of ['detail', 'requesters'] as const) {
|
||||
test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, requester);
|
||||
state.hold = dependency;
|
||||
await editButton(page).click();
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
// Remain gated for the whole delay, not just the first render.
|
||||
await page.waitForTimeout(250);
|
||||
await expectLoading(page);
|
||||
state.hold = '';
|
||||
state.held.forEach((request) => request.release());
|
||||
await expectReady(page, 'alpha', requester);
|
||||
expect(state.mutations).toEqual([]);
|
||||
expect(state.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependency of ['detail', 'requesters'] as const) {
|
||||
test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.fail = dependency;
|
||||
await editButton(page).click();
|
||||
const form = dialog(page);
|
||||
await expect(form.getByRole('alert')).toContainText('Failed to load data');
|
||||
await expect(form.locator('input')).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Retry', exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Cancel', exact: true }),
|
||||
).toBeEnabled();
|
||||
state.fail = '';
|
||||
state.hold = dependency;
|
||||
await form.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
state.hold = '';
|
||||
state.held.forEach((request) => request.release());
|
||||
await expectReady(page);
|
||||
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(form).toHaveCount(0);
|
||||
state.fail = dependency;
|
||||
await editButton(page).click();
|
||||
await expect(form.getByRole('alert')).toBeVisible();
|
||||
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(form).toHaveCount(0);
|
||||
expect(state.mutations).toEqual([]);
|
||||
expect(state.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const next of ['alpha', 'beta']) {
|
||||
for (const staleFailure of [false, true]) {
|
||||
test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.hold = 'detail';
|
||||
state.fail = staleFailure ? 'detail' : '';
|
||||
await editButton(page).click();
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
const staleRequests = state.held.splice(0);
|
||||
await dialog(page)
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
state.fail = '';
|
||||
// Reopen during the closing animation, before Radix's retained content unmounts.
|
||||
await editButton(page, next).dispatchEvent('click');
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
state.hold = '';
|
||||
state.held.forEach((request) => request.release());
|
||||
await expectReady(page, next);
|
||||
await dialog(page)
|
||||
.locator('input[name="name"]')
|
||||
.fill('Unsaved fixture edit');
|
||||
staleRequests.forEach((request) => request.release());
|
||||
await Promise.all(staleRequests.map((request) => request.finished));
|
||||
await page.waitForTimeout(250);
|
||||
await expect(dialog(page).locator('input[name="name"]')).toHaveValue(
|
||||
'Unsaved fixture edit',
|
||||
);
|
||||
await expect(dialog(page).getByRole('alert')).toHaveCount(0);
|
||||
expect(state.mutations).toEqual([]);
|
||||
expect(state.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
|
||||
const directory = new URL('../../src/i18n/locales/', import.meta.url);
|
||||
let expected;
|
||||
for (const file of fs.readdirSync(directory)) {
|
||||
const compiled = ts.transpileModule(
|
||||
fs.readFileSync(new URL(file, directory), 'utf8'),
|
||||
{
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
const catalog = (module.exports.default || Object.values(module.exports)[0])
|
||||
.models.codex;
|
||||
const keys = Object.keys(catalog).sort();
|
||||
expected ??= keys;
|
||||
assert.deepEqual(keys, expected, file);
|
||||
assert.equal(keys.length, 26, file);
|
||||
assert.ok(catalog.expiresAt.includes('{{time}}'), file);
|
||||
}
|
||||
});
|
||||
|
||||
function policy() {
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
test('Codex payload discards previously entered API credentials and URL', () => {
|
||||
const { providerPayload } = policy();
|
||||
assert.deepEqual(
|
||||
providerPayload({
|
||||
name: 'Subscription',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://proxy.invalid',
|
||||
api_key: 'fixture-only',
|
||||
}),
|
||||
{
|
||||
name: 'Subscription',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
api_keys: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('ordinary providers preserve API key and base URL behavior', () => {
|
||||
assert.deepEqual(
|
||||
policy().providerPayload({
|
||||
name: 'API',
|
||||
requester: 'openai',
|
||||
base_url: 'https://api.example.test/v1',
|
||||
api_key: 'fixture-only',
|
||||
}),
|
||||
{
|
||||
name: 'API',
|
||||
requester: 'openai',
|
||||
base_url: 'https://api.example.test/v1',
|
||||
api_keys: ['fixture-only'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('poll delay honors upstream minimum and transient backoff', () => {
|
||||
const { pollDelay } = policy();
|
||||
assert.equal(pollDelay(5, 0), 5000);
|
||||
assert.equal(pollDelay(10, 2), 40000);
|
||||
assert.equal(pollDelay(120, 3), 120000);
|
||||
assert.equal(pollDelay(NaN, 0), 5000);
|
||||
assert.equal(pollDelay(-1, 0), 5000);
|
||||
});
|
||||
|
||||
test('only the contracted OpenAI device authorization URL can be opened', () => {
|
||||
const { isCodexVerificationUri } = policy();
|
||||
assert.equal(
|
||||
isCodexVerificationUri('https://auth.openai.com/codex/device'),
|
||||
true,
|
||||
);
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'https://auth.openai.com.evil.test/codex/device',
|
||||
'https://evil.test',
|
||||
'https://user@auth.openai.com/codex/device',
|
||||
]) {
|
||||
assert.equal(isCodexVerificationUri(url), false);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user