refactor(#3501): god-component Phase 1k-1m — client 3408→2553 LOC (-855) (#3721)

Phase 1k-1m of #3501: client 3408→2553 LOC. Pure extraction (useModelImportHandlers+ImportProgressModal, useModelVisibilityHandlers, ProviderModelsSection).

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-12 08:50:16 -03:00
committed by GitHub
parent 72274e9032
commit 2db9a3aa36
6 changed files with 1549 additions and 1003 deletions

View File

@@ -15,7 +15,7 @@
"open-sse/executors/muse-spark-web.ts": 1284,
"open-sse/executors/perplexity-web.ts": 868,
"open-sse/handlers/audioSpeech.ts": 952,
"open-sse/handlers/chatCore.ts": 6023,
"open-sse/handlers/chatCore.ts": 5808,
"open-sse/handlers/imageGeneration.ts": 3777,
"open-sse/handlers/responseSanitizer.ts": 1103,
"open-sse/handlers/search.ts": 1442,
@@ -35,7 +35,7 @@
"open-sse/translator/response/openai-responses.ts": 873,
"open-sse/utils/cursorAgentProtobuf.ts": 1499,
"open-sse/utils/stream.ts": 2710,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1417,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2680,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
@@ -48,7 +48,7 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 3409,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 2554,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171,
@@ -73,7 +73,7 @@
"src/app/api/oauth/[provider]/[action]/route.ts": 897,
"src/app/api/providers/[id]/models/route.ts": 2426,
"src/app/api/providers/[id]/test/route.ts": 842,
"src/app/api/usage/analytics/route.ts": 1355,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1435,
"src/lib/cloudflaredTunnel.ts": 934,
"src/lib/db/apiKeys.ts": 1490,

View File

@@ -0,0 +1,142 @@
"use client";
/**
* ImportProgressModal — Issue #3501 Phase 1k
*
* Extracted from the inline Import Progress Modal JSX in ProviderDetailPageClient.
* Pure presentational component driven entirely by props.
*
* Cycle-safe: no import from ProviderDetailPageClient.
*/
import { Modal } from "@/shared/components";
import type { ImportProgress } from "../hooks/useModelImportHandlers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface ImportProgressModalProps {
importProgress: ImportProgress;
isOpen: boolean;
onClose: () => void;
t: ProviderMessageTranslator;
}
export default function ImportProgressModal({
importProgress,
isOpen,
onClose,
t,
}: ImportProgressModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("importingModelsTitle")}
size="md"
closeOnOverlay={false}
showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"}
>
<div className="flex flex-col gap-4">
{/* Status text */}
<div className="flex items-center gap-3">
{importProgress.phase === "fetching" && (
<span className="material-symbols-outlined text-primary animate-spin">
progress_activity
</span>
)}
{importProgress.phase === "importing" && (
<span className="material-symbols-outlined text-primary animate-spin">
progress_activity
</span>
)}
{importProgress.phase === "done" && (
<span className="material-symbols-outlined text-green-500">check_circle</span>
)}
{importProgress.phase === "error" && (
<span className="material-symbols-outlined text-red-500">error</span>
)}
<span className="text-sm font-medium text-text-main">{importProgress.status}</span>
</div>
{/* Progress bar */}
{(importProgress.phase === "importing" || importProgress.phase === "done") &&
importProgress.total > 0 && (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">
{importProgress.current} / {importProgress.total}
</span>
<span className="text-xs text-text-muted">
{Math.round((importProgress.current / importProgress.total) * 100)}%
</span>
</div>
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${(importProgress.current / importProgress.total) * 100}%`,
background:
importProgress.phase === "done"
? "linear-gradient(90deg, #22c55e, #16a34a)"
: "linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
}}
/>
</div>
</div>
)}
{/* Fetching indeterminate bar */}
{importProgress.phase === "fetching" && (
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full animate-pulse"
style={{
width: "60%",
background:
"linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
}}
/>
</div>
)}
{/* Error message */}
{importProgress.phase === "error" && importProgress.error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<p className="text-sm text-red-400">{importProgress.error}</p>
</div>
)}
{/* Log list */}
{importProgress.logs.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3 border border-black/5 dark:border-white/5">
<div className="flex flex-col gap-1">
{importProgress.logs.map((log, i) => (
<p
key={i}
className={`text-xs font-mono ${
typeof log === "string" && log.startsWith("✓")
? "text-green-500 font-semibold"
: "text-text-muted"
}`}
>
{log}
</p>
))}
</div>
</div>
)}
{/* Close button */}
{importProgress.phase === "done" && (
<div className="flex justify-center">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:opacity-90 transition-opacity"
>
{t("close")}
</button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,466 @@
"use client";
/**
* ProviderModelsSection — Issue #3501 Phase 1m
*
* Extracted from the renderModelsSection() inline function in
* ProviderDetailPageClient. Receives all model/compat state + handlers
* as props (from useModelImportHandlers, useModelVisibilityHandlers,
* useModelCompatState, useProviderModels).
*
* Cycle-safe: no import from ProviderDetailPageClient.
*/
import { Button } from "@/shared/components";
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import ModelRow, { ModelVisibilityToolbar } from "./ModelRow";
import PassthroughModelsSection from "./PassthroughModelsSection";
import CompatibleModelsSection from "./CompatibleModelsSection";
import type { ModelCompatSavePatch } from "../hooks/useModelVisibilityHandlers";
export interface ProviderModelsSectionProps {
// Provider identity
providerId: string;
providerAlias: string;
providerStorageAlias: string;
providerDisplayAlias: string;
providerInfo: {
name?: string;
passthroughModels?: boolean;
} | null;
// Provider-type flags
isCcCompatible: boolean;
isAnthropicCompatible: boolean;
isAnthropicProtocolCompatible: boolean;
isManagedAvailableModelsProvider: boolean;
compatibleSupportsModelImport: boolean;
// Models data
models: Array<{ id: string; name?: string; source?: string }>;
modelMeta: { customModels: any[]; modelCompatOverrides?: any[] };
modelAliases: Record<string, string>;
syncedAvailableModels: any[];
compatibleFallbackModels: any[];
// Clipboard
copied: string | null;
onCopy: (text: string) => void;
// Model alias handlers
onSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise<void>;
onDeleteAlias: (alias: string) => Promise<void>;
fetchProviderModelMeta: () => Promise<void>;
// Connections
connections: any[];
selectedConnection: any;
// Phase 1k: import handlers
canImportModels: boolean;
importingModels: boolean;
handleImportModels: () => Promise<void>;
isAutoSyncEnabled: boolean;
togglingAutoSync: boolean;
handleToggleAutoSync: () => Promise<void>;
handleCompatibleImportWithProgress: (connectionId: string) => Promise<void>;
// Phase 1l: visibility handlers
compatSavingModelId: string | null;
togglingModelId: string | null;
bulkVisibilityAction: "select" | "deselect" | null;
clearingModels: boolean;
modelFilter: string;
testingModelId: string | null;
modelTestStatus: Record<string, "ok" | "error">;
testingAll: boolean;
testProgress: { done: number; total: number } | null;
autoHideFailed: boolean;
visibilityFilter: "all" | "visible" | "hidden";
providerAliasEntries: [string, string][];
setModelFilter: (v: string) => void;
setAutoHideFailed: (v: boolean) => void;
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
handleToggleModelHidden: (
providerKey: string,
modelId: string,
hidden: boolean
) => Promise<void>;
handleBulkToggleModelHidden: (
providerKey: string,
modelIds: string[],
hidden: boolean
) => Promise<void>;
handleClearAllModels: () => Promise<void>;
onTestModel: (modelId: string, fullModel: string) => Promise<void>;
handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
// Compat state (from useModelCompatState)
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
effectiveModelHidden: (modelId: string) => boolean;
getUpstreamHeadersRecordForModel: (modelId: string, protocol: string) => Record<string, string>;
// Translation
t: ProviderMessageTranslator;
}
export default function ProviderModelsSection({
providerId,
providerAlias,
providerStorageAlias,
providerDisplayAlias,
providerInfo,
isCcCompatible,
isAnthropicCompatible,
isAnthropicProtocolCompatible,
isManagedAvailableModelsProvider,
compatibleSupportsModelImport,
models,
modelMeta,
modelAliases,
syncedAvailableModels,
compatibleFallbackModels,
copied,
onCopy,
onSetAlias,
onDeleteAlias,
fetchProviderModelMeta,
connections,
selectedConnection,
canImportModels,
importingModels,
handleImportModels,
isAutoSyncEnabled,
togglingAutoSync,
handleToggleAutoSync,
handleCompatibleImportWithProgress,
compatSavingModelId,
togglingModelId,
bulkVisibilityAction,
clearingModels,
modelFilter,
testingModelId,
modelTestStatus,
testingAll,
testProgress,
autoHideFailed,
visibilityFilter,
providerAliasEntries,
setModelFilter,
setAutoHideFailed,
setVisibilityFilter,
saveModelCompatFlags,
handleToggleModelHidden,
handleBulkToggleModelHidden,
handleClearAllModels,
onTestModel,
handleTestAll,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
effectiveModelHidden,
getUpstreamHeadersRecordForModel,
t,
}: ProviderModelsSectionProps) {
const autoSyncToggle = compatibleSupportsModelImport && canImportModels && (
<button
onClick={handleToggleAutoSync}
disabled={togglingAutoSync}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-border bg-transparent cursor-pointer text-[12px] disabled:opacity-50 disabled:cursor-not-allowed"
title={t("autoSyncTooltip")}
>
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isAutoSyncEnabled ? "#22c55e" : "var(--color-text-muted)" }}
>
{isAutoSyncEnabled ? "toggle_on" : "toggle_off"}
</span>
<span className="text-text-main">{t("autoSync")}</span>
</button>
);
const clearAllButton = (modelMeta.customModels.length > 0 ||
providerAliasEntries.length > 0) && (
<button
onClick={handleClearAllModels}
disabled={clearingModels}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-red-300 dark:border-red-800 bg-transparent cursor-pointer text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
title={t("clearAllModels")}
>
<span className="material-symbols-outlined text-[16px]">delete_sweep</span>
<span>{t("clearAllModels")}</span>
</button>
);
if (isManagedAvailableModelsProvider) {
const description =
providerId === "openrouter"
? t("openRouterAnyModelHint")
: isCcCompatible
? t("ccCompatibleModelsDescription")
: t("compatibleModelsDescription", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
});
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
const inputPlaceholder =
providerId === "openrouter"
? t("openRouterModelPlaceholder")
: isCcCompatible
? "claude-sonnet-4-6"
: isAnthropicCompatible
? t("anthropicCompatibleModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
return (
<div>
<div className="flex items-center gap-2 mb-4">
{autoSyncToggle}
{clearAllButton}
</div>
<CompatibleModelsSection
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
fallbackModels={compatibleFallbackModels}
description={description}
inputLabel={inputLabel}
inputPlaceholder={inputPlaceholder}
copied={copied}
onCopy={onCopy}
onSetAlias={onSetAlias}
onDeleteAlias={onDeleteAlias}
connections={connections}
isAnthropic={isAnthropicProtocolCompatible}
onImportWithProgress={handleCompatibleImportWithProgress}
t={t}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
onModelsChanged={fetchProviderModelMeta}
allowImport={compatibleSupportsModelImport}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
onTestModel={onTestModel}
modelTestStatus={modelTestStatus}
testingModelId={testingModelId}
onTestAll={handleTestAll}
testingAll={testingAll}
testProgress={testProgress}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
</div>
);
}
if (providerInfo?.passthroughModels) {
const passthroughDescription =
providerId === "openrouter"
? t("openRouterAnyModelHint")
: providerId === "bedrock"
? t("bedrockModelsDescription")
: t("passthroughModelsDescription", { provider: providerInfo?.name || providerId });
const passthroughInputLabel =
providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
const passthroughInputPlaceholder =
providerId === "openrouter"
? t("openRouterModelPlaceholder")
: providerId === "bedrock"
? t("bedrockModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
return (
<div>
<div className="flex items-center gap-2 mb-4">
<Button
size="sm"
variant="secondary"
icon="download"
onClick={handleImportModels}
disabled={!canImportModels || importingModels}
>
{importingModels ? t("importingModels") : t("importFromModels")}
</Button>
{autoSyncToggle}
{clearAllButton}
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
</div>
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
description={passthroughDescription}
inputLabel={passthroughInputLabel}
inputPlaceholder={passthroughInputPlaceholder}
copied={copied}
onCopy={onCopy}
onSetAlias={onSetAlias}
onDeleteAlias={onDeleteAlias}
t={t}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
onTestModel={onTestModel}
modelTestStatus={modelTestStatus}
testingModelId={testingModelId}
providerId={providerId}
connectionId={selectedConnection?.id ?? ""}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
</div>
);
}
const importButton = (
<div className="flex items-center gap-2 mb-4">
<Button
size="sm"
variant="secondary"
icon="download"
onClick={handleImportModels}
disabled={!canImportModels || importingModels}
>
{importingModels ? t("importingModels") : t("importFromModels")}
</Button>
{autoSyncToggle}
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
</div>
);
if (models.length === 0) {
return (
<div>
{importButton}
<p className="text-sm text-text-muted">{t("noModelsConfigured")}</p>
</div>
);
}
const modelsWithVisibility = models.map((model) => ({
...model,
isHidden: effectiveModelHidden(model.id),
}));
const filteredModels = modelsWithVisibility.filter((model) => {
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
modelId: model.id,
modelName: model.name,
source: model.source,
});
const matchesVisibility =
visibilityFilter === "all"
? true
: visibilityFilter === "visible"
? !model.isHidden
: model.isHidden;
return matchesQuery && matchesVisibility;
});
const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
const testAllTargets = filteredModels
.filter((m) => !m.isHidden)
.map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` }));
return (
<div>
{importButton}
{modelsWithVisibility.length > 0 && (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={modelsWithVisibility.length}
onSelectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
false
)
}
onDeselectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null}
deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null}
onTestAll={() => handleTestAll(testAllTargets)}
testingAll={testingAll}
testProgress={testProgress}
visibilityFilter={visibilityFilter}
onVisibilityFilterChange={setVisibilityFilter}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
)}
<div className="flex flex-wrap gap-3">
{filteredModels.map((model) => {
return (
<ModelRow
key={model.id}
model={model}
fullModel={`${providerDisplayAlias}/${model.id}`}
provider={providerId}
copied={copied}
onCopy={onCopy}
t={t}
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecordForModel(model.id, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === model.id}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerId, modelId, hidden)
}
togglingHidden={togglingModelId === model.id}
onTestModel={onTestModel}
testStatus={modelTestStatus[model.id] || null}
testingModel={testingModelId === model.id}
/>
);
})}
{filteredModels.length === 0 && modelFilter && (
<p className="text-sm text-text-muted py-2">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,382 @@
"use client";
/**
* useModelImportHandlers — Issue #3501 Phase 1k
*
* Owns import-progress state and handlers that were previously inline in
* ProviderDetailPageClient:
* - importingModels, showImportModal, importProgress, togglingAutoSync
* - handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync
* - canImportModels (derived), isAutoSyncEnabled (derived), autoSyncConnection (derived)
*
* Cycle-safe: imports only from leaf modules and React.
* No import from ProviderDetailPageClient.
*/
import React, { useState } from "react";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
import { useNotificationStore } from "@/store/notificationStore";
type NotifyStore = ReturnType<typeof useNotificationStore>;
// ──── types ──────────────────────────────────────────────────────────────────
export interface ImportProgress {
current: number;
total: number;
phase: "idle" | "fetching" | "importing" | "done" | "error";
status: string;
logs: string[];
error: string;
importedCount: number;
}
export interface UseModelImportHandlersParams {
providerId: string;
models: Array<{ id: string; name?: string }>;
modelMeta: { customModels: Array<{ id: string }>; modelCompatOverrides?: unknown[] };
modelAliases: Record<string, string>;
connections: Array<{ id?: string; isActive?: boolean; providerSpecificData?: Record<string, unknown> }>;
isFreeNoAuth: boolean;
handleSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise<void>;
fetchAliases: () => Promise<void>;
fetchProviderModelMeta: () => Promise<void>;
fetchConnections: () => Promise<void>;
notify: NotifyStore;
t: ProviderMessageTranslator;
providerStorageAlias: string;
}
export interface UseModelImportHandlersReturn {
importingModels: boolean;
showImportModal: boolean;
importProgress: ImportProgress;
togglingAutoSync: boolean;
canImportModels: boolean;
isAutoSyncEnabled: boolean;
autoSyncConnection: UseModelImportHandlersParams["connections"][number] | undefined;
setShowImportModal: (v: boolean) => void;
setImportProgress: React.Dispatch<React.SetStateAction<ImportProgress>>;
handleImportModels: () => Promise<void>;
handleCompatibleImportWithProgress: (connectionId: string) => Promise<void>;
handleToggleAutoSync: () => Promise<void>;
}
// ──── hook ───────────────────────────────────────────────────────────────────
export function useModelImportHandlers({
providerId,
models,
modelMeta,
modelAliases,
connections,
isFreeNoAuth,
handleSetAlias,
fetchAliases,
fetchProviderModelMeta,
fetchConnections,
notify,
t,
providerStorageAlias,
}: UseModelImportHandlersParams): UseModelImportHandlersReturn {
const [importingModels, setImportingModels] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [importProgress, setImportProgress] = useState<ImportProgress>({
current: 0,
total: 0,
phase: "idle",
status: "",
logs: [],
error: "",
importedCount: 0,
});
const [togglingAutoSync, setTogglingAutoSync] = useState(false);
// Derived
const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false);
const autoSyncConnection = connections.find((conn) => conn.isActive !== false);
const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync;
const handleImportModels = async () => {
if (importingModels) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection && !isFreeNoAuth) return;
const importTargetId = activeConnection?.id ?? providerId;
setImportingModels(true);
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: t("fetchingModels"),
logs: [],
error: "",
importedCount: 0,
});
try {
const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`);
const data = await res.json();
if (!res.ok) {
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("failedFetchModels"),
error: data.error || t("failedImportModels"),
}));
return;
}
const fetchedModels = data.models || [];
if (fetchedModels.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status: t("noModelsFound"),
logs: [t("noModelsReturnedFromEndpoint")],
}));
return;
}
const existingIds = new Set([
...(modelMeta.customModels || []).map((m: any) => m.id),
...models.map((m: any) => m.id),
]);
const newModels = fetchedModels.filter(
(model: any) => !existingIds.has(model.id || model.name || model.model)
);
if (newModels.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status: t("allModelsAlreadyImported") || "All models already imported",
logs: [t("noNewModelsToImport") || "No new models to import"],
importedCount: 0,
total: 0,
current: 0,
}));
return;
}
setImportProgress((prev) => ({
...prev,
phase: "importing",
total: newModels.length,
current: 0,
status: t("importingModelsProgress", { current: 0, total: newModels.length }),
logs: [
t("foundModelsStartingImport", { count: newModels.length }),
...(newModels.length < fetchedModels.length
? [
t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) ||
`Skipping ${fetchedModels.length - newModels.length} existing models`,
]
: []),
],
}));
let importedCount = 0;
for (let i = 0; i < newModels.length; i++) {
const model = newModels[i];
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
const parts = modelId.split("/");
const baseAlias = parts[parts.length - 1];
setImportProgress((prev) => ({
...prev,
current: i + 1,
status: t("importingModelsProgress", { current: i + 1, total: newModels.length }),
logs: [...prev.logs, t("importingModelById", { modelId })],
}));
await fetch("/api/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
modelId,
modelName: model.name || modelId,
source: "imported",
...(typeof model.apiFormat === "string" ? { apiFormat: model.apiFormat } : {}),
...(Array.isArray(model.supportedEndpoints)
? { supportedEndpoints: model.supportedEndpoints }
: {}),
}),
});
if (!modelAliases[baseAlias]) {
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
}
importedCount += 1;
}
await fetchAliases();
setImportProgress((prev) => ({
...prev,
phase: "done",
current: newModels.length,
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAddedExisting"),
logs: [
...prev.logs,
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));
if (importedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
console.log("Error importing models:", error);
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("importFailed"),
error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"),
}));
} finally {
setImportingModels(false);
}
};
const handleCompatibleImportWithProgress = async (connectionId: string) => {
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: t("fetchingModels"),
logs: [],
error: "",
importedCount: 0,
});
try {
const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, {
method: "POST",
signal: AbortSignal.timeout(60_000),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t("failedImportModels"));
}
const importedModels = Array.isArray(data.importedModels) ? data.importedModels : [];
const importedCount =
typeof data.importedCount === "number" ? data.importedCount : importedModels.length;
const changedCount =
typeof data.importedChanges?.total === "number"
? data.importedChanges.total
: importedCount;
const totalChangedCount =
changedCount +
(typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0);
if (importedModels.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAdded"),
logs: [
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
return;
}
setImportProgress((prev) => ({
...prev,
phase: "done",
total: importedModels.length,
current: importedModels.length,
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAdded"),
logs: [
t("foundModelsStartingImport", { count: importedModels.length }),
...importedModels.map((model: any) =>
t("importingModelById", { modelId: model.id || model.name || model.model })
),
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
console.log("Error importing models:", error);
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("importFailed"),
error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"),
}));
}
};
const handleToggleAutoSync = async () => {
if (!autoSyncConnection || togglingAutoSync) return;
setTogglingAutoSync(true);
try {
const newValue = !isAutoSyncEnabled;
await fetch(`/api/providers/${(autoSyncConnection as any).id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: { autoSync: newValue },
}),
});
await fetchConnections();
notify[newValue ? "success" : "info"](
newValue ? t("autoSyncEnabled") : t("autoSyncDisabled")
);
} catch (error) {
console.log("Error toggling auto-sync:", error);
notify.error(t("autoSyncToggleFailed"));
} finally {
setTogglingAutoSync(false);
}
};
return {
importingModels,
showImportModal,
importProgress,
togglingAutoSync,
canImportModels,
isAutoSyncEnabled,
autoSyncConnection,
setShowImportModal,
setImportProgress,
handleImportModels,
handleCompatibleImportWithProgress,
handleToggleAutoSync,
};
}

