Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 191 additions & 39 deletions src/browser/components/Settings/sections/ModelsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Plus, Trash2, Pencil, Check, X, Loader2 } from "lucide-react";
import type { ProvidersConfigMap } from "../types";
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";

Expand All @@ -8,10 +8,18 @@ interface NewModelForm {
modelId: string;
}

interface EditingState {
provider: string;
originalModelId: string;
newModelId: string;
}

export function ModelsSection() {
const [config, setConfig] = useState<ProvidersConfigMap>({});
const [config, setConfig] = useState<ProvidersConfigMap | null>(null);
const [newModel, setNewModel] = useState<NewModelForm>({ provider: "", modelId: "" });
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<EditingState | null>(null);
const [error, setError] = useState<string | null>(null);

// Load config on mount
useEffect(() => {
Expand All @@ -21,26 +29,32 @@ export function ModelsSection() {
})();
}, []);

// Get all custom models across providers
const getAllModels = (): Array<{ provider: string; modelId: string }> => {
const models: Array<{ provider: string; modelId: string }> = [];
for (const [provider, providerConfig] of Object.entries(config)) {
if (providerConfig.models) {
for (const modelId of providerConfig.models) {
models.push({ provider, modelId });
}
}
}
return models;
};
// Check if a model already exists (for duplicate prevention)
const modelExists = useCallback(
(provider: string, modelId: string, excludeOriginal?: string): boolean => {
if (!config) return false;
const currentModels = config[provider]?.models ?? [];
return currentModels.some((m) => m === modelId && m !== excludeOriginal);
},
[config]
);

const handleAddModel = useCallback(async () => {
if (!newModel.provider || !newModel.modelId.trim()) return;
if (!config || !newModel.provider || !newModel.modelId.trim()) return;

const trimmedModelId = newModel.modelId.trim();

// Check for duplicates
if (modelExists(newModel.provider, trimmedModelId)) {
setError(`Model "${trimmedModelId}" already exists for this provider`);
return;
}

setError(null);
setSaving(true);
try {
const currentModels = config[newModel.provider]?.models ?? [];
const updatedModels = [...currentModels, newModel.modelId.trim()];
const updatedModels = [...currentModels, trimmedModelId];

await window.api.providers.setModels(newModel.provider, updatedModels);

Expand All @@ -54,10 +68,11 @@ export function ModelsSection() {
} finally {
setSaving(false);
}
}, [newModel, config]);
}, [newModel, config, modelExists]);

const handleRemoveModel = useCallback(
async (provider: string, modelId: string) => {
if (!config) return;
setSaving(true);
try {
const currentModels = config[provider]?.models ?? [];
Expand All @@ -78,6 +93,78 @@ export function ModelsSection() {
[config]
);

const handleStartEdit = useCallback((provider: string, modelId: string) => {
setEditing({ provider, originalModelId: modelId, newModelId: modelId });
setError(null);
}, []);

const handleCancelEdit = useCallback(() => {
setEditing(null);
setError(null);
}, []);

const handleSaveEdit = useCallback(async () => {
if (!config || !editing) return;

const trimmedModelId = editing.newModelId.trim();
if (!trimmedModelId) {
setError("Model ID cannot be empty");
return;
}

// Only validate duplicates if the model ID actually changed
if (trimmedModelId !== editing.originalModelId) {
if (modelExists(editing.provider, trimmedModelId)) {
setError(`Model "${trimmedModelId}" already exists for this provider`);
return;
}
}

setError(null);
setSaving(true);
try {
const currentModels = config[editing.provider]?.models ?? [];
const updatedModels = currentModels.map((m) =>
m === editing.originalModelId ? trimmedModelId : m
);

await window.api.providers.setModels(editing.provider, updatedModels);

// Refresh config
const cfg = await window.api.providers.getConfig();
setConfig(cfg);
setEditing(null);

// Notify other components about the change
window.dispatchEvent(new Event("providers-config-changed"));
} finally {
setSaving(false);
}
}, [editing, config, modelExists]);

// Show loading state while config is being fetched
if (config === null) {
return (
<div className="flex items-center justify-center gap-2 py-12">
<Loader2 className="text-muted h-5 w-5 animate-spin" />
<span className="text-muted text-sm">Loading settings...</span>
</div>
);
}

// Get all custom models across providers
const getAllModels = (): Array<{ provider: string; modelId: string }> => {
const models: Array<{ provider: string; modelId: string }> = [];
for (const [provider, providerConfig] of Object.entries(config)) {
if (providerConfig.models) {
for (const modelId of providerConfig.models) {
models.push({ provider, modelId });
}
}
}
return models;
};

const allModels = getAllModels();

return (
Expand Down Expand Up @@ -122,6 +209,7 @@ export function ModelsSection() {
Add
</button>
</div>
{error && !editing && <div className="text-error mt-2 text-xs">{error}</div>}
</div>

{/* List of custom models */}
Expand All @@ -130,29 +218,93 @@ export function ModelsSection() {
<div className="text-muted text-xs font-medium tracking-wide uppercase">
Custom Models
</div>
{allModels.map(({ provider, modelId }) => (
<div
key={`${provider}-${modelId}`}
className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-4 py-2"
>
<div className="flex items-center gap-3">
<span className="text-muted text-xs">
{PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ??
provider}
</span>
<span className="text-foreground font-mono text-sm">{modelId}</span>
</div>
<button
type="button"
onClick={() => void handleRemoveModel(provider, modelId)}
disabled={saving}
className="text-muted hover:text-error p-1 transition-colors"
title="Remove model"
{allModels.map(({ provider, modelId }) => {
const isEditing =
editing?.provider === provider && editing?.originalModelId === modelId;

return (
<div
key={`${provider}-${modelId}`}
className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-4 py-2"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="text-muted shrink-0 text-xs">
{PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ??
provider}
</span>
{isEditing ? (
<div className="flex min-w-0 flex-1 flex-col gap-1">
<input
type="text"
value={editing.newModelId}
onChange={(e) =>
setEditing((prev) =>
prev ? { ...prev, newModelId: e.target.value } : null
)
}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSaveEdit();
if (e.key === "Escape") handleCancelEdit();
}}
className="bg-modal-bg border-border-medium focus:border-accent min-w-0 flex-1 rounded border px-2 py-1 font-mono text-xs focus:outline-none"
autoFocus
/>
{error && <div className="text-error text-xs">{error}</div>}
</div>
) : (
<span className="text-foreground min-w-0 truncate font-mono text-sm">
{modelId}
</span>
)}
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
{isEditing ? (
<>
<button
type="button"
onClick={() => void handleSaveEdit()}
disabled={saving}
className="text-accent hover:text-accent-dark p-1 transition-colors"
title="Save changes (Enter)"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleCancelEdit}
disabled={saving}
className="text-muted hover:text-foreground p-1 transition-colors"
title="Cancel (Escape)"
>
<X className="h-4 w-4" />
</button>
</>
) : (
<>
<button
type="button"
onClick={() => handleStartEdit(provider, modelId)}
disabled={saving || editing !== null}
className="text-muted hover:text-foreground p-1 transition-colors disabled:opacity-50"
title="Edit model"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void handleRemoveModel(provider, modelId)}
disabled={saving || editing !== null}
className="text-muted hover:text-error p-1 transition-colors disabled:opacity-50"
title="Remove model"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-muted py-8 text-center text-sm">
Expand Down