diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 26c5452f..a48c280a 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -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> = { + openai: "gpt-5.2", + anthropic: "claude-opus-4-5-20251101", +} + +const defaultBaseURLs: Partial> = { + ollama: "http://localhost:11434", + "openai-compatible": "http://localhost:1234/v1", +} + +function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [provider, setProvider] = useState("openai") + const [providerConfigs, setProviderConfigs] = useState>({ + 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>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(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 = {} + 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 }) => ( + + ) + + if (configLoading) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+ {/* Provider selection */} +
+ Provider +
+ {primaryProviders.map(renderProviderCard)} +
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map(renderProviderCard)} +
+ ) : ( + + )} +
+ + {/* Model selection */} +
+ Model + {modelsLoading ? ( +
+ + Loading models... +
+ ) : showModelInput ? ( + updateConfig(provider, { model: e.target.value })} + placeholder="Enter model ID" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ + {/* API Key */} + {requiresApiKey && ( +
+ API Key + updateConfig(provider, { apiKey: e.target.value })} + placeholder="Paste your API key" + /> +
+ )} + + {/* Base URL */} + {showBaseURL && ( +
+ Base URL + 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" + } + /> +
+ )} + + {/* Test status */} + {testState.status === "error" && ( +
+ {testState.error || "Connection test failed"} +
+ )} + {testState.status === "success" && ( +
+ + Connected and saved +
+ )} + + {/* Test & Save button */} + +
+ ) +} + +// --- Main Settings Dialog --- + export function SettingsDialog({ children }: SettingsDialogProps) { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState("models") @@ -131,7 +469,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(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) { {children} -
+
{/* Sidebar */}
@@ -239,7 +576,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Main content */} -
+
{/* Header */}

{activeTabConfig.label}

@@ -249,8 +586,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Content */} -
- {activeTab === "appearance" ? ( +
+ {activeTab === "models" ? ( + + ) : activeTab === "appearance" ? ( ) : loading ? (
@@ -267,8 +606,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) { )}
- {/* Footer - only show for config tabs */} - {isConfigTab && ( + {/* Footer - only show for JSON config tabs */} + {isJsonTab && (
{error && (