mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
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:
13 files changed
+1881
-32
No files matched your search
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user