Merge pull request #698 from kaitranntt/kai/feat/issue-697-profile-lifecycle-ux

feat: API profile lifecycle discover/copy/export/import parity
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-07 01:25:53 -05:00
commit 248b6d11b8
13 files changed
+1881 -32

No files matched your search

+58 -1
View File
@@ -4,7 +4,14 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type CreateProfile, type UpdateProfile } from '@/lib/api-client';
import {
api,
type CreateProfile,
type UpdateProfile,
type RegisterProfileOrphansRequest,
type CopyProfileRequest,
type ImportProfileRequest,
} from '@/lib/api-client';
import { toast } from 'sonner';
export function useProfiles() {
@@ -59,3 +66,53 @@ export function useDeleteProfile() {
},
});
}
export function useDiscoverProfileOrphans() {
return useMutation({
mutationFn: () => api.profiles.discoverOrphans(),
});
}
export function useRegisterProfileOrphans() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RegisterProfileOrphansRequest) => api.profiles.registerOrphans(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Orphan profiles registration complete');
},
});
}
export function useCopyProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, data }: { name: string; data: CopyProfileRequest }) =>
api.profiles.copy(name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile copied successfully');
},
});
}
export function useExportProfile() {
return useMutation({
mutationFn: ({ name, includeSecrets }: { name: string; includeSecrets?: boolean }) =>
api.profiles.export(name, includeSecrets ?? false),
});
}
export function useImportProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ImportProfileRequest) => api.profiles.import(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile imported successfully');
},
});
}
+98
View File
@@ -124,6 +124,83 @@ export interface UpdateProfile {
target?: CliTarget;
}
export interface ProfileValidationIssue {
level: 'error' | 'warning';
code: string;
message: string;
field?: string;
hint?: string;
}
export interface ProfileValidationSummary {
valid: boolean;
issues: ProfileValidationIssue[];
}
export interface ApiProfileOrphanCandidate {
name: string;
settingsPath: string;
validation: ProfileValidationSummary;
}
export interface DiscoverProfileOrphansResponse {
orphans: ApiProfileOrphanCandidate[];
}
export interface RegisterProfileOrphansRequest {
names?: string[];
target?: CliTarget;
force?: boolean;
}
export interface RegisterProfileOrphansResponse {
registered: string[];
skipped: Array<{ name: string; reason: string }>;
}
export interface CopyProfileRequest {
destination: string;
target?: CliTarget;
force?: boolean;
}
export interface CopyProfileResponse {
success: boolean;
name?: string;
settingsPath?: string;
warnings?: string[];
}
export interface ApiProfileExportBundle {
schemaVersion: 1;
exportedAt: string;
profile: {
name: string;
target: CliTarget;
};
settings: Record<string, unknown>;
}
export interface ExportProfileResponse {
success: boolean;
bundle: ApiProfileExportBundle;
redacted?: boolean;
}
export interface ImportProfileRequest {
bundle: ApiProfileExportBundle;
name?: string;
target?: CliTarget;
force?: boolean;
}
export interface ImportProfileResponse {
success: boolean;
name?: string;
warnings?: string[];
validation?: ProfileValidationSummary;
}
export interface Variant {
name: string;
provider: CLIProxyProvider;
@@ -691,6 +768,27 @@ export const api = {
body: JSON.stringify(data),
}),
delete: (name: string) => request(`/profiles/${name}`, { method: 'DELETE' }),
discoverOrphans: () => request<DiscoverProfileOrphansResponse>('/profiles/orphans'),
registerOrphans: (data: RegisterProfileOrphansRequest) =>
request<RegisterProfileOrphansResponse>('/profiles/orphans/register', {
method: 'POST',
body: JSON.stringify(data),
}),
copy: (name: string, data: CopyProfileRequest) =>
request<CopyProfileResponse>(`/profiles/${name}/copy`, {
method: 'POST',
body: JSON.stringify(data),
}),
export: (name: string, includeSecrets = false) =>
request<ExportProfileResponse>(`/profiles/${name}/export`, {
method: 'POST',
body: JSON.stringify({ includeSecrets }),
}),
import: (data: ImportProfileRequest) =>
request<ImportProfileResponse>('/profiles/import', {
method: 'POST',
body: JSON.stringify(data),
}),
},
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
+208 -28
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -12,6 +12,9 @@ import {
Server,
FileJson,
RefreshCw,
Copy,
Download,
Upload,
} from 'lucide-react';
import { ProfileEditor } from '@/components/profile-editor';
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
@@ -19,18 +22,32 @@ import { OpenRouterBanner } from '@/components/profiles/openrouter-banner';
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
import { AlibabaCodingPlanPromoCard } from '@/components/profiles/alibaba-coding-plan-promo-card';
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
import {
useProfiles,
useDeleteProfile,
useDiscoverProfileOrphans,
useRegisterProfileOrphans,
useCopyProfile,
useExportProfile,
useImportProfile,
} from '@/hooks/use-profiles';
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import type { Profile } from '@/lib/api-client';
import type { ApiProfileExportBundle, Profile } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export function ApiPage() {
const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useProfiles();
const deleteMutation = useDeleteProfile();
const discoverOrphansMutation = useDiscoverProfileOrphans();
const registerOrphansMutation = useRegisterProfileOrphans();
const copyProfileMutation = useCopyProfile();
const exportProfileMutation = useExportProfile();
const importProfileMutation = useImportProfile();
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
@@ -40,6 +57,7 @@ export function ApiPage() {
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
const importFileInputRef = useRef<HTMLInputElement>(null);
useOpenRouterModels();
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
@@ -50,11 +68,22 @@ export function ApiPage() {
const selectedProfileData = selectedProfile
? profiles.find((p) => p.name === selectedProfile)
: null;
const switchToProfile = (name: string) => {
if (editorHasChanges && selectedProfile !== name) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
}
};
const handleDelete = (name: string) => {
deleteMutation.mutate(name, {
onSuccess: () => {
if (selectedProfile === name) {
setSelectedProfile(null);
setEditorHasChanges(false);
setPendingSwitch(null);
}
setDeleteConfirm(null);
},
@@ -63,17 +92,114 @@ export function ApiPage() {
const handleCreateSuccess = (name: string) => {
setCreateDialogOpen(false);
if (editorHasChanges && selectedProfile !== null) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
}
switchToProfile(name);
};
const handleProfileSelect = (name: string) => {
if (editorHasChanges && selectedProfile !== name) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
switchToProfile(name);
};
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
const content = JSON.stringify(bundle, null, 2) + '\n';
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
const handleDiscoverOrphans = async () => {
try {
const result = await discoverOrphansMutation.mutateAsync();
if (result.orphans.length === 0) {
toast.success('No orphan profile settings found');
return;
}
const validCount = result.orphans.filter((orphan) => orphan.validation.valid).length;
const shouldRegister = window.confirm(
`Found ${result.orphans.length} orphan settings file(s). Register ${validCount} valid profile(s) now?`
);
if (!shouldRegister) return;
const registration = await registerOrphansMutation.mutateAsync({});
const skippedMessage =
registration.skipped.length > 0 ? `, skipped ${registration.skipped.length}` : '';
toast.success(`Registered ${registration.registered.length} profile(s)${skippedMessage}`);
} catch (error) {
toast.error((error as Error).message);
}
};
const handleCopySelectedProfile = async () => {
if (!selectedProfileData) return;
const destinationInput = window.prompt(
`Copy profile "${selectedProfileData.name}" to new profile name:`,
`${selectedProfileData.name}-copy`
);
if (!destinationInput) return;
const destination = destinationInput.trim();
if (!destination) {
toast.error('Destination profile name cannot be empty');
return;
}
try {
const result = await copyProfileMutation.mutateAsync({
name: selectedProfileData.name,
data: { destination },
});
switchToProfile(destination);
if (result.warnings && result.warnings.length > 0) {
toast.info(result.warnings.join('\n'));
}
} catch (error) {
toast.error((error as Error).message);
}
};
const handleExportSelectedProfile = async () => {
if (!selectedProfileData) return;
try {
const result = await exportProfileMutation.mutateAsync({ name: selectedProfileData.name });
triggerDownload(`${selectedProfileData.name}.ccs-profile.json`, result.bundle);
if (result.redacted) {
toast.info(
'Export created with redacted token. Use include-secrets flow in CLI if needed.'
);
} else {
toast.success('Profile export downloaded');
}
} catch (error) {
toast.error((error as Error).message);
}
};
const handleImportClick = () => {
importFileInputRef.current?.click();
};
const handleImportFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const rawText = await file.text();
const bundle = JSON.parse(rawText) as ApiProfileExportBundle;
const result = await importProfileMutation.mutateAsync({ bundle });
if (result.name) {
switchToProfile(result.name);
}
if (result.warnings && result.warnings.length > 0) {
toast.info(result.warnings.join('\n'));
}
} catch (error) {
toast.error((error as Error).message || 'Failed to import profile bundle');
}
};
@@ -88,15 +214,39 @@ export function ApiPage() {
<Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
</div>
<Button
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
{t('apiProfiles.new')}
</Button>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
onClick={() => void handleDiscoverOrphans()}
disabled={discoverOrphansMutation.isPending || registerOrphansMutation.isPending}
aria-label="Discover orphan profiles"
title="Discover orphan profiles"
>
<RefreshCw
className={`w-4 h-4 ${discoverOrphansMutation.isPending ? 'animate-spin' : ''}`}
/>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleImportClick}
disabled={importProfileMutation.isPending}
aria-label="Import profile bundle"
title="Import profile bundle"
>
<Upload className="w-4 h-4" />
</Button>
<Button
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
{t('apiProfiles.new')}
</Button>
</div>
</div>
<div className="relative">
@@ -204,13 +354,35 @@ export function ApiPage() {
<div className="flex-1 flex flex-col min-w-0">
{selectedProfileData ? (
<ProfileEditor
key={selectedProfileData.name}
profileName={selectedProfileData.name}
profileTarget={selectedProfileData.target}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
onHasChangesUpdate={setEditorHasChanges}
/>
<>
<div className="px-4 py-2 border-b bg-background flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleCopySelectedProfile()}
disabled={copyProfileMutation.isPending}
>
<Copy className="w-4 h-4 mr-1" />
Copy
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleExportSelectedProfile()}
disabled={exportProfileMutation.isPending}
>
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
<ProfileEditor
key={selectedProfileData.name}
profileName={selectedProfileData.name}
profileTarget={selectedProfileData.target}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
onHasChangesUpdate={setEditorHasChanges}
/>
</>
) : (
<OpenRouterQuickStart
onOpenRouterClick={() => {
@@ -230,6 +402,14 @@ export function ApiPage() {
</div>
</div>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(event) => void handleImportFileChange(event)}
/>
<ProfileCreateDialog
open={isCreateDialogOpen}
onOpenChange={setCreateDialogOpen}