diff --git a/e2e-tests/edit_custom_models.spec.ts b/e2e-tests/edit_custom_models.spec.ts new file mode 100644 index 0000000..e2092cc --- /dev/null +++ b/e2e-tests/edit_custom_models.spec.ts @@ -0,0 +1,55 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test("edit custom model", async ({ po }) => { + await po.setUp(); + await po.goToSettingsTab(); + await po.page.getByText("test-provider").click(); + + // test edit model by double clicking the model panel + await po.page + .locator(".text-lg.font-semibold", { hasText: "test-model" }) + .dblclick({ delay: 100 }); + await po.page.locator("#edit-model-id").clear(); + await po.page.locator("#edit-model-id").fill("new-model-id"); + await po.page.locator("#edit-model-name").clear(); + await po.page.locator("#edit-model-name").fill("new-model-name"); + await po.page.getByRole("button", { name: "Update Model" }).click(); + + // assert that the model was updated + await po.page + .locator(".text-lg.font-semibold", { hasText: "new-model-name" }) + .dblclick({ delay: 100 }); + await expect(po.page.locator("#edit-model-id")).toHaveValue("new-model-id"); + await expect(po.page.locator("#edit-model-name")).toHaveValue( + "new-model-name", + ); + await po.page.getByRole("button", { name: "Cancel" }).click(); + + // test edit model by clicking the edit button + await po.page + .locator('button svg path[d*="M11 5H6a2"]') + .locator("..") + .locator("..") + .click(); + await po.page.locator("#edit-model-id").clear(); + await po.page.locator("#edit-model-id").fill("another-model-id"); + await po.page.locator("#edit-model-name").clear(); + await po.page.locator("#edit-model-name").fill("another-model-name"); + await po.page.getByRole("button", { name: "Update Model" }).click(); + + // assert that the model was updated + await po.page + .locator(".text-lg.font-semibold", { hasText: "another-model-name" }) + .dblclick({ delay: 100 }); + await expect(po.page.locator("#edit-model-id")).toHaveValue( + "another-model-id", + ); + await expect(po.page.locator("#edit-model-name")).toHaveValue( + "another-model-name", + ); + await po.page.getByRole("button", { name: "Cancel" }).click(); + + // Make sure UI hasn't freezed + await po.goToAppsTab(); +}); diff --git a/src/components/EditCustomModelDialog.tsx b/src/components/EditCustomModelDialog.tsx new file mode 100644 index 0000000..a30ab0b --- /dev/null +++ b/src/components/EditCustomModelDialog.tsx @@ -0,0 +1,223 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useMutation } from "@tanstack/react-query"; +import { showError, showSuccess } from "@/lib/toast"; + +interface Model { + apiName: string; + displayName: string; + description?: string; + maxOutputTokens?: number; + contextWindow?: number; + type: "cloud" | "custom"; + tag?: string; +} + +interface EditCustomModelDialogProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + providerId: string; + model: Model | null; +} + +export function EditCustomModelDialog({ + isOpen, + onClose, + onSuccess, + providerId, + model, +}: EditCustomModelDialogProps) { + const [apiName, setApiName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [description, setDescription] = useState(""); + const [maxOutputTokens, setMaxOutputTokens] = useState(""); + const [contextWindow, setContextWindow] = useState(""); + + const ipcClient = IpcClient.getInstance(); + + useEffect(() => { + if (model) { + setApiName(model.apiName); + setDisplayName(model.displayName); + setDescription(model.description || ""); + setMaxOutputTokens(model.maxOutputTokens?.toString() || ""); + setContextWindow(model.contextWindow?.toString() || ""); + } + }, [model]); + + const mutation = useMutation({ + mutationFn: async () => { + if (!model) throw new Error("No model to edit"); + + const newParams = { + apiName, + displayName, + providerId, + description: description || undefined, + maxOutputTokens: maxOutputTokens + ? parseInt(maxOutputTokens, 10) + : undefined, + contextWindow: contextWindow ? parseInt(contextWindow, 10) : undefined, + }; + + if (!newParams.apiName) throw new Error("Model API name is required"); + if (!newParams.displayName) + throw new Error("Model display name is required"); + if (maxOutputTokens && isNaN(newParams.maxOutputTokens ?? NaN)) + throw new Error("Max Output Tokens must be a valid number"); + if (contextWindow && isNaN(newParams.contextWindow ?? NaN)) + throw new Error("Context Window must be a valid number"); + + // First delete the old model + await ipcClient.deleteCustomModel({ + providerId, + modelApiName: model.apiName, + }); + + // Then create the new model + await ipcClient.createCustomLanguageModel(newParams); + }, + onSuccess: () => { + showSuccess("Custom model updated successfully!"); + onSuccess(); + onClose(); + }, + onError: (error) => { + showError(error); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate(); + }; + + const handleClose = () => { + if (!mutation.isPending) { + onClose(); + } + }; + + if (!model) return null; + + return ( + + + + Edit Custom Model + + Modify the configuration of the selected language model. + + +
+
+
+ + ) => + setApiName(e.target.value) + } + className="col-span-3" + placeholder="This must match the model expected by the API" + required + disabled={mutation.isPending} + /> +
+
+ + ) => + setDisplayName(e.target.value) + } + className="col-span-3" + placeholder="Human-friendly name for the model" + required + disabled={mutation.isPending} + /> +
+
+ + ) => + setDescription(e.target.value) + } + className="col-span-3" + placeholder="Optional: Describe the model's capabilities" + disabled={mutation.isPending} + /> +
+
+ + ) => + setMaxOutputTokens(e.target.value) + } + className="col-span-3" + placeholder="Optional: e.g., 4096" + disabled={mutation.isPending} + /> +
+
+ + ) => + setContextWindow(e.target.value) + } + className="col-span-3" + placeholder="Optional: e.g., 8192" + disabled={mutation.isPending} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/settings/ModelsSection.tsx b/src/components/settings/ModelsSection.tsx index 7073c92..295f407 100644 --- a/src/components/settings/ModelsSection.tsx +++ b/src/components/settings/ModelsSection.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { CreateCustomModelDialog } from "@/components/CreateCustomModelDialog"; +import { EditCustomModelDialog } from "@/components/EditCustomModelDialog"; import { useLanguageModelsForProvider } from "@/hooks/useLanguageModelsForProvider"; // Use the hook directly here import { useDeleteCustomModel } from "@/hooks/useDeleteCustomModel"; // Import the new hook import { @@ -23,9 +24,12 @@ interface ModelsSectionProps { export function ModelsSection({ providerId }: ModelsSectionProps) { const [isCustomModelDialogOpen, setIsCustomModelDialogOpen] = useState(false); + const [isEditModelDialogOpen, setIsEditModelDialogOpen] = useState(false); const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(false); const [modelToDelete, setModelToDelete] = useState(null); + const [modelToEdit, setModelToEdit] = useState(null); + const [selectedModel, setSelectedModel] = useState(null); // Fetch custom models within this component now const { @@ -51,6 +55,21 @@ export function ModelsSection({ providerId }: ModelsSectionProps) { setIsConfirmDeleteDialogOpen(true); }; + const handleEditClick = (model: any) => { + setModelToEdit(model); + setIsEditModelDialogOpen(true); + }; + + const handleModelClick = (modelApiName: string) => { + setSelectedModel(selectedModel === modelApiName ? null : modelApiName); + }; + + const handleModelDoubleClick = (model: any) => { + if (model.type === "custom") { + handleEditClick(model); + } + }; + const handleConfirmDelete = () => { if (modelToDelete) { deleteModel({ providerId, modelApiName: modelToDelete }); @@ -85,22 +104,56 @@ export function ModelsSection({ providerId }: ModelsSectionProps) { {models.map((model) => (
handleModelClick(model.apiName)} + onDoubleClick={() => handleModelDoubleClick(model)} >

{model.displayName}

{model.type === "custom" && ( - +
+ + +
)}

@@ -155,7 +208,7 @@ export function ModelsSection({ providerId }: ModelsSectionProps) { )} - {/* Render the dialog */} + {/* Render the dialogs */} setIsCustomModelDialogOpen(false)} @@ -166,6 +219,17 @@ export function ModelsSection({ providerId }: ModelsSectionProps) { providerId={providerId} /> + setIsEditModelDialogOpen(false)} + onSuccess={() => { + setIsEditModelDialogOpen(false); + refetchModels(); // Refetch models on success + }} + providerId={providerId} + model={modelToEdit} + /> +