Files
openchamber/packages/ui/src/components/sections/providers/ProvidersPage.tsx
T
Bohdan Triapitsyn 209ffc16e4 fix: handle multi-file tool diffs safely
Split multi-file patches before diff rendering
Fixed hook dependency lint warnings
2026-04-26 16:58:07 +03:00

1118 lines
51 KiB
TypeScript

import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui';
import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine, RiInformationLine, RiEyeLine, RiEyeOffLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import type { ModelMetadata } from '@/types';
import { useI18n } from '@/lib/i18n';
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
minimumFractionDigits: 0,
});
const formatTokens = (value?: number | null) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return null;
}
if (value === 0) {
return '0';
}
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
};
const ADD_PROVIDER_ID = '__add_provider__';
interface AuthMethod {
type?: string;
name?: string;
label?: string;
description?: string;
help?: string;
method?: number;
[key: string]: unknown;
}
interface ProviderOption {
id: string;
name?: string;
}
interface ProviderSourceInfo {
exists: boolean;
path?: string | null;
}
interface ProviderSources {
auth: ProviderSourceInfo;
user: ProviderSourceInfo;
project: ProviderSourceInfo;
custom?: ProviderSourceInfo;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const normalizeAuthType = (method: AuthMethod) => {
const raw = typeof method.type === 'string' ? method.type : '';
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
const merged = `${raw} ${label}`.toLowerCase();
if (merged.includes('oauth')) return 'oauth';
if (merged.includes('api')) return 'api';
return raw.toLowerCase();
};
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
if (!isRecord(payload)) {
return {};
}
const result: Record<string, AuthMethod[]> = {};
for (const [providerId, value] of Object.entries(payload)) {
if (Array.isArray(value)) {
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
}
}
return result;
};
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
if (typeof entry === 'string') {
return { id: entry };
}
if (!isRecord(entry)) {
return null;
}
const idCandidate =
(typeof entry.id === 'string' && entry.id) ||
(typeof entry.providerID === 'string' && entry.providerID) ||
(typeof entry.slug === 'string' && entry.slug) ||
(typeof entry.name === 'string' && entry.name);
if (!idCandidate) {
return null;
}
const nameCandidate = typeof entry.name === 'string' ? entry.name : undefined;
return { id: idCandidate, name: nameCandidate };
};
const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
let entries: unknown[] = [];
if (Array.isArray(payload)) {
entries = payload;
} else if (isRecord(payload)) {
if (Array.isArray(payload.all)) {
entries = payload.all;
} else if (Array.isArray(payload.providers)) {
entries = payload.providers;
}
}
const mapped = entries
.map((entry) => normalizeProviderEntry(entry))
.filter((entry): entry is ProviderOption => Boolean(entry));
const seen = new Set<string>();
return mapped.filter((entry) => {
if (seen.has(entry.id)) {
return false;
}
seen.add(entry.id);
return true;
});
};
export const ProvidersPage: React.FC = () => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
const hiddenModels = useUIStore((state) => state.hiddenModels);
const toggleHiddenModel = useUIStore((state) => state.toggleHiddenModel);
const hideAllModels = useUIStore((state) => state.hideAllModels);
const showAllModels = useUIStore((state) => state.showAllModels);
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
const [authLoading, setAuthLoading] = React.useState(false);
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
const [modelQuery, setModelQuery] = React.useState('');
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
const [availableLoading, setAvailableLoading] = React.useState(false);
const [availableError, setAvailableError] = React.useState<string | null>(null);
const [candidateProviderId, setCandidateProviderId] = React.useState('');
const [providerSearchQuery, setProviderSearchQuery] = React.useState('');
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
React.useEffect(() => {
if (!selectedProviderId && providers.length > 0) {
setSelectedProvider(providers[0].id);
}
}, [providers, selectedProviderId, setSelectedProvider]);
React.useEffect(() => {
let isMounted = true;
const loadAuthMethods = async () => {
setAuthLoading(true);
try {
const response = await fetch('/api/provider/auth', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Auth methods request failed (${response.status})`);
}
const payload = await response.json().catch(() => ({}));
if (!isMounted) return;
setAuthMethodsByProvider(parseAuthPayload(payload));
} catch (error) {
if (!isMounted) return;
console.error('Failed to load provider auth methods:', error);
toast.error(t('settings.providers.page.toast.authMethodsLoadFailed'));
} finally {
if (isMounted) {
setAuthLoading(false);
}
}
};
loadAuthMethods();
return () => {
isMounted = false;
};
}, [t]);
React.useEffect(() => {
let isMounted = true;
const loadAvailableProviders = async () => {
setAvailableLoading(true);
setAvailableError(null);
try {
const response = await fetch('/api/provider', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Provider list request failed (${response.status})`);
}
const payload = await response.json().catch(() => ({}));
if (!isMounted) return;
setAvailableProviders(parseProvidersPayload(payload));
} catch (error) {
if (!isMounted) return;
console.error('Failed to load available providers:', error);
setAvailableError(t('settings.providers.page.state.unableToLoadProviderList'));
} finally {
if (isMounted) {
setAvailableLoading(false);
}
}
};
loadAvailableProviders();
return () => {
isMounted = false;
};
}, [t]);
const connectedProviderIds = React.useMemo(
() => new Set(providers.map((provider) => provider.id)),
[providers]
);
const unconnectedProviders = React.useMemo(
() =>
availableProviders
.filter((provider) => !connectedProviderIds.has(provider.id))
.sort((a, b) => {
const labelA = (a.name || a.id).toLowerCase();
const labelB = (b.name || b.id).toLowerCase();
return labelA.localeCompare(labelB);
}),
[availableProviders, connectedProviderIds]
);
React.useEffect(() => {
if (selectedProviderId !== ADD_PROVIDER_ID) {
return;
}
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
setCandidateProviderId('');
}
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
React.useEffect(() => {
if (selectedProviderId === ADD_PROVIDER_ID) {
setShowAuthPanel(true);
return;
}
setShowAuthPanel(false);
}, [selectedProviderId, t]);
React.useEffect(() => {
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
return;
}
let cancelled = false;
const loadSources = async () => {
try {
const response = await fetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.error || t('settings.providers.page.toast.providerSourcesLoadFailed'));
}
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
if (!cancelled && sources) {
setProviderSources((prev) => ({
...prev,
[selectedProviderId]: sources,
}));
}
} catch (error) {
if (!cancelled) {
console.error('Failed to load provider sources:', error);
}
}
};
loadSources();
return () => {
cancelled = true;
};
}, [selectedProviderId, t]);
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined;
const handleSaveApiKey = async (providerId: string) => {
const apiKey = apiKeyInputs[providerId]?.trim() ?? '';
if (!apiKey) {
toast.error(t('settings.providers.page.toast.apiKeyRequired'));
return;
}
const busyKey = `api:${providerId}`;
setAuthBusyKey(busyKey);
try {
const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'api', key: apiKey }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed');
throw new Error(message);
}
toast.success(t('settings.providers.page.toast.apiKeySaved'));
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
setSelectedProvider(providerId);
} catch (error) {
console.error('Failed to save API key:', error);
toast.error(t('settings.providers.page.toast.apiKeySaveFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
const busyKey = `oauth:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
try {
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: methodIndex }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed');
throw new Error(message);
}
const payloadRecord = isRecord(payload) ? payload : {};
const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord;
const urlCandidate =
(typeof dataRecord.url === 'string' && dataRecord.url) ||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
(typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) ||
undefined;
const instructions =
(typeof dataRecord.instructions === 'string' && dataRecord.instructions) ||
(typeof dataRecord.message === 'string' && dataRecord.message) ||
undefined;
const userCode =
(typeof dataRecord.user_code === 'string' && dataRecord.user_code) ||
(typeof dataRecord.code === 'string' && dataRecord.code) ||
(typeof dataRecord.userCode === 'string' && dataRecord.userCode) ||
undefined;
if (!urlCandidate && !instructions && !userCode) {
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
}
const detailsKey = `${providerId}:${methodIndex}`;
setOauthDetails((prev) => ({
...prev,
[detailsKey]: {
url: urlCandidate,
instructions,
userCode,
},
}));
if (urlCandidate) {
void openExternalUrl(urlCandidate);
}
setPendingOAuth({ providerId, methodIndex });
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
} catch (error) {
console.error('Failed to start OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleOAuthComplete = async (providerId: string, methodIndex: number) => {
const codeKey = `${providerId}:${methodIndex}`;
const code = oauthCodes[codeKey]?.trim();
const busyKey = `oauth-complete:${providerId}:${methodIndex}`;
setAuthBusyKey(busyKey);
try {
const requestBody: { method: number; code?: string } = { method: methodIndex };
if (code) {
requestBody.code = code;
}
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
const responsePayload = await response.json().catch(() => null);
if (!response.ok) {
const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed');
throw new Error(message);
}
toast.success(t('settings.providers.page.toast.oauthCompleted'));
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
setPendingOAuth(null);
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
setSelectedProvider(providerId);
} catch (error) {
console.error('Failed to complete OAuth flow:', error);
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
} finally {
setAuthBusyKey(null);
}
};
const handleCopyOAuthLink = async (url: string) => {
const result = await copyTextToClipboard(url);
if (result.ok) {
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
return;
}
console.error('Failed to copy OAuth link:', result.error);
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
};
const handleCopyOAuthCode = async (code: string) => {
const result = await copyTextToClipboard(code);
if (result.ok) {
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
return;
}
console.error('Failed to copy device code:', result.error);
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
};
const handleDisconnectProvider = async (providerId: string) => {
const busyKey = `disconnect:${providerId}`;
setAuthBusyKey(busyKey);
try {
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed');
throw new Error(message);
}
toast.success(t('settings.providers.page.toast.providerDisconnected'));
await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" });
} catch (error) {
console.error('Failed to disconnect provider:', error);
toast.error(t('settings.providers.page.toast.providerDisconnectFailed'));
} finally {
setAuthBusyKey(null);
}
};
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
if (!isAddMode && providers.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.providers.page.empty.noProvidersDetected')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.checkOpenCodeConfiguration')}</p>
</div>
</div>
);
}
if (isAddMode) {
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4">
<h1 className="typography-ui-header font-semibold text-foreground">{t('settings.providers.page.connect.title')}</h1>
</div>
<div className="mb-8">
<div className="mb-1 px-1">
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connect.selectProviderTitle')}</h2>
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex flex-wrap items-center gap-2 py-1.5">
<span className="typography-ui-label text-foreground">{t('settings.providers.page.connect.providerField')}</span>
{availableLoading ? (
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.state.loading')}</p>
) : availableError ? (
<p className="typography-meta text-muted-foreground">{availableError}</p>
) : unconnectedProviders.length === 0 ? (
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.connect.allProvidersConnected')}</p>
) : (
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
setProviderDropdownOpen(open);
if (!open) setProviderSearchQuery('');
}}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover h-6 w-fit",
)}
>
<span className="flex items-center gap-2 min-w-0">
{candidateProviderId ? <ProviderLogo providerId={candidateProviderId} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<span className={cn("truncate typography-ui-label font-normal", candidateProviderId ? "text-foreground" : "text-muted-foreground")}>
{candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: t('settings.providers.page.connect.selectProviderPlaceholder')}
</span>
</span>
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[280px] p-0"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div
className="flex items-center gap-2 border-b border-[var(--surface-subtle)] px-3 py-2"
onKeyDown={(e) => e.stopPropagation()}
>
<RiSearchLine className="h-4 w-4 text-muted-foreground" />
<input
type="text"
value={providerSearchQuery}
onChange={(e) => setProviderSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder={t('settings.providers.page.connect.searchProvidersPlaceholder')}
className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
autoFocus
/>
</div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => {
const filtered = unconnectedProviders.filter(p => {
const query = providerSearchQuery.toLowerCase();
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
});
if (filtered.length === 0) {
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
}
return filtered.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => {
setCandidateProviderId(provider.id);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{provider.name || provider.id}</span>
</span>
{candidateProviderId === provider.id && (
<RiCheckLine className="h-4 w-4 text-[var(--primary-base)]" />
)}
</DropdownMenuItem>
));
})()}
</ScrollableOverlay>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</section>
</div>
{candidateProviderId && (
<div className="mb-8">
<div className="mb-1 px-1">
<h2 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h2>
</div>
{authLoading ? (
<p className="typography-meta text-muted-foreground px-2">{t('settings.providers.page.auth.loadingMethods')}</p>
) : (
<section className="px-2 pb-2 pt-0 space-y-4">
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.providers.page.auth.apiKeyTooltip')}
</TooltipContent>
</Tooltip>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[candidateProviderId] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[candidateProviderId]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
className="flex-1 font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`}
>
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
</div>
</div>
{(() => {
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
const candidateOAuthMethods = candidateAuthMethods.filter(
(method) => normalizeAuthType(method) === 'oauth'
);
if (candidateOAuthMethods.length === 0) {
return null;
}
return (
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
{candidateOAuthMethods.map((method, index) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
const codeKey = `${candidateProviderId}:${index}`;
const isPending =
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
return (
<div key={`${candidateProviderId}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(candidateProviderId, index)}
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(candidateProviderId, index)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
);
})()}
</section>
)}
</div>
)}
</div>
</ScrollableOverlay>
);
}
if (!selectedProvider) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">{t('settings.providers.page.empty.selectProviderFromSidebar')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.page.empty.reviewDetailsAndConfigureAuth')}</p>
</div>
</div>
);
}
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : '';
const id = typeof model?.id === 'string' ? model.id : '';
const query = modelQuery.trim().toLowerCase();
if (!query) return true;
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5 shrink-0" />
<div className="min-w-0">
<h2 className="typography-ui-header font-semibold text-foreground truncate">
{selectedProvider.name || selectedProvider.id}
</h2>
<p className="typography-meta text-muted-foreground truncate">
<span className="font-mono">{selectedProvider.id}</span>
</p>
</div>
</div>
{/* Authentication */}
<div className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.auth.title')}</h3>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => setShowAuthPanel((prev) => !prev)}
>
{showAuthPanel ? t('settings.providers.page.actions.hide') : t('settings.providers.page.actions.reconnect')}
</Button>
</div>
<section className="px-2 pb-2 pt-0">
{!showAuthPanel ? (
<div className="flex items-center gap-1.5 py-1.5">
<RiCheckLine className="w-4 h-4 text-[var(--status-success)] shrink-0" />
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.connected')}</span>
<span className="typography-meta text-muted-foreground ml-1">{t('settings.providers.page.auth.useReconnectHint')}</span>
</div>
) : authLoading ? (
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
) : (
<div className="space-y-4">
<div className="py-1.5">
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
{t('settings.providers.page.auth.apiKeyLabel')}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
{t('settings.providers.page.auth.apiKeyTooltip')}
</TooltipContent>
</Tooltip>
</label>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mt-1.5">
<Input
type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[selectedProvider.id]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
className="flex-1 font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal shrink-0"
onClick={() => handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`}
>
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
</Button>
</div>
</div>
{oauthAuthMethods.length > 0 && (
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
{oauthAuthMethods.map((method, index) => {
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
const codeKey = `${selectedProvider.id}:${index}`;
const isPending =
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
return (
<div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => handleOAuthStart(selectedProvider.id, index)}
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
>
{t('settings.providers.page.actions.connect')}
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex items-center gap-2 mt-2">
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
<div className="flex gap-1 shrink-0">
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
</div>
</div>
)}
{isPending && (
<div className="flex items-center gap-2 mt-2">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
className="font-mono text-xs"
/>
<Button
size="xs"
className="!font-normal"
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</section>
</div>
{/* Connection Details */}
<div className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.connectionDetails.title')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-8">
<div className="flex min-w-0 flex-col">
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? (
<span className="typography-meta text-muted-foreground">
{t('settings.providers.page.connectionDetails.configuredIn')}{' '}
{[
selectedSources.auth.exists ? t('settings.providers.page.connectionDetails.source.authCredentials') : null,
selectedSources.user.exists ? t('settings.providers.page.connectionDetails.source.userConfig') : null,
selectedSources.project.exists ? t('settings.providers.page.connectionDetails.source.projectConfig') : null,
selectedSources.custom?.exists ? t('settings.providers.page.connectionDetails.source.customConfig') : null,
].filter(Boolean).join(', ')}
</span>
) : (
<span className="typography-meta text-muted-foreground">{t('settings.providers.page.connectionDetails.noActiveSource')}</span>
)}
</div>
<Button
variant="ghost"
size="xs"
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => handleDisconnectProvider(selectedProvider.id)}
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
>
{authBusyKey === `disconnect:${selectedProvider.id}` ? t('settings.providers.page.actions.disconnecting') : t('settings.providers.page.actions.disconnect')}
</Button>
</div>
</section>
</div>
{/* Models */}
<div className="mb-8">
<div className="mb-1 px-1 flex items-center justify-between gap-2">
<h3 className="typography-ui-header font-medium text-foreground">
{t('settings.providers.page.models.title')}
{providerModels.length > 0 && (
<span className="ml-1.5 typography-micro text-muted-foreground font-normal">
({providerModels.length})
</span>
)}
</h3>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
const allIds = providerModels
.map((model) => (typeof model?.id === 'string' ? model.id : ''))
.filter((id) => id.length > 0);
hideAllModels(selectedProvider.id, allIds);
}}
>
{t('settings.providers.page.actions.hideAll')}
</Button>
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => showAllModels(selectedProvider.id)}
>
{t('settings.providers.page.actions.showAll')}
</Button>
</div>
</div>
<section className="px-2 pb-2 pt-0">
<div className="relative mb-2">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
placeholder={t('settings.providers.page.models.filterPlaceholder')}
className="h-7 pl-8 w-full"
/>
</div>
{filteredModels.length === 0 ? (
<p className="typography-meta text-muted-foreground py-4 text-center">{t('settings.providers.page.models.noModelsMatchFilter')}</p>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filteredModels.map((model) => {
const modelId = typeof model?.id === 'string' ? model.id : '';
const modelName = typeof model?.name === 'string' ? model.name : modelId;
const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined;
const isHidden = hiddenModels.some(
(item) => item.providerID === selectedProvider.id && item.modelID === modelId
);
const contextTokens = formatTokens(metadata?.limit?.context);
const outputTokens = formatTokens(metadata?.limit?.output);
const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: t('settings.providers.page.models.capability.toolCalling') });
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: t('settings.providers.page.models.capability.reasoning') });
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: t('settings.providers.page.models.capability.imageInput') });
return (
<div key={modelId} className="py-1.5">
<div
className={cn(
"flex items-center gap-3",
isHidden && 'opacity-50',
)}
>
<span className="typography-meta font-medium text-foreground truncate flex-1 min-w-0">
{modelName}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{(contextTokens || outputTokens) && (
<span className="typography-micro text-muted-foreground flex-shrink-0 bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">
{contextTokens ? `${contextTokens} ${t('settings.providers.page.models.tokenBadge.context')}` : ''}
{contextTokens && outputTokens ? ' · ' : ''}
{outputTokens ? `${outputTokens} ${t('settings.providers.page.models.tokenBadge.output')}` : ''}
</span>
)}
{capabilityIcons.length > 0 && (
<div className="flex items-center gap-1 flex-shrink-0">
{capabilityIcons.map(({ key, icon: Icon, label }) => (
<span
key={key}
className="flex h-5 w-5 rounded items-center justify-center text-muted-foreground bg-[var(--surface-muted)]"
title={label}
aria-label={label}
>
<Icon className="h-3 w-3" />
</span>
))}
</div>
)}
<button
type="button"
onClick={() => toggleHiddenModel(selectedProvider.id, modelId)}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-[var(--interactive-hover)]/50"
title={isHidden ? t('settings.providers.page.models.actions.showModelInSelectors') : t('settings.providers.page.models.actions.hideModelFromSelectors')}
aria-label={isHidden ? t('settings.providers.page.models.actions.showModel') : t('settings.providers.page.models.actions.hideModel')}
>
{isHidden ? <RiEyeOffLine className="h-3.5 w-3.5" /> : <RiEyeLine className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</section>
</div>
</div>
</ScrollableOverlay>
);
};