mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 00:14:49 +00:00
feat(profiles): add cliproxy api profile bridge
- add backend and CLI flows for creating routed API profiles from CLIProxy providers - add dashboard bridge CTAs, metadata, and guided create dialog mode - cover bridge routes and dialog behavior with focused tests Refs #649
This commit is contained in:
1 parent
1f284132ce
commit
287691fa04
26 files changed
+1804
-465
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||
import { ArrowRight, Link2, ShieldCheck } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface ApiProfileBridgeCalloutProps {
|
||||
provider?: CLIProxyProvider;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ApiProfileBridgeCallout({
|
||||
provider,
|
||||
className,
|
||||
compact = false,
|
||||
}: ApiProfileBridgeCalloutProps) {
|
||||
const providerQuery = provider ? `&cliproxyProvider=${provider}` : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-xl border bg-card/95 shadow-sm', compact ? 'p-3' : 'p-4', className)}
|
||||
>
|
||||
<div className={cn('flex gap-3', compact ? 'items-start' : 'items-center')}>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{provider ? (
|
||||
<ProviderLogo provider={provider} size="md" />
|
||||
) : (
|
||||
<Link2 className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Use this provider in API Profiles</h3>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
No manual token copy
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure keys, OAuth accounts, or models here, then create a routed API Profile in CCS.
|
||||
The profile will point at the provider route automatically.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" asChild>
|
||||
<Link to={`/providers?cliproxyBridge=1${providerQuery}`}>
|
||||
Create API Profile
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/providers">Open API Profiles</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { api, withApiBase } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||
import { ApiProfileBridgeCallout } from './api-profile-bridge-callout';
|
||||
|
||||
interface AuthTokensResponse {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
@@ -200,6 +201,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-b bg-muted/20 p-4">
|
||||
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center max-w-md px-8">
|
||||
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
|
||||
@@ -219,67 +223,72 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="border-b bg-muted/20 p-4">
|
||||
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
key={`${managementUrl}:${iframeRevision}`}
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
key={`${managementUrl}:${iframeRevision}`}
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import type { AuthStatus } from '@/lib/api-client';
|
||||
import {
|
||||
CLIPROXY_PROVIDERS,
|
||||
getProviderDescription,
|
||||
getProviderDisplayName,
|
||||
type CLIProxyProvider,
|
||||
} from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckCircle2, Link2, Settings2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface CliproxyBridgeCreatePanelProps {
|
||||
selectedProvider: CLIProxyProvider;
|
||||
authStatuses?: AuthStatus[];
|
||||
onProviderSelect: (provider: CLIProxyProvider) => void;
|
||||
}
|
||||
|
||||
export function CliproxyBridgeCreatePanel({
|
||||
selectedProvider,
|
||||
authStatuses,
|
||||
onProviderSelect,
|
||||
}: CliproxyBridgeCreatePanelProps) {
|
||||
const statusMap = new Map((authStatuses || []).map((status) => [status.provider, status]));
|
||||
const selectedStatus = statusMap.get(selectedProvider);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">CLIProxy Provider</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick the provider already configured in CLIProxy. CCS will create a routed API Profile
|
||||
without asking you to copy the proxy URL or internal auth token.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{CLIPROXY_PROVIDERS.map((provider) => {
|
||||
const status = statusMap.get(provider);
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => onProviderSelect(provider)}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-colors',
|
||||
selectedProvider === provider
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/15'
|
||||
: 'border-border hover:border-primary/35 hover:bg-accent/15'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<ProviderLogo provider={provider} size="md" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
{status?.authenticated ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : status?.accounts?.length ? (
|
||||
<Badge variant="secondary">{status.accounts.length} account(s)</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Configured in CLIProxy</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getProviderDescription(provider)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-muted/20 p-4 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Link2 className="h-4 w-4 text-primary" />
|
||||
Routed profile summary
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Provider route:{' '}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">/api/provider/{selectedProvider}</code>
|
||||
</p>
|
||||
<p>CCS resolves the active CLIProxy target automatically for local and remote setups.</p>
|
||||
<p>
|
||||
{selectedStatus?.authenticated
|
||||
? 'CCS OAuth account detected for this provider.'
|
||||
: 'No CCS OAuth account is required here if you already configured provider API keys in CLIProxy Control Panel.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/cliproxy?provider=${selectedProvider}`}>
|
||||
<Settings2 className="mr-1 h-4 w-4" />
|
||||
Open CLIProxy
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/cliproxy/control-panel">Open Control Panel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, Info, Link2, TriangleAlert } from 'lucide-react';
|
||||
import type { SettingsResponse } from './types';
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
|
||||
@@ -62,6 +63,78 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
||||
</span>
|
||||
<span className="font-mono">{target}</span>
|
||||
</div>
|
||||
{data.cliproxyBridge && (
|
||||
<>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
||||
<span className="font-medium text-muted-foreground">Source</span>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
CLIProxy {data.cliproxyBridge.providerDisplayName}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{data.cliproxyBridge.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This profile is routed through{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
{data.cliproxyBridge.routePath}
|
||||
</code>
|
||||
. Keys and account state stay managed in CLIProxy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
||||
<span className="font-medium text-muted-foreground">Bridge Status</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
data.cliproxyBridge.usesCurrentTarget
|
||||
? 'border-green-200 text-green-700 bg-green-50'
|
||||
: 'border-amber-200 text-amber-700 bg-amber-50'
|
||||
}
|
||||
>
|
||||
{data.cliproxyBridge.usesCurrentTarget ? (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TriangleAlert className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{data.cliproxyBridge.usesCurrentTarget
|
||||
? 'Proxy target is current'
|
||||
: 'Proxy target changed'}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
data.cliproxyBridge.usesCurrentAuthToken
|
||||
? 'border-green-200 text-green-700 bg-green-50'
|
||||
: 'border-amber-200 text-amber-700 bg-amber-50'
|
||||
}
|
||||
>
|
||||
{data.cliproxyBridge.usesCurrentAuthToken ? (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TriangleAlert className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{data.cliproxyBridge.usesCurrentAuthToken
|
||||
? 'Auth token matches current proxy'
|
||||
: 'Auth token may need refresh'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expected proxy URL:{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px] break-all">
|
||||
{data.cliproxyBridge.currentBaseUrl}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Types for Profile Editor
|
||||
*/
|
||||
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
|
||||
|
||||
export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
@@ -13,6 +13,7 @@ export interface SettingsResponse {
|
||||
settings: Settings;
|
||||
mtime: number;
|
||||
path: string;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface ProfileEditorProps {
|
||||
|
||||
@@ -8,18 +8,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound } from 'lucide-react';
|
||||
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound, Link2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OpenRouterQuickStartProps {
|
||||
onOpenRouterClick: () => void;
|
||||
onAlibabaCodingPlanClick: () => void;
|
||||
onCliproxyClick: () => void;
|
||||
onCustomClick: () => void;
|
||||
}
|
||||
|
||||
export function OpenRouterQuickStart({
|
||||
onOpenRouterClick,
|
||||
onAlibabaCodingPlanClick,
|
||||
onCliproxyClick,
|
||||
onCustomClick,
|
||||
}: OpenRouterQuickStartProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -145,6 +147,53 @@ export function OpenRouterQuickStart({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-emerald-500/30 dark:border-emerald-500/40 bg-gradient-to-br from-emerald-500/5 to-background dark:from-emerald-500/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
|
||||
<Link2 className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
>
|
||||
Routed via CLIProxy
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">Use an existing CLIProxy provider</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
If you already configured Gemini, Codex, Claude, or other providers in CLIProxy,
|
||||
create an API Profile without copying internal URLs or auth tokens.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Link2 className="w-4 h-4 text-emerald-600" />
|
||||
<span>Route /api/provider/<provider></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="w-4 h-4 text-emerald-600" />
|
||||
<span>Proxy token resolved automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onCliproxyClick}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
Create CLIProxy Profile
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Configure providers under CLIProxy or the embedded Control Panel, then bridge them
|
||||
here.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { useCreateProfile } from '@/hooks/use-profiles';
|
||||
import { useCreateCliproxyBridgeProfile, useCreateProfile } from '@/hooks/use-profiles';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||
import {
|
||||
Loader2,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
Settings2,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Link2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
@@ -61,7 +63,9 @@ import {
|
||||
} from '@/lib/openrouter-utils';
|
||||
import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
import { isValidProvider, type CLIProxyProvider } from '@/lib/provider-config';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { CliproxyBridgeCreatePanel } from './cliproxy-bridge-create-panel';
|
||||
|
||||
const optionalUrlSchema = z
|
||||
.string()
|
||||
@@ -90,7 +94,8 @@ interface ProfileCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (name: string) => void;
|
||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan';
|
||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy';
|
||||
initialCliproxyProvider?: CLIProxyProvider | null;
|
||||
}
|
||||
|
||||
// Common URL mistakes to warn about
|
||||
@@ -122,15 +127,25 @@ export function ProfileCreateDialog({
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialMode = 'openrouter',
|
||||
initialCliproxyProvider = null,
|
||||
}: ProfileCreateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const createMutation = useCreateProfile();
|
||||
const createCliproxyBridgeMutation = useCreateCliproxyBridgeProfile();
|
||||
const { data: cliproxyAuthData } = useCliproxyAuth();
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [createKind, setCreateKind] = useState<'preset' | 'cliproxy'>(
|
||||
initialMode === 'cliproxy' ? 'cliproxy' : 'preset'
|
||||
);
|
||||
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
|
||||
const [showMorePresets, setShowMorePresets] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [selectedCliproxyProvider, setSelectedCliproxyProvider] = useState<CLIProxyProvider>(
|
||||
initialCliproxyProvider ?? 'gemini'
|
||||
);
|
||||
const [autoGeneratedCliproxyName, setAutoGeneratedCliproxyName] = useState('gemini-api');
|
||||
|
||||
// OpenRouter models for model picker
|
||||
const { models: openRouterModels } = useOpenRouterCatalog();
|
||||
@@ -140,6 +155,7 @@ export function ProfileCreateDialog({
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
control,
|
||||
getValues,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
@@ -149,6 +165,21 @@ export function ProfileCreateDialog({
|
||||
|
||||
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
|
||||
const targetValue = useWatch({ control, name: 'target' });
|
||||
const preferredCliproxyProvider = useMemo<CLIProxyProvider>(() => {
|
||||
if (initialCliproxyProvider && isValidProvider(initialCliproxyProvider)) {
|
||||
return initialCliproxyProvider;
|
||||
}
|
||||
|
||||
const connectedProvider = cliproxyAuthData?.authStatus.find(
|
||||
(status) =>
|
||||
isValidProvider(status.provider) &&
|
||||
(status.authenticated || (status.accounts?.length ?? 0) > 0)
|
||||
);
|
||||
|
||||
return connectedProvider && isValidProvider(connectedProvider.provider)
|
||||
? connectedProvider.provider
|
||||
: 'gemini';
|
||||
}, [cliproxyAuthData?.authStatus, initialCliproxyProvider]);
|
||||
const applyPresetToForm = useCallback(
|
||||
(preset: ProviderPreset | null) => {
|
||||
if (!preset) {
|
||||
@@ -190,11 +221,24 @@ export function ProfileCreateDialog({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab('basic');
|
||||
setCreateKind(initialMode === 'cliproxy' ? 'cliproxy' : 'preset');
|
||||
setUrlWarning(null);
|
||||
setShowApiKey(false);
|
||||
setShowMorePresets(false);
|
||||
setModelSearch('');
|
||||
|
||||
if (initialMode === 'cliproxy') {
|
||||
const provider = preferredCliproxyProvider;
|
||||
const generatedName = `${provider}-api`;
|
||||
setSelectedCliproxyProvider(provider);
|
||||
setAutoGeneratedCliproxyName(generatedName);
|
||||
reset({
|
||||
...EMPTY_FORM_VALUES,
|
||||
name: generatedName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial preset based on initialMode
|
||||
if (initialMode === 'normal') {
|
||||
setSelectedPreset(CUSTOM_PRESET_ID);
|
||||
@@ -213,7 +257,20 @@ export function ProfileCreateDialog({
|
||||
applyPresetToForm(null);
|
||||
}
|
||||
}
|
||||
}, [open, initialMode, applyPresetToForm]);
|
||||
}, [open, initialMode, applyPresetToForm, preferredCliproxyProvider, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || createKind !== 'cliproxy') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextName = `${selectedCliproxyProvider}-api`;
|
||||
const currentName = getValues('name').trim();
|
||||
if (!currentName || currentName === autoGeneratedCliproxyName) {
|
||||
setValue('name', nextName);
|
||||
}
|
||||
setAutoGeneratedCliproxyName(nextName);
|
||||
}, [autoGeneratedCliproxyName, createKind, getValues, open, selectedCliproxyProvider, setValue]);
|
||||
|
||||
// Handle preset selection
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
@@ -265,6 +322,22 @@ export function ProfileCreateDialog({
|
||||
}, [baseUrlValue, selectedPreset]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (createKind === 'cliproxy') {
|
||||
try {
|
||||
const result = await createCliproxyBridgeMutation.mutateAsync({
|
||||
provider: selectedCliproxyProvider,
|
||||
name: data.name,
|
||||
target: data.target,
|
||||
});
|
||||
toast.success(`Profile "${result.name}" created`);
|
||||
onSuccess(result.name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to create routed profile');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate API key - required unless preset has requiresApiKey: false
|
||||
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
|
||||
toast.error(i18n.t('commonToast.apiKeyRequired'));
|
||||
@@ -288,11 +361,41 @@ export function ProfileCreateDialog({
|
||||
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
|
||||
const hasModelErrors =
|
||||
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
||||
const isCreating =
|
||||
createKind === 'cliproxy' ? createCliproxyBridgeMutation.isPending : createMutation.isPending;
|
||||
|
||||
const isQuickTemplateSelected =
|
||||
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
||||
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
|
||||
const showQuickTemplates = showMorePresets || isQuickTemplateSelected;
|
||||
const handleCreateKindChange = (nextKind: 'preset' | 'cliproxy') => {
|
||||
if (nextKind === createKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateKind(nextKind);
|
||||
setActiveTab('basic');
|
||||
setUrlWarning(null);
|
||||
|
||||
if (nextKind === 'cliproxy') {
|
||||
const provider = selectedCliproxyProvider || preferredCliproxyProvider;
|
||||
const generatedName = `${provider}-api`;
|
||||
setSelectedCliproxyProvider(provider);
|
||||
setAutoGeneratedCliproxyName(generatedName);
|
||||
reset({
|
||||
...EMPTY_FORM_VALUES,
|
||||
name: generatedName,
|
||||
target: targetValue || 'claude',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPreset) {
|
||||
applyPresetToForm(currentPreset);
|
||||
} else {
|
||||
applyPresetToForm(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -303,7 +406,9 @@ export function ProfileCreateDialog({
|
||||
Create API Profile
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a provider or configure a custom API endpoint.
|
||||
{createKind === 'cliproxy'
|
||||
? 'Bridge a provider already managed by CLIProxy into a usable CCS API Profile.'
|
||||
: 'Choose a provider or configure a custom API endpoint.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -311,108 +416,111 @@ export function ProfileCreateDialog({
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="border-b bg-muted/10 px-6 py-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.provider')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.providerChooserHint')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="pt-0.5 text-[11px] text-muted-foreground">
|
||||
{t('profileEditor.scrollHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b bg-muted/10 px-6 py-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={createKind === 'cliproxy' ? 'default' : 'outline'}
|
||||
onClick={() => handleCreateKindChange('cliproxy')}
|
||||
>
|
||||
Use CLIProxy Provider
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={createKind === 'preset' ? 'default' : 'outline'}
|
||||
onClick={() => handleCreateKindChange('preset')}
|
||||
>
|
||||
Preset or Manual Setup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
{t('profileEditor.featuredProviders')}
|
||||
</Label>
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{RECOMMENDED_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="featured"
|
||||
/>
|
||||
))}
|
||||
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
|
||||
<CustomPresetCard
|
||||
isSelected={selectedPreset === CUSTOM_PRESET_ID}
|
||||
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
|
||||
/>
|
||||
{createKind === 'preset' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.provider')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.providerChooserHint')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="pt-0.5 text-[11px] text-muted-foreground">
|
||||
{t('profileEditor.scrollHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMorePresets((current) => !current)}
|
||||
aria-expanded={showQuickTemplates}
|
||||
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mr-1 h-3.5 w-3.5 transition-transform',
|
||||
showQuickTemplates ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
{t('profileEditor.morePresets')}
|
||||
</Button>
|
||||
|
||||
{showQuickTemplates && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
{t('profileEditor.featuredProviders')}
|
||||
</Label>
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
||||
{RECOMMENDED_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="compact"
|
||||
density="featured"
|
||||
/>
|
||||
))}
|
||||
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
|
||||
<CustomPresetCard
|
||||
isSelected={selectedPreset === CUSTOM_PRESET_ID}
|
||||
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMorePresets((current) => !current)}
|
||||
aria-expanded={showQuickTemplates}
|
||||
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mr-1 h-3.5 w-3.5 transition-transform',
|
||||
showQuickTemplates ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
{t('profileEditor.morePresets')}
|
||||
</Button>
|
||||
|
||||
{showQuickTemplates && (
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="relative">
|
||||
Basic Information
|
||||
{hasBasicErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="relative">
|
||||
Model Configuration
|
||||
{hasModelErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{createKind === 'cliproxy' ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
|
||||
<CliproxyBridgeCreatePanel
|
||||
selectedProvider={selectedCliproxyProvider}
|
||||
authStatuses={cliproxyAuthData?.authStatus}
|
||||
onProviderSelect={setSelectedCliproxyProvider}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
@@ -433,81 +541,6 @@ export function ProfileCreateDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL - always editable, pre-filled from preset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
{...register('baseUrl')}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
{errors.baseUrl ? (
|
||||
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
||||
) : urlWarning ? (
|
||||
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{urlWarning}</span>
|
||||
</div>
|
||||
) : currentPreset ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentPreset.baseUrl
|
||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key - optional for presets that don't require it */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key{' '}
|
||||
{currentPreset?.requiresApiKey !== false && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{currentPreset?.requiresApiKey === false && (
|
||||
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
{...register('apiKey')}
|
||||
placeholder={
|
||||
currentPreset?.requiresApiKey === false
|
||||
? 'Optional - only if auth is enabled'
|
||||
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.apiKey ? (
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
) : currentPreset?.requiresApiKey === false ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only needed if your local endpoint has authentication enabled
|
||||
</p>
|
||||
) : (
|
||||
currentPreset?.apiKeyHint && (
|
||||
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="target">Default Target CLI</Label>
|
||||
<Select
|
||||
@@ -523,162 +556,336 @@ export function ProfileCreateDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run with{' '}
|
||||
The routed profile still runs like a normal API Profile via{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
||||
</code>{' '}
|
||||
by default. You can still override each run with{' '}
|
||||
</code>
|
||||
. You can override per command with{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Model Mapping</p>
|
||||
<p className="text-xs opacity-90">
|
||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className={cn(isCreating && 'opacity-80')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
Create Routed Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="relative">
|
||||
Basic Information
|
||||
{hasBasicErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="relative">
|
||||
Model Configuration
|
||||
{hasModelErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* OpenRouter Model Picker */}
|
||||
{isOpenRouter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Search Models</Label>
|
||||
<Input
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filteredModels.length > 0) {
|
||||
e.preventDefault();
|
||||
handleModelSelect(filteredModels[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||
{modelSearch
|
||||
? `No models found for "${modelSearch}"`
|
||||
: 'Loading models...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!modelSearch && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 text-accent" />
|
||||
<span>Newest Models</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredModels.map((model) => (
|
||||
<ModelSearchItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
onClick={() => handleModelSelect(model)}
|
||||
showAge={!modelSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Inputs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="model">
|
||||
Default Model
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
ANTHROPIC_MODEL
|
||||
</Badge>
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="model"
|
||||
{...register('model')}
|
||||
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||
className="font-mono text-sm"
|
||||
id="name"
|
||||
{...register('name')}
|
||||
placeholder="my-api"
|
||||
className="font-mono"
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in CLI:{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
ccs my-api "prompt"
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 pt-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sonnetModel" className="text-sm">
|
||||
Sonnet Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_SONNET
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="sonnetModel"
|
||||
{...register('sonnetModel')}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
{/* Base URL - always editable, pre-filled from preset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
{...register('baseUrl')}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
{errors.baseUrl ? (
|
||||
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
||||
) : urlWarning ? (
|
||||
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{urlWarning}</span>
|
||||
</div>
|
||||
) : currentPreset ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentPreset.baseUrl
|
||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="opusModel" className="text-sm">
|
||||
Opus Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_OPUS
|
||||
</Badge>
|
||||
</Label>
|
||||
{/* API Key - optional for presets that don't require it */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key{' '}
|
||||
{currentPreset?.requiresApiKey !== false && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{currentPreset?.requiresApiKey === false && (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="opusModel"
|
||||
{...register('opusModel')}
|
||||
placeholder="e.g. o1, claude-opus-4.5"
|
||||
className="font-mono text-sm h-9"
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
{...register('apiKey')}
|
||||
placeholder={
|
||||
currentPreset?.requiresApiKey === false
|
||||
? 'Optional - only if auth is enabled'
|
||||
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.apiKey ? (
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
) : currentPreset?.requiresApiKey === false ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only needed if your local endpoint has authentication enabled
|
||||
</p>
|
||||
) : (
|
||||
currentPreset?.apiKeyHint && (
|
||||
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="haikuModel" className="text-sm">
|
||||
Haiku Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_HAIKU
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="haikuModel"
|
||||
{...register('haikuModel')}
|
||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="target">Default Target CLI</Label>
|
||||
<Select
|
||||
value={targetValue}
|
||||
onValueChange={(value) => setValue('target', value as CliTarget)}
|
||||
>
|
||||
<SelectTrigger id="target">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">Claude Code (default)</SelectItem>
|
||||
<SelectItem value="droid">Factory Droid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run with{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
||||
</code>{' '}
|
||||
by default. You can still override each run with{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Model Mapping</p>
|
||||
<p className="text-xs opacity-90">
|
||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className={cn(createMutation.isPending && 'opacity-80')}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
{/* OpenRouter Model Picker */}
|
||||
{isOpenRouter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Search Models</Label>
|
||||
<Input
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filteredModels.length > 0) {
|
||||
e.preventDefault();
|
||||
handleModelSelect(filteredModels[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||
{modelSearch
|
||||
? `No models found for "${modelSearch}"`
|
||||
: 'Loading models...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!modelSearch && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 text-accent" />
|
||||
<span>Newest Models</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredModels.map((model) => (
|
||||
<ModelSearchItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
onClick={() => handleModelSelect(model)}
|
||||
showAge={!modelSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Inputs */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="model">
|
||||
Default Model
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
ANTHROPIC_MODEL
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="model"
|
||||
{...register('model')}
|
||||
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 pt-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sonnetModel" className="text-sm">
|
||||
Sonnet Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_SONNET
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="sonnetModel"
|
||||
{...register('sonnetModel')}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="opusModel" className="text-sm">
|
||||
Opus Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_OPUS
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="opusModel"
|
||||
{...register('opusModel')}
|
||||
placeholder="e.g. o1, claude-opus-4.5"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="haikuModel" className="text-sm">
|
||||
Haiku Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_HAIKU
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="haikuModel"
|
||||
{...register('haikuModel')}
|
||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className={cn(isCreating && 'opacity-80')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
api,
|
||||
type CreateProfile,
|
||||
type CreateCliproxyBridgeProfileRequest,
|
||||
type UpdateProfile,
|
||||
type RegisterProfileOrphansRequest,
|
||||
type CopyProfileRequest,
|
||||
@@ -36,6 +37,22 @@ export function useCreateProfile() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCliproxyBridgeProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCliproxyBridgeProfileRequest) =>
|
||||
api.profiles.createCliproxyBridge(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
toast.success('CLIProxy-routed profile created successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -96,11 +96,22 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
// Types
|
||||
export type CliTarget = 'claude' | 'droid';
|
||||
|
||||
export interface CliproxyBridgeMetadata {
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
routePath: string;
|
||||
currentBaseUrl: string;
|
||||
source: 'local' | 'remote';
|
||||
usesCurrentTarget: boolean;
|
||||
usesCurrentAuthToken: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
configured: boolean;
|
||||
target?: CliTarget;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface CreateProfile {
|
||||
@@ -124,6 +135,19 @@ export interface UpdateProfile {
|
||||
target?: CliTarget;
|
||||
}
|
||||
|
||||
export interface CreateCliproxyBridgeProfileRequest {
|
||||
provider: CLIProxyProvider;
|
||||
name?: string;
|
||||
target?: CliTarget;
|
||||
}
|
||||
|
||||
export interface CreateCliproxyBridgeProfileResponse {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
target: CliTarget;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface ProfileValidationIssue {
|
||||
level: 'error' | 'warning';
|
||||
code: string;
|
||||
@@ -762,6 +786,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
createCliproxyBridge: (data: CreateCliproxyBridgeProfileRequest) =>
|
||||
request<CreateCliproxyBridgeProfileResponse>('/profiles/cliproxy-bridge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (name: string, data: UpdateProfile) =>
|
||||
request(`/profiles/${name}`, {
|
||||
method: 'PUT',
|
||||
|
||||
+72
-6
@@ -38,9 +38,14 @@ import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||
import { isValidProvider } from '@/lib/provider-config';
|
||||
|
||||
export function ApiPage() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, refetch } = useProfiles();
|
||||
const deleteMutation = useDeleteProfile();
|
||||
const discoverOrphansMutation = useDiscoverProfileOrphans();
|
||||
@@ -51,9 +56,11 @@ export function ApiPage() {
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>(
|
||||
'normal'
|
||||
);
|
||||
const [createMode, setCreateMode] = useState<
|
||||
'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy'
|
||||
>('normal');
|
||||
const [createDialogCliproxyProvider, setCreateDialogCliproxyProvider] =
|
||||
useState<CLIProxyProvider | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||
@@ -68,6 +75,34 @@ export function ApiPage() {
|
||||
const selectedProfileData = selectedProfile
|
||||
? profiles.find((p) => p.name === selectedProfile)
|
||||
: null;
|
||||
const cliproxyBridgeIntent = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const rawProvider = params.get('cliproxyProvider');
|
||||
const wantsBridge = params.get('cliproxyBridge') === '1' || rawProvider !== null;
|
||||
if (!wantsBridge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = rawProvider && isValidProvider(rawProvider) ? rawProvider : null;
|
||||
|
||||
return {
|
||||
provider,
|
||||
};
|
||||
}, [location.search]);
|
||||
|
||||
const clearCliproxyBridgeIntent = () => {
|
||||
if (!cliproxyBridgeIntent) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({ pathname: location.pathname, search: '' }, { replace: true });
|
||||
};
|
||||
|
||||
const resolvedCreateMode = cliproxyBridgeIntent ? 'cliproxy' : createMode;
|
||||
const resolvedCreateDialogProvider = cliproxyBridgeIntent
|
||||
? cliproxyBridgeIntent.provider
|
||||
: createDialogCliproxyProvider;
|
||||
const isResolvedCreateDialogOpen = isCreateDialogOpen || !!cliproxyBridgeIntent;
|
||||
|
||||
const switchToProfile = (name: string) => {
|
||||
if (editorHasChanges && selectedProfile !== name) {
|
||||
@@ -91,6 +126,7 @@ export function ApiPage() {
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (name: string) => {
|
||||
clearCliproxyBridgeIntent();
|
||||
setCreateDialogOpen(false);
|
||||
switchToProfile(name);
|
||||
};
|
||||
@@ -98,6 +134,13 @@ export function ApiPage() {
|
||||
switchToProfile(name);
|
||||
};
|
||||
|
||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
clearCliproxyBridgeIntent();
|
||||
}
|
||||
setCreateDialogOpen(open);
|
||||
};
|
||||
|
||||
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
|
||||
const content = JSON.stringify(bundle, null, 2) + '\n';
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
@@ -292,6 +335,18 @@ export function ApiPage() {
|
||||
{t('apiProfiles.noProfilesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateMode('cliproxy');
|
||||
setCreateDialogCliproxyProvider(null);
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-1" />
|
||||
Use CLIProxy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -385,6 +440,11 @@ export function ApiPage() {
|
||||
</>
|
||||
) : (
|
||||
<OpenRouterQuickStart
|
||||
onCliproxyClick={() => {
|
||||
setCreateMode('cliproxy');
|
||||
setCreateDialogCliproxyProvider(null);
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onOpenRouterClick={() => {
|
||||
setCreateMode('openrouter');
|
||||
setCreateDialogOpen(true);
|
||||
@@ -411,10 +471,11 @@ export function ApiPage() {
|
||||
/>
|
||||
|
||||
<ProfileCreateDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
open={isResolvedCreateDialogOpen}
|
||||
onOpenChange={handleCreateDialogOpenChange}
|
||||
onSuccess={handleCreateSuccess}
|
||||
initialMode={createMode}
|
||||
initialMode={resolvedCreateMode}
|
||||
initialCliproxyProvider={resolvedCreateDialogProvider}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -480,6 +541,11 @@ function ProfileListItem({
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
|
||||
{profile.target || 'claude'}
|
||||
</Badge>
|
||||
{profile.cliproxyBridge && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
|
||||
CLIProxy {profile.cliproxyBridge.provider}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="text-xs text-muted-foreground truncate flex-1">
|
||||
|
||||
+105
-84
@@ -15,6 +15,7 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||
import { AddAccountDialog } from '@/components/account/add-account-dialog';
|
||||
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
|
||||
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||
import { ApiProfileBridgeCallout } from '@/components/cliproxy/api-profile-bridge-callout';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||
import {
|
||||
@@ -436,93 +437,113 @@ export function CliproxyPage() {
|
||||
)}
|
||||
|
||||
{selectedVariantData && parentAuthForVariant ? (
|
||||
// Variant selected - show ProviderEditor with variant profile name
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={t('cliproxyPage.variantDisplay', {
|
||||
name: selectedVariantData.name,
|
||||
provider: selectedVariantData.provider,
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
<>
|
||||
<ApiProfileBridgeCallout
|
||||
provider={selectedVariantData.provider}
|
||||
compact
|
||||
className="mx-4 mt-4"
|
||||
/>
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={t('cliproxyPage.variantDisplay', {
|
||||
name: selectedVariantData.name,
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) =>
|
||||
handleBulkPause(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
) : selectedStatus ? (
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
<>
|
||||
<ApiProfileBridgeCallout
|
||||
provider={
|
||||
isValidProvider(selectedStatus.provider) ? selectedStatus.provider : undefined
|
||||
}
|
||||
compact
|
||||
className="mx-4 mt-4"
|
||||
/>
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<ApiProfileBridgeCallout compact className="mx-4 mt-4" />
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ vi.mock('@/hooks/use-profiles', () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useCreateCliproxyBridgeProfile: () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
@@ -18,6 +22,12 @@ vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
useCliproxyAuth: () => ({
|
||||
data: { authStatus: [] },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProfileCreateDialog', () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockReset();
|
||||
|
||||
Reference in new issue
Block a user