add editable custom model (#794)
Now users can free to edit their custom models by double clicking any custom models created in each provider. Before this, they have to delete -> create a new one. I simply add an edit panel (which looks the same as 'Add Custom Model') and integrate that process into the "update" button. There is one more issue that if a user deletes a model that he was using in chat, then back to chat, that model would still appear (and work) unless user chooses a new one. Tried to modify "delete-custom-model" in language_model_handlers.ts by the logic that if the name of that model matches the latest using one -> switch to auto (or default) model. Yet I failed, maybe need more explanation for this :)
This commit is contained in:
55
e2e-tests/edit_custom_models.spec.ts
Normal file
55
e2e-tests/edit_custom_models.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
223
src/components/EditCustomModelDialog.tsx
Normal file
223
src/components/EditCustomModelDialog.tsx
Normal file
@@ -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<string>("");
|
||||||
|
const [contextWindow, setContextWindow] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[525px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Custom Model</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Modify the configuration of the selected language model.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-model-id" className="text-right">
|
||||||
|
Model ID*
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-model-id"
|
||||||
|
value={apiName}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setApiName(e.target.value)
|
||||||
|
}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder="This must match the model expected by the API"
|
||||||
|
required
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-model-name" className="text-right">
|
||||||
|
Name*
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-model-name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setDisplayName(e.target.value)
|
||||||
|
}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder="Human-friendly name for the model"
|
||||||
|
required
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-description" className="text-right">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setDescription(e.target.value)
|
||||||
|
}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder="Optional: Describe the model's capabilities"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-max-output-tokens" className="text-right">
|
||||||
|
Max Output Tokens
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-max-output-tokens"
|
||||||
|
type="number"
|
||||||
|
value={maxOutputTokens}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setMaxOutputTokens(e.target.value)
|
||||||
|
}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder="Optional: e.g., 4096"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="edit-context-window" className="text-right">
|
||||||
|
Context Window
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-context-window"
|
||||||
|
type="number"
|
||||||
|
value={contextWindow}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setContextWindow(e.target.value)
|
||||||
|
}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder="Optional: e.g., 8192"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Updating..." : "Update Model"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { CreateCustomModelDialog } from "@/components/CreateCustomModelDialog";
|
import { CreateCustomModelDialog } from "@/components/CreateCustomModelDialog";
|
||||||
|
import { EditCustomModelDialog } from "@/components/EditCustomModelDialog";
|
||||||
import { useLanguageModelsForProvider } from "@/hooks/useLanguageModelsForProvider"; // Use the hook directly here
|
import { useLanguageModelsForProvider } from "@/hooks/useLanguageModelsForProvider"; // Use the hook directly here
|
||||||
import { useDeleteCustomModel } from "@/hooks/useDeleteCustomModel"; // Import the new hook
|
import { useDeleteCustomModel } from "@/hooks/useDeleteCustomModel"; // Import the new hook
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +24,12 @@ interface ModelsSectionProps {
|
|||||||
|
|
||||||
export function ModelsSection({ providerId }: ModelsSectionProps) {
|
export function ModelsSection({ providerId }: ModelsSectionProps) {
|
||||||
const [isCustomModelDialogOpen, setIsCustomModelDialogOpen] = useState(false);
|
const [isCustomModelDialogOpen, setIsCustomModelDialogOpen] = useState(false);
|
||||||
|
const [isEditModelDialogOpen, setIsEditModelDialogOpen] = useState(false);
|
||||||
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] =
|
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [modelToDelete, setModelToDelete] = useState<string | null>(null);
|
const [modelToDelete, setModelToDelete] = useState<string | null>(null);
|
||||||
|
const [modelToEdit, setModelToEdit] = useState<any | null>(null);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch custom models within this component now
|
// Fetch custom models within this component now
|
||||||
const {
|
const {
|
||||||
@@ -51,6 +55,21 @@ export function ModelsSection({ providerId }: ModelsSectionProps) {
|
|||||||
setIsConfirmDeleteDialogOpen(true);
|
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 = () => {
|
const handleConfirmDelete = () => {
|
||||||
if (modelToDelete) {
|
if (modelToDelete) {
|
||||||
deleteModel({ providerId, modelApiName: modelToDelete });
|
deleteModel({ providerId, modelApiName: modelToDelete });
|
||||||
@@ -85,22 +104,56 @@ export function ModelsSection({ providerId }: ModelsSectionProps) {
|
|||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<div
|
<div
|
||||||
key={model.apiName + model.displayName}
|
key={model.apiName + model.displayName}
|
||||||
className="p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm"
|
className={`p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm cursor-pointer hover:shadow-md transition-shadow ${
|
||||||
|
selectedModel === model.apiName
|
||||||
|
? "ring-2 ring-blue-500 dark:ring-blue-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleModelClick(model.apiName)}
|
||||||
|
onDoubleClick={() => handleModelDoubleClick(model)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
|
||||||
{model.displayName}
|
{model.displayName}
|
||||||
</h4>
|
</h4>
|
||||||
{model.type === "custom" && (
|
{model.type === "custom" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteClick(model.apiName)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditClick(model);
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:text-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/50 h-8 w-8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick(model.apiName);
|
||||||
|
}}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="text-red-500 hover:text-red-700 hover:bg-red-100 dark:hover:bg-red-900/50 h-8 w-8"
|
className="text-red-500 hover:text-red-700 hover:bg-red-100 dark:hover:bg-red-900/50 h-8 w-8"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
@@ -155,7 +208,7 @@ export function ModelsSection({ providerId }: ModelsSectionProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render the dialog */}
|
{/* Render the dialogs */}
|
||||||
<CreateCustomModelDialog
|
<CreateCustomModelDialog
|
||||||
isOpen={isCustomModelDialogOpen}
|
isOpen={isCustomModelDialogOpen}
|
||||||
onClose={() => setIsCustomModelDialogOpen(false)}
|
onClose={() => setIsCustomModelDialogOpen(false)}
|
||||||
@@ -166,6 +219,17 @@ export function ModelsSection({ providerId }: ModelsSectionProps) {
|
|||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditCustomModelDialog
|
||||||
|
isOpen={isEditModelDialogOpen}
|
||||||
|
onClose={() => setIsEditModelDialogOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsEditModelDialogOpen(false);
|
||||||
|
refetchModels(); // Refetch models on success
|
||||||
|
}}
|
||||||
|
providerId={providerId}
|
||||||
|
model={modelToEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={isConfirmDeleteDialogOpen}
|
open={isConfirmDeleteDialogOpen}
|
||||||
onOpenChange={setIsConfirmDeleteDialogOpen}
|
onOpenChange={setIsConfirmDeleteDialogOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user