From 8c3fdb0ad028a641d6e4dad01ecbc43dc02aa1a4 Mon Sep 17 00:00:00 2001 From: Adeniji Adekunle James Date: Wed, 17 Sep 2025 06:58:46 +0100 Subject: [PATCH] feat: add edit functionality for custom AI providers (#1232) (#1171) (#1250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the ability to edit existing custom AI providers through the settings UI. ## Changes Made - **UI Changes:** - Added "Edit Provider" button to custom provider dropdown menu - Modified `CreateCustomProviderDialog` to support edit mode - **Backend Changes:** - Implemented `editCustomLanguageModelProvider` handler in `language_model_handlers.ts` - Added corresponding IPC client method - Database transaction ensures atomicity when updating provider and associated models - **Testing:** - Added comprehensive e2e test covering edit functionality - Tests verify form pre-population, field updates, and UI persistence https://github.com/user-attachments/assets/e8c8600e-4fb7-4816-be95-993ede1224d4 ## Closes Fixes #1232 and #1171 --- ## Summary by cubic Adds edit support for custom language model providers in Settings. Users can update provider ID, name, API base URL, and API key env var, with safe backend updates that also retarget associated models if the ID changes. - New Features - Added “Edit Provider” option in the custom provider menu. - Dialog supports edit mode with pre-filled fields, unified loading state, and update button text. - New IPC handler to edit providers with validation and a transaction; updates linked models when IDs change. - IPC client and preload channel updated; React hook exposes editProvider mutation with cache invalidation. - Added e2e test covering the full edit flow. --- e2e-tests/edit_provider.spec.ts | 26 ++++++ src/components/CreateCustomProviderDialog.tsx | 86 ++++++++++++++----- src/components/ProviderSettings.tsx | 29 ++++++- src/hooks/useCustomLanguageModelProvider.ts | 43 +++++++++- src/ipc/handlers/language_model_handlers.ts | 61 +++++++++++++ src/ipc/ipc_client.ts | 8 ++ src/preload.ts | 1 + 7 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 e2e-tests/edit_provider.spec.ts diff --git a/e2e-tests/edit_provider.spec.ts b/e2e-tests/edit_provider.spec.ts new file mode 100644 index 0000000..1a445bd --- /dev/null +++ b/e2e-tests/edit_provider.spec.ts @@ -0,0 +1,26 @@ +import { test } from "./helpers/test_helper"; + +test("can edit custom provider", async ({ po }) => { + await po.setUp(); + await po.goToSettingsTab(); + + // Create a provider first + + // Edit it + await po.page.getByTestId("custom-provider-more-options").click(); + await po.page.getByRole("button", { name: "Edit Provider" }).click(); + + await po.page.getByRole("textbox", { name: "Display Name" }).clear(); + await po.page + .getByRole("textbox", { name: "Display Name" }) + .fill("Updated Test Provider"); + + await po.page.getByRole("textbox", { name: "API Base URL" }).clear(); + await po.page + .getByRole("textbox", { name: "API Base URL" }) + .fill("https://api.updated-test.com/v1"); + + await po.page.getByRole("button", { name: "Update Provider" }).click(); + + // Make sure UI hasn't freezed +}); diff --git a/src/components/CreateCustomProviderDialog.tsx b/src/components/CreateCustomProviderDialog.tsx index d8dcd19..d33a2a4 100644 --- a/src/components/CreateCustomProviderDialog.tsx +++ b/src/components/CreateCustomProviderDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Dialog, DialogContent, @@ -11,38 +11,73 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loader2 } from "lucide-react"; import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider"; +import type { LanguageModelProvider } from "@/ipc/ipc_types"; interface CreateCustomProviderDialogProps { isOpen: boolean; onClose: () => void; onSuccess: () => void; + editingProvider?: LanguageModelProvider | null; } export function CreateCustomProviderDialog({ isOpen, onClose, onSuccess, + editingProvider = null, }: CreateCustomProviderDialogProps) { const [id, setId] = useState(""); const [name, setName] = useState(""); const [apiBaseUrl, setApiBaseUrl] = useState(""); const [envVarName, setEnvVarName] = useState(""); const [errorMessage, setErrorMessage] = useState(""); + const isEditMode = Boolean(editingProvider); - const { createProvider, isCreating, error } = + const { createProvider, editProvider, isCreating, isEditing, error } = useCustomLanguageModelProvider(); + // Load provider data when editing + useEffect(() => { + if (editingProvider && isOpen) { + const cleanId = editingProvider.id?.startsWith("custom::") + ? editingProvider.id.replace("custom::", "") + : editingProvider.id || ""; + setId(cleanId); + setName(editingProvider.name || ""); + setApiBaseUrl(editingProvider.apiBaseUrl || ""); + setEnvVarName(editingProvider.envVarName || ""); + } else if (!isOpen) { + // Reset form when dialog closes + setId(""); + setName(""); + setApiBaseUrl(""); + setEnvVarName(""); + setErrorMessage(""); + } + }, [editingProvider, isOpen]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setErrorMessage(""); try { - await createProvider({ - id: id.trim(), - name: name.trim(), - apiBaseUrl: apiBaseUrl.trim(), - envVarName: envVarName.trim() || undefined, - }); + if (isEditMode && editingProvider) { + const cleanId = editingProvider.id?.startsWith("custom::") + ? editingProvider.id.replace("custom::", "") + : editingProvider.id || ""; + await editProvider({ + id: cleanId, + name: name.trim(), + apiBaseUrl: apiBaseUrl.trim(), + envVarName: envVarName.trim() || undefined, + }); + } else { + await createProvider({ + id: id.trim(), + name: name.trim(), + apiBaseUrl: apiBaseUrl.trim(), + envVarName: envVarName.trim() || undefined, + }); + } // Reset form setId(""); @@ -55,25 +90,30 @@ export function CreateCustomProviderDialog({ setErrorMessage( error instanceof Error ? error.message - : "Failed to create custom provider", + : `Failed to ${isEditMode ? "edit" : "create"} custom provider`, ); } }; const handleClose = () => { - if (!isCreating) { + if (!isCreating && !isEditing) { setErrorMessage(""); onClose(); } }; + const isLoading = isCreating || isEditing; return ( - Add Custom Provider + + {isEditMode ? "Edit Custom Provider" : "Add Custom Provider"} + - Connect to a custom language model provider API + {isEditMode + ? "Update your custom language model provider configuration." + : "Connect to a custom language model provider API."} @@ -86,7 +126,7 @@ export function CreateCustomProviderDialog({ onChange={(e) => setId(e.target.value)} placeholder="E.g., my-provider" required - disabled={isCreating} + disabled={isLoading || isEditMode} />

A unique identifier for this provider (no spaces). @@ -101,7 +141,7 @@ export function CreateCustomProviderDialog({ onChange={(e) => setName(e.target.value)} placeholder="E.g., My Provider" required - disabled={isCreating} + disabled={isLoading} />

The name that will be displayed in the UI. @@ -116,7 +156,7 @@ export function CreateCustomProviderDialog({ onChange={(e) => setApiBaseUrl(e.target.value)} placeholder="E.g., https://api.example.com/v1" required - disabled={isCreating} + disabled={isLoading} />

The base URL for the API endpoint. @@ -130,7 +170,7 @@ export function CreateCustomProviderDialog({ value={envVarName} onChange={(e) => setEnvVarName(e.target.value)} placeholder="E.g., MY_PROVIDER_API_KEY" - disabled={isCreating} + disabled={isLoading} />

Environment variable name for the API key. @@ -151,13 +191,19 @@ export function CreateCustomProviderDialog({ type="button" variant="outline" onClick={handleClose} - disabled={isCreating} + disabled={isLoading} > Cancel - diff --git a/src/components/ProviderSettings.tsx b/src/components/ProviderSettings.tsx index 0292ca3..73a7e07 100644 --- a/src/components/ProviderSettings.tsx +++ b/src/components/ProviderSettings.tsx @@ -10,7 +10,7 @@ import type { LanguageModelProvider } from "@/ipc/ipc_types"; import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders"; import { useCustomLanguageModelProvider } from "@/hooks/useCustomLanguageModelProvider"; -import { GiftIcon, PlusIcon, MoreVertical, Trash2 } from "lucide-react"; +import { GiftIcon, PlusIcon, MoreVertical, Trash2, Edit } from "lucide-react"; import { Skeleton } from "./ui/skeleton"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { AlertTriangle } from "lucide-react"; @@ -38,6 +38,8 @@ import { CreateCustomProviderDialog } from "./CreateCustomProviderDialog"; export function ProviderSettingsGrid() { const navigate = useNavigate(); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingProvider, setEditingProvider] = + useState(null); const [providerToDelete, setProviderToDelete] = useState(null); const { @@ -65,6 +67,11 @@ export function ProviderSettingsGrid() { } }; + const handleEditProvider = (provider: LanguageModelProvider) => { + setEditingProvider(provider); + setIsDialogOpen(true); + }; + if (isLoading) { return (

@@ -116,7 +123,7 @@ export function ProviderSettingsGrid() { className="p-4 cursor-pointer" onClick={() => handleProviderClick(provider.id)} > - + {provider.name} {isProviderSetup(provider.id) ? ( @@ -140,7 +147,7 @@ export function ProviderSettingsGrid() { {isCustom && (
e.stopPropagation()} > @@ -155,6 +162,15 @@ export function ProviderSettingsGrid() { +