feat: implement model settings UI in settings dialog

This commit is contained in:
tusharmagar 2026-02-09 21:38:04 +05:30
parent 9a70e90220
commit 464f257271

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2 } from "lucide-react"
import {
Dialog,
@ -10,8 +10,17 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
type ConfigTab = "models" | "mcp" | "security" | "appearance"
@ -57,6 +66,8 @@ interface SettingsDialogProps {
children: React.ReactNode
}
// --- Theme option for Appearance tab ---
function ThemeOption({
label,
icon: Icon,
@ -121,6 +132,333 @@ function AppearanceSettings() {
)
}
// --- Model Settings UI ---
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
interface LlmModelOption {
id: string
name?: string
release_date?: string
}
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openai", name: "OpenAI", description: "GPT models" },
{ id: "anthropic", name: "Anthropic", description: "Claude models" },
{ id: "google", name: "Gemini", description: "Google AI Studio" },
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally" },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key" },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway" },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API" },
]
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-5-20251101",
}
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
openai: { apiKey: "", baseURL: "", model: "" },
anthropic: { apiKey: "", baseURL: "", model: "" },
google: { apiKey: "", baseURL: "", model: "" },
openrouter: { apiKey: "", baseURL: "", model: "" },
aigateway: { apiKey: "", baseURL: "", model: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
const activeConfig = providerConfigs[provider]
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const modelsForProvider = modelsCatalog[provider] || []
const showModelInput = isLocalProvider || modelsForProvider.length === 0
const isMoreProvider = moreProviders.some(p => p.id === provider)
const canTest =
activeConfig.model.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
// Load current config from file
useEffect(() => {
if (!dialogOpen) return
async function loadCurrentConfig() {
try {
setConfigLoading(true)
const result = await window.ipc.invoke("workspace:readFile", {
path: "config/models.json",
})
const parsed = JSON.parse(result.data)
if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor)
setProviderConfigs(prev => ({
...prev,
[flavor]: {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
model: parsed.model,
},
}))
}
} catch {
// No existing config or parse error - use defaults
} finally {
setConfigLoading(false)
}
}
loadCurrentConfig()
}, [dialogOpen])
// Load models catalog
useEffect(() => {
if (!dialogOpen) return
async function loadModels() {
try {
setModelsLoading(true)
setModelsError(null)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const p of result.providers || []) {
catalog[p.id] = p.models || []
}
setModelsCatalog(catalog)
} catch {
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
}
}
loadModels()
}, [dialogOpen])
// Set default models from catalog when catalog loads
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
setProviderConfigs(prev => {
const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const prov of cloudProviders) {
const models = modelsCatalog[prov]
if (models?.length && !next[prov].model) {
const preferred = preferredDefaults[prov]
const hasPreferred = preferred && models.some(m => m.id === preferred)
next[prov] = { ...next[prov], model: hasPreferred ? preferred : (models[0]?.id || "") }
}
}
return next
})
}, [modelsCatalog])
const handleTestAndSave = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
const providerConfig = {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
model: activeConfig.model.trim(),
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
await window.ipc.invoke("models:saveConfig", providerConfig)
setTestState({ status: "success" })
toast.success("Model configuration saved")
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
}
} catch {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, provider, activeConfig])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => (
<button
key={p.id}
onClick={() => {
setProvider(p.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-2.5 text-left transition-colors",
provider === p.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="text-sm font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
</button>
)
if (configLoading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-4">
{/* Provider selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 grid-cols-2">
{primaryProviders.map(renderProviderCard)}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 grid-cols-2 mt-2">
{moreProviders.map(renderProviderCard)}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Model selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading models...
</div>
) : showModelInput ? (
<Input
value={activeConfig.model}
onChange={(e) => updateConfig(provider, { model: e.target.value })}
placeholder="Enter model ID"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateConfig(provider, { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
{/* API Key */}
{requiresApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API Key</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
placeholder={
provider === "ollama"
? "http://localhost:11434"
: provider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">
{testState.error || "Connection test failed"}
</div>
)}
{testState.status === "success" && (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected and saved
</div>
)}
{/* Test & Save button */}
<Button
onClick={handleTestAndSave}
disabled={!canTest || testState.status === "testing"}
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
) : (
"Test & Save"
)}
</Button>
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
@ -131,7 +469,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState<string | null>(null)
const activeTabConfig = tabs.find((t) => t.id === activeTab)!
const isConfigTab = activeTab !== "appearance"
const isJsonTab = activeTab === "mcp" || activeTab === "security"
const formatJson = (jsonString: string): string => {
try {
@ -142,7 +480,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance") return
if (tab === "appearance" || tab === "models") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -164,11 +502,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}, [])
const saveConfig = async () => {
if (!isConfigTab || !activeTabConfig.path) return
if (!isJsonTab || !activeTabConfig.path) return
setSaving(true)
setError(null)
try {
// Validate JSON before saving
JSON.parse(content)
await window.ipc.invoke("workspace:writeFile", {
path: activeTabConfig.path,
@ -193,13 +530,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const hasChanges = content !== originalContent
useEffect(() => {
if (open && activeTab !== "appearance") {
if (open && isJsonTab) {
loadConfig(activeTab)
}
}, [open, activeTab, loadConfig])
}, [open, activeTab, isJsonTab, loadConfig])
const handleTabChange = (tab: ConfigTab) => {
if (hasChanges) {
if (isJsonTab && hasChanges) {
if (!confirm("You have unsaved changes. Discard them?")) {
return
}
@ -211,9 +548,9 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="!max-w-[900px] w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
>
<div className="flex h-full">
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
<div className="px-2 py-3 mb-2">
@ -239,7 +576,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex flex-col min-w-0 min-h-0">
{/* Header */}
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
@ -249,8 +586,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className="flex-1 p-4 overflow-hidden">
{activeTab === "appearance" ? (
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
{activeTab === "models" ? (
<ModelSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
@ -267,8 +606,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
)}
</div>
{/* Footer - only show for config tabs */}
{isConfigTab && (
{/* Footer - only show for JSON config tabs */}
{isJsonTab && (
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{error && (