mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +00:00
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
/**
|
|
* Provider Editor Component
|
|
* Split-view editor for CLIProxy provider settings
|
|
*/
|
|
|
|
/* eslint-disable react-refresh/only-export-components */
|
|
import { useMemo, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import { Loader2, Code2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
useCliproxyModels,
|
|
usePresets,
|
|
useCreatePreset,
|
|
useDeletePreset,
|
|
} from '@/hooks/use-cliproxy';
|
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
|
import i18n from '@/lib/i18n';
|
|
import { usePrivacy } from '@/contexts/privacy-context';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useProviderEditor } from './use-provider-editor';
|
|
import { CustomPresetDialog } from './custom-preset-dialog';
|
|
import { RawEditorSection } from './raw-editor-section';
|
|
import { ProviderInfoTab } from './provider-info-tab';
|
|
import { ProviderEditorHeader } from './provider-editor-header';
|
|
import { ModelConfigTab } from './model-config-tab';
|
|
import type { ProviderEditorProps, ModelMappingValues } from './types';
|
|
|
|
export function ProviderEditor({
|
|
provider,
|
|
displayName,
|
|
authStatus,
|
|
catalog,
|
|
routing,
|
|
logoProvider,
|
|
baseProvider,
|
|
isRemoteMode,
|
|
port,
|
|
defaultTarget,
|
|
topNotice,
|
|
onAddAccount,
|
|
onSetDefault,
|
|
onRemoveAccount,
|
|
onPauseToggle,
|
|
onSoloMode,
|
|
onBulkPause,
|
|
onBulkResume,
|
|
isRemovingAccount,
|
|
isPausingAccount,
|
|
isSoloingAccount,
|
|
isBulkPausing,
|
|
isBulkResuming,
|
|
}: ProviderEditorProps) {
|
|
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
|
const { privacyMode } = usePrivacy();
|
|
const { t } = useTranslation();
|
|
|
|
const { data: modelsData } = useCliproxyModels();
|
|
const { data: presetsData } = usePresets(provider);
|
|
const createPresetMutation = useCreatePreset();
|
|
const deletePresetMutation = useDeletePreset();
|
|
|
|
// Use baseProvider for model filtering (for variants, this is the parent provider)
|
|
const modelFilterProvider = baseProvider || provider;
|
|
const isAgyProvider = modelFilterProvider.toLowerCase() === 'agy';
|
|
|
|
const savedPresets = useMemo(() => {
|
|
const presets = presetsData?.presets || [];
|
|
if (!isAgyProvider) return presets;
|
|
|
|
return presets.filter(
|
|
(preset) =>
|
|
!isDeniedAgyModelId(preset.default) &&
|
|
!isDeniedAgyModelId(preset.opus) &&
|
|
!isDeniedAgyModelId(preset.sonnet) &&
|
|
!isDeniedAgyModelId(preset.haiku)
|
|
);
|
|
}, [isAgyProvider, presetsData?.presets]);
|
|
|
|
const providerModels = useMemo(() => {
|
|
if (!modelsData?.models) return [];
|
|
const ownerMap: Record<string, string[]> = {
|
|
gemini: ['google'],
|
|
agy: ['antigravity'],
|
|
codex: ['openai'],
|
|
cursor: ['cursor'],
|
|
gitlab: ['gitlab', 'duo'],
|
|
codebuddy: ['codebuddy'],
|
|
qwen: ['alibaba', 'qwen'],
|
|
iflow: ['iflow'],
|
|
kilo: ['kilo'],
|
|
kiro: ['kiro', 'aws'],
|
|
ghcp: ['github', 'copilot'],
|
|
kimi: ['kimi', 'moonshot'],
|
|
};
|
|
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
|
|
modelFilterProvider.toLowerCase(),
|
|
];
|
|
return modelsData.models.filter((m) => {
|
|
if (!owners.some((o) => m.owned_by.toLowerCase().includes(o))) return false;
|
|
if (!isAgyProvider) return true;
|
|
return !isDeniedAgyModelId(m.id);
|
|
});
|
|
}, [isAgyProvider, modelsData, modelFilterProvider]);
|
|
|
|
const providerRoute = (baseProvider || provider).toLowerCase();
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
refetch,
|
|
rawJsonContent,
|
|
rawJsonEdits,
|
|
isRawJsonValid,
|
|
hasChanges,
|
|
currentModel,
|
|
opusModel,
|
|
sonnetModel,
|
|
haikuModel,
|
|
extendedContextEnabled,
|
|
toggleExtendedContext,
|
|
handleRawJsonChange,
|
|
updateEnvValue,
|
|
updateEnvValues,
|
|
saveMutation,
|
|
conflictDialog,
|
|
handleConflictResolve,
|
|
missingRequiredFields,
|
|
} = useProviderEditor(provider, catalog);
|
|
|
|
// Defensive normalization: remote/legacy payloads may omit account.provider.
|
|
// Fallback to current editor provider to avoid runtime crashes in account UI.
|
|
const accounts = useMemo(
|
|
() =>
|
|
(authStatus.accounts || []).map((account) => ({
|
|
...account,
|
|
provider: account.provider || baseProvider || provider,
|
|
})),
|
|
[authStatus.accounts, baseProvider, provider]
|
|
);
|
|
|
|
// Fetch effective API key for presets (uses configured value, not hardcoded)
|
|
const { data: authTokens } = useQuery<{ apiKey: { value: string } }>({
|
|
queryKey: ['auth-tokens-raw'],
|
|
queryFn: async () => {
|
|
const response = await fetch('/api/settings/auth/tokens/raw');
|
|
if (!response.ok) return { apiKey: { value: 'ccs-internal-managed' } };
|
|
return response.json();
|
|
},
|
|
staleTime: 60000, // Cache for 1 minute
|
|
});
|
|
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
|
|
|
|
const handleApplyPreset = (updates: Record<string, string>) => {
|
|
if (
|
|
isAgyProvider &&
|
|
[
|
|
updates.ANTHROPIC_MODEL,
|
|
updates.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
updates.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
updates.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
].some((modelId) => typeof modelId === 'string' && isDeniedAgyModelId(modelId))
|
|
) {
|
|
toast.error(t('providerEditor.agyDenylist'));
|
|
return;
|
|
}
|
|
|
|
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
|
updateEnvValues({
|
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
|
|
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
|
...updates,
|
|
});
|
|
toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`);
|
|
};
|
|
|
|
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
|
|
if (
|
|
isAgyProvider &&
|
|
[values.default, values.opus, values.sonnet, values.haiku].some((modelId) =>
|
|
isDeniedAgyModelId(modelId)
|
|
)
|
|
) {
|
|
toast.error(t('providerEditor.agyDenylist'));
|
|
return;
|
|
}
|
|
|
|
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
|
updateEnvValues({
|
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
|
|
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
|
ANTHROPIC_MODEL: values.default,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku,
|
|
});
|
|
toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`);
|
|
setCustomPresetOpen(false);
|
|
};
|
|
|
|
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
|
|
if (!presetName) {
|
|
toast.error(i18n.t('commonToast.enterPresetName'));
|
|
return;
|
|
}
|
|
if (
|
|
isAgyProvider &&
|
|
[values.default, values.opus, values.sonnet, values.haiku].some((modelId) =>
|
|
isDeniedAgyModelId(modelId)
|
|
)
|
|
) {
|
|
toast.error(t('providerEditor.agyDenylist'));
|
|
return;
|
|
}
|
|
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
|
|
setCustomPresetOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
<ProviderEditorHeader
|
|
provider={provider}
|
|
displayName={displayName}
|
|
logoProvider={logoProvider}
|
|
data={data}
|
|
isLoading={isLoading}
|
|
hasChanges={hasChanges}
|
|
isRawJsonValid={isRawJsonValid}
|
|
isSaving={saveMutation.isPending}
|
|
isRemoteMode={isRemoteMode}
|
|
port={port}
|
|
onRefetch={refetch}
|
|
onSave={() => saveMutation.mutate()}
|
|
/>
|
|
{topNotice ? <div className="border-b bg-muted/10 px-4 py-3">{topNotice}</div> : null}
|
|
|
|
{isLoading ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
<span className="ml-3 text-muted-foreground">{t('providerEditor.loadingSettings')}</span>
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
|
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
|
|
<Tabs defaultValue="config" className="h-full flex flex-col">
|
|
<div className="px-4 pt-4 shrink-0">
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="config" className="flex-1">
|
|
Model Config
|
|
</TabsTrigger>
|
|
<TabsTrigger value="info" className="flex-1">
|
|
Info & Usage
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
<TabsContent
|
|
value="config"
|
|
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
|
>
|
|
<ModelConfigTab
|
|
provider={provider}
|
|
catalog={catalog}
|
|
savedPresets={savedPresets}
|
|
currentModel={currentModel}
|
|
opusModel={opusModel}
|
|
sonnetModel={sonnetModel}
|
|
haikuModel={haikuModel}
|
|
providerModels={providerModels}
|
|
routing={routing}
|
|
extendedContextEnabled={extendedContextEnabled}
|
|
onExtendedContextToggle={toggleExtendedContext}
|
|
onApplyPreset={handleApplyPreset}
|
|
onUpdateEnvValue={updateEnvValue}
|
|
onOpenCustomPreset={() => setCustomPresetOpen(true)}
|
|
onDeletePreset={(name) =>
|
|
deletePresetMutation.mutate({ profile: provider, name })
|
|
}
|
|
isDeletePending={deletePresetMutation.isPending}
|
|
accounts={accounts}
|
|
onAddAccount={onAddAccount}
|
|
onSetDefault={onSetDefault}
|
|
onRemoveAccount={onRemoveAccount}
|
|
onPauseToggle={onPauseToggle}
|
|
onSoloMode={onSoloMode}
|
|
onBulkPause={onBulkPause}
|
|
onBulkResume={onBulkResume}
|
|
isRemovingAccount={isRemovingAccount}
|
|
isPausingAccount={isPausingAccount}
|
|
isSoloingAccount={isSoloingAccount}
|
|
isBulkPausing={isBulkPausing}
|
|
isBulkResuming={isBulkResuming}
|
|
privacyMode={privacyMode}
|
|
isRemoteMode={isRemoteMode}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent
|
|
value="info"
|
|
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
|
|
>
|
|
<ProviderInfoTab
|
|
provider={provider}
|
|
displayName={displayName}
|
|
baseProvider={baseProvider}
|
|
defaultTarget={defaultTarget}
|
|
data={data}
|
|
authStatus={authStatus}
|
|
supportsModelConfig={Boolean(catalog)}
|
|
/>
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
|
|
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
|
<Code2 className="w-4 h-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
{t('rawEditorSection.rawConfig')} (JSON)
|
|
</span>
|
|
</div>
|
|
<RawEditorSection
|
|
rawJsonContent={rawJsonContent}
|
|
isRawJsonValid={isRawJsonValid}
|
|
rawJsonEdits={rawJsonEdits}
|
|
onRawJsonChange={handleRawJsonChange}
|
|
profileEnv={data?.settings?.env}
|
|
missingRequiredFields={missingRequiredFields}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={conflictDialog}
|
|
title="File Modified Externally"
|
|
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
|
confirmText="Overwrite"
|
|
variant="destructive"
|
|
onConfirm={() => handleConflictResolve(true)}
|
|
onCancel={() => handleConflictResolve(false)}
|
|
/>
|
|
|
|
<CustomPresetDialog
|
|
open={customPresetOpen}
|
|
onClose={() => setCustomPresetOpen(false)}
|
|
currentValues={{
|
|
default: currentModel || '',
|
|
opus: opusModel || '',
|
|
sonnet: sonnetModel || '',
|
|
haiku: haikuModel || '',
|
|
}}
|
|
onApply={handleCustomPresetApply}
|
|
onSave={handleCustomPresetSave}
|
|
isSaving={createPresetMutation.isPending}
|
|
catalog={catalog}
|
|
allModels={providerModels}
|
|
routing={routing}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type { ProviderEditorProps, ModelMappingValues } from './types';
|
|
export { AccountItem } from './account-item';
|
|
export { UsageCommand } from './usage-command';
|
|
export { CustomPresetDialog } from './custom-preset-dialog';
|
|
export { ModelConfigSection } from './model-config-section';
|
|
export { RawEditorSection } from './raw-editor-section';
|
|
export { AccountsSection } from './accounts-section';
|
|
export { ProviderInfoTab } from './provider-info-tab';
|
|
export { ProviderEditorHeader } from './provider-editor-header';
|
|
export { ModelConfigTab } from './model-config-tab';
|
|
export { useProviderEditor } from './use-provider-editor';
|