View File

@@ -0,0 +1,411 @@
"use client";
/**
* useModelVisibilityHandlers — Issue #3501 Phase 1l
*
* Owns model-visibility/compat state and handlers previously inline in
* ProviderDetailPageClient:
* - State: compatSavingModelId, togglingModelId, bulkVisibilityAction,
* clearingModels, modelFilter, testingModelId, modelTestStatus,
* testingAll, testProgress, autoHideFailed, visibilityFilter
* - Derived: providerAliasEntries
* - Handlers: saveModelCompatFlags, handleToggleModelHidden,
* handleBulkToggleModelHidden, handleClearAllModels,
* onTestModel, handleTestAll
*
* onTestModel and handleTestAll share handleToggleModelHidden — kept in the
* same hook to avoid cross-hook cycles.
*
* Cycle-safe: imports only from leaf modules. No import from
* ProviderDetailPageClient.
*/
import { useState, useMemo } from "react";
import {
formatProviderModelsErrorResponse,
providerText,
type ProviderMessageTranslator,
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { useNotificationStore } from "@/store/notificationStore";
type NotifyStore = ReturnType<typeof useNotificationStore>;
// ──── types ──────────────────────────────────────────────────────────────────
/** Subset of ModelCompatSavePatch fields needed by this hook. */
export interface ModelCompatSavePatch {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: CompatByProtocolMap;
isHidden?: boolean;
}
export interface UseModelVisibilityHandlersParams {
providerId: string;
modelAliases: Record<string, string>;
/** The computed custom-model map from useModelCompatState. */
customMap: Map<string, unknown>;
providerStorageAlias: string;
fetchProviderModelMeta: () => Promise<void>;
fetchAliases: () => Promise<void>;
notify: NotifyStore;
t: ProviderMessageTranslator;
formatProviderModelsErrorResponse?: typeof formatProviderModelsErrorResponse;
/** The current selected connection (may be null). */
selectedConnection: any;
/** The provider node (may be null). */
providerNode: any;
}
export interface UseModelVisibilityHandlersReturn {
compatSavingModelId: string | null;
togglingModelId: string | null;
bulkVisibilityAction: "select" | "deselect" | null;
clearingModels: boolean;
modelFilter: string;
testingModelId: string | null;
modelTestStatus: Record<string, "ok" | "error">;
testingAll: boolean;
testProgress: { done: number; total: number } | null;
autoHideFailed: boolean;
visibilityFilter: "all" | "visible" | "hidden";
providerAliasEntries: [string, string][];
setModelFilter: (v: string) => void;
setAutoHideFailed: (v: boolean) => void;
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
handleToggleModelHidden: (
providerKey: string,
modelId: string,
hidden: boolean
) => Promise<void>;
handleBulkToggleModelHidden: (
providerKey: string,
modelIds: string[],
hidden: boolean
) => Promise<void>;
handleClearAllModels: () => Promise<void>;
onTestModel: (modelId: string, fullModel: string) => Promise<void>;
handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
}
// ──── hook ───────────────────────────────────────────────────────────────────
export function useModelVisibilityHandlers({
providerId,
modelAliases,
customMap,
providerStorageAlias,
fetchProviderModelMeta,
fetchAliases,
notify,
t,
selectedConnection,
providerNode,
}: UseModelVisibilityHandlersParams): UseModelVisibilityHandlersReturn {
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<
"select" | "deselect" | null
>(null);
const [clearingModels, setClearingModels] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
const [testingAll, setTestingAll] = useState(false);
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
const [autoHideFailed, setAutoHideFailed] = useState(true);
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
const providerAliasEntries = useMemo(
() =>
Object.entries(modelAliases).filter(
([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`)
) as [string, string][],
[modelAliases, providerStorageAlias]
);
const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => {
setCompatSavingModelId(modelId);
try {
const c = customMap.get(modelId) as Record<string, unknown> | undefined;
let body: Record<string, unknown>;
const onlyCompatByProtocol =
patch.compatByProtocol &&
patch.normalizeToolCallId === undefined &&
patch.preserveOpenAIDeveloperRole === undefined &&
!("upstreamHeaders" in patch);
if (c) {
if (onlyCompatByProtocol) {
body = {
provider: providerId,
modelId,
compatByProtocol: patch.compatByProtocol,
};
} else {
body = {
provider: providerId,
modelId,
modelName: (c.name as string) || modelId,
source: (c.source as string) || "manual",
apiFormat: (c.apiFormat as string) || "chat-completions",
supportedEndpoints:
Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length
? c.supportedEndpoints
: ["chat"],
normalizeToolCallId:
patch.normalizeToolCallId !== undefined
? patch.normalizeToolCallId
: Boolean(c.normalizeToolCallId),
preserveOpenAIDeveloperRole:
patch.preserveOpenAIDeveloperRole !== undefined
? patch.preserveOpenAIDeveloperRole
: Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")
? Boolean(c.preserveOpenAIDeveloperRole)
: true,
};
if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol;
}
} else {
body = { provider: providerId, modelId, ...patch };
}
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await formatProviderModelsErrorResponse(res);
notify.error(
detail ? `${t("failedSaveCustomModel")}${detail}` : t("failedSaveCustomModel")
);
return;
}
} catch {
notify.error(t("failedSaveCustomModel"));
return;
} finally {
setCompatSavingModelId(null);
}
try {
await fetchProviderModelMeta();
} catch {
/* refresh failure is non-critical — data was already saved */
}
};
const handleToggleModelHidden = async (
providerKey: string,
modelId: string,
hidden: boolean
): Promise<void> => {
setTogglingModelId(modelId);
try {
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden }),
}
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
notify.error(detail || t("failedSaveCustomModel"));
return;
}
await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]);
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
setTogglingModelId(null);
}
};
const handleBulkToggleModelHidden = async (
providerKey: string,
modelIds: string[],
hidden: boolean
): Promise<void> => {
if (modelIds.length === 0) return;
setBulkVisibilityAction(hidden ? "deselect" : "select");
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden, modelIds }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
notify.error(detail || t("failedSaveCustomModel"));
return;
}
await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]);
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
setBulkVisibilityAction(null);
}
};
const handleClearAllModels = async () => {
if (clearingModels) return;
if (!confirm(t("clearAllModelsConfirm"))) return;
setClearingModels(true);
try {
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&all=true`,
{ method: "DELETE" }
);
if (res.ok) {
// Also delete all aliases that belong to this provider
await Promise.all(
providerAliasEntries.map(([alias]) =>
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
method: "DELETE",
}).catch(() => {})
)
);
await fetchProviderModelMeta();
await fetchAliases();
notify.success(t("clearAllModelsSuccess"));
} else {
notify.error(t("clearAllModelsFailed"));
}
} catch {
notify.error(t("clearAllModelsFailed"));
} finally {
setClearingModels(false);
}
};
const onTestModel = async (modelId: string, fullModel: string) => {
setTestingModelId(modelId);
setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined as any }));
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: selectedConnection?.provider || providerNode?.id || providerId,
modelId: fullModel,
connectionId: selectedConnection?.id,
}),
});
const data = await res.json();
if (res.ok && data.status === "ok") {
notify.success(
providerText(t, "testModelSuccess", `Model ${modelId} is working. Latency: ${data.latencyMs}ms`, {
modelId,
latencyMs: data.latencyMs,
})
);
setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" }));
} else {
notify.error(data.error || "Model test failed");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
await handleToggleModelHidden(providerStorageAlias, modelId, true);
}
} catch (err) {
notify.error("Network error testing model");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
await handleToggleModelHidden(providerStorageAlias, modelId, true);
} finally {
setTestingModelId(null);
}
};
const handleTestAll = async (
targets: Array<{ modelId: string; fullModel: string }>
): Promise<void> => {
if (testingAll) return;
if (targets.length === 0) {
notify.error(providerText(t, "noModelsToTest", "No models to test"));
return;
}
setTestingAll(true);
setTestProgress({ done: 0, total: targets.length });
let ok = 0;
let error = 0;
let hiddenCount = 0;
const CHUNK_SIZE = 3;
for (let i = 0; i < targets.length; i += CHUNK_SIZE) {
const chunk = targets.slice(i, i + CHUNK_SIZE);
await Promise.all(
chunk.map(async ({ modelId, fullModel }) => {
try {
const result: {
results?: Record<
string,
{
status?: "ok" | "error";
rateLimited?: boolean;
isTimeout?: boolean;
error?: string;
}
>;
} = await fetch("/api/models/test-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: providerId,
connectionId: selectedConnection?.id,
modelIds: [fullModel],
}),
}).then((r) => r.json());
const entry = result.results?.[fullModel];
if (entry?.status === "ok") {
ok++;
} else {
error++;
if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) {
await handleToggleModelHidden(providerStorageAlias, modelId, true);
hiddenCount++;
}
}
} catch (e) {
error++;
}
setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null));
})
);
}
notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }));
if (hiddenCount > 0) {
notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }));
}
setTestingAll(false);
setTestProgress(null);
};
return {
compatSavingModelId,
togglingModelId,
bulkVisibilityAction,
clearingModels,
modelFilter,
testingModelId,
modelTestStatus,
testingAll,
testProgress,
autoHideFailed,
visibilityFilter,
providerAliasEntries,
setModelFilter,
setAutoHideFailed,
setVisibilityFilter,
saveModelCompatFlags,
handleToggleModelHidden,
handleBulkToggleModelHidden,
handleClearAllModels,
onTestModel,
handleTestAll,
};
}