From 184d5814091de078898a6381e661d11b6f49d8b1 Mon Sep 17 00:00:00 2001 From: Ali Alakbarli Date: Sat, 7 Mar 2026 06:51:50 -0800 Subject: [PATCH] feat: Enhanced custom model profiles with multiple models per profile ## Summary - Support multiple models per custom profile (e.g., multiple models under one OpenRouter API) - Custom models now appear individually in model selector dropdown with profile name as subtitle - Added visibility toggle for custom models in Settings > Models section - Fixed IPC transport to use activeConfigAtom for proper profile/model selection - Added search filtering for custom models in Settings - Removed redundant "Use" button from profile management (models are selected directly from dropdown) ## Changes - Updated ModelProfile type to include `models: CustomModelConfig[]` array - Added `activeCustomModelIdAtom` to track selected model within a profile - Added migration logic for old profile structure - Updated AgentModelSelector to show individual custom models with profile subtitle - Added `hiddenModelIds` prop to filter hidden models from dropdown - Fixed `ipc-chat-transport.ts` to use `activeConfigAtom` instead of legacy `customClaudeConfigAtom` - Added Custom Models subsection in Settings > Models with visibility toggles - Added search filtering for custom models in Settings ## Test plan - [ ] Create a custom profile with multiple models - [ ] Verify each model appears separately in the model dropdown - [ ] Verify profile name shows as subtitle under each custom model - [ ] Toggle custom model visibility in Settings and verify dropdown updates - [ ] Search in Settings and verify custom models are filtered - [ ] Edit/remove profiles and verify changes persist Made-with: Cursor --- .../settings-tabs/agents-models-tab.tsx | 528 ++++++++++++++---- .../components/agent-model-selector.tsx | 91 ++- .../features/agents/lib/ipc-chat-transport.ts | 10 +- .../features/agents/main/active-chat.tsx | 9 +- .../features/agents/main/chat-input-area.tsx | 50 +- .../features/agents/main/new-chat-form.tsx | 50 +- src/renderer/lib/atoms/index.ts | 132 ++++- 7 files changed, 674 insertions(+), 196 deletions(-) diff --git a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx index eb692afd..df9e5afb 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx @@ -1,6 +1,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { ChevronDown, MoreHorizontal, Plus, Trash2 } from "lucide-react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ChevronDown, Edit2, MoreHorizontal, Plus, Trash2, Check, Settings } from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" import { toast } from "sonner" import { agentsLoginModalOpenAtom, @@ -9,11 +9,13 @@ import { codexLoginModalOpenAtom, codexOnboardingAuthMethodAtom, codexOnboardingCompletedAtom, - customClaudeConfigAtom, hiddenModelsAtom, + modelProfilesAtom, + activeProfileIdAtom, normalizeCodexApiKey, openaiApiKeyAtom, - type CustomClaudeConfig, + type ModelProfile, + type CustomModelConfig, } from "../../../lib/atoms" import { ClaudeCodeIcon, CodexIcon, SearchIcon } from "../../ui/icons" import { CLAUDE_MODELS, CODEX_MODELS } from "../../../features/agents/lib/models" @@ -52,10 +54,262 @@ function useIsNarrowScreen(): boolean { return isNarrow } -const EMPTY_CONFIG: CustomClaudeConfig = { - model: "", - token: "", - baseUrl: "", +// Helper to generate unique IDs +const generateProfileId = () => `custom-${crypto.randomUUID()}` +const generateModelId = () => `model-${crypto.randomUUID()}` + +// Custom Model Profile Row Component - shows profile with models +function CustomProfileRow({ + profile, + onEdit, + onRemove, +}: { + profile: ModelProfile + onEdit: () => void + onRemove: () => void +}) { + const modelNames = profile.models.map(m => m.name).join(', ') + return ( +
+
+
{profile.name || 'Custom Model'}
+
+ {profile.models.length} model{profile.models.length !== 1 ? 's' : ''}: {modelNames || 'No models set'} +
+
+ +
+ + + + + + + + Edit + + + + Remove + + + +
+
+ ) +} + +// Add/Edit Custom Profile Dialog with multiple models support +function CustomProfileDialog({ + open, + onOpenChange, + profile, + onSave, +}: { + open: boolean + onOpenChange: (open: boolean) => void + profile: ModelProfile | null // null = new profile + onSave: (profile: ModelProfile) => void +}) { + const [name, setName] = useState('') + const [token, setToken] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [models, setModels] = useState([]) + + useEffect(() => { + if (profile) { + setName(profile.name) + setToken(profile.token) + setBaseUrl(profile.baseUrl) + setModels(profile.models.length > 0 ? profile.models : []) + } else { + setName('') + setToken('') + setBaseUrl('') + setModels([]) + } + }, [profile, open]) + + const handleAddModel = () => { + setModels(prev => [...prev, { + id: generateModelId(), + name: '', + modelId: '', + }]) + } + + const handleRemoveModel = (modelId: string) => { + setModels(prev => prev.filter(m => m.id !== modelId)) + } + + const handleUpdateModel = (modelId: string, field: 'name' | 'modelId', value: string) => { + setModels(prev => prev.map(m => + m.id === modelId ? { ...m, [field]: value } : m + )) + } + + const handleSave = () => { + const trimmedName = name.trim() + const trimmedToken = token.trim() + const trimmedBaseUrl = baseUrl.trim() + + if (!trimmedName) { + toast.error('Profile name is required') + return + } + if (!trimmedToken) { + toast.error('API token is required') + return + } + if (!trimmedBaseUrl) { + toast.error('Base URL is required') + return + } + if (models.length === 0) { + toast.error('At least one model is required') + return + } + + // Validate all models have required fields + const validModels = models.filter(m => m.name.trim() && m.modelId.trim()) + if (validModels.length === 0) { + toast.error('At least one complete model is required') + return + } + + onSave({ + id: profile?.id || generateProfileId(), + name: trimmedName, + token: trimmedToken, + baseUrl: trimmedBaseUrl, + models: validModels.map(m => ({ + ...m, + name: m.name.trim(), + modelId: m.modelId.trim(), + })), + }) + onOpenChange(false) + } + + return ( +
onOpenChange(false)} + > +
e.stopPropagation()} + > +
+

+ {profile ? 'Edit Custom Model Profile' : 'Add Custom Model Profile'} +

+
+
+
+ + setName(e.target.value)} + placeholder="e.g., OpenRouter, Together AI" + /> +
+
+ + setBaseUrl(e.target.value)} + placeholder="https://openrouter.ai/api/v1" + /> +
+
+ + setToken(e.target.value)} + placeholder="sk-..." + /> +
+ + {/* Models Section */} +
+
+ + +
+ + {models.length === 0 ? ( +
+ No models added. Click "Add Model" to add one. +
+ ) : ( +
+ {models.map((model, index) => ( +
+
+ Model {index + 1} + +
+
+
+ + handleUpdateModel(model.id, 'name', e.target.value)} + placeholder="Claude 3 Opus" + className="h-8" + /> +
+
+ + handleUpdateModel(model.id, 'modelId', e.target.value)} + placeholder="anthropic/claude-3-opus" + className="h-8" + /> +
+
+
+ ))} +
+ )} +
+
+
+ + +
+
+
+ ) } // Account row component @@ -261,10 +515,6 @@ function AnthropicAccountsSection() { } export function AgentsModelsTab() { - const [storedConfig, setStoredConfig] = useAtom(customClaudeConfigAtom) - const [model, setModel] = useState(storedConfig.model) - const [baseUrl, setBaseUrl] = useState(storedConfig.baseUrl) - const [token, setToken] = useState(storedConfig.token) const setClaudeLoginModalConfig = useSetAtom(claudeLoginModalConfigAtom) const setClaudeLoginModalOpen = useSetAtom(agentsLoginModalOpenAtom) const setCodexLoginModalOpen = useSetAtom(codexLoginModalOpenAtom) @@ -275,6 +525,54 @@ export function AgentsModelsTab() { const { data: codexIntegration, isLoading: isCodexLoading } = trpc.codex.getIntegration.useQuery() + // Custom model profiles state + const [modelProfiles, setModelProfiles] = useAtom(modelProfilesAtom) + const [activeProfileId, setActiveProfileId] = useAtom(activeProfileIdAtom) + const [editingProfile, setEditingProfile] = useState(null) + const [isProfileDialogOpen, setIsProfileDialogOpen] = useState(false) + + // Filter out offline profile - only show custom profiles + const customProfiles = useMemo(() => + modelProfiles.filter(p => !p.isOffline), + [modelProfiles] + ) + + // Custom profile handlers + const handleAddProfile = useCallback(() => { + setEditingProfile(null) + setIsProfileDialogOpen(true) + }, []) + + const handleEditProfile = useCallback((profile: ModelProfile) => { + setEditingProfile(profile) + setIsProfileDialogOpen(true) + }, []) + + const handleSaveProfile = useCallback((profile: ModelProfile) => { + setModelProfiles(prev => { + const existing = prev.find(p => p.id === profile.id) + if (existing) { + return prev.map(p => p.id === profile.id ? profile : p) + } + return [...prev, profile] + }) + toast.success(editingProfile ? 'Profile updated' : 'Profile added') + }, [editingProfile, setModelProfiles]) + + const handleRemoveProfile = useCallback((profileId: string) => { + const profile = modelProfiles.find(p => p.id === profileId) + const confirmed = window.confirm( + `Are you sure you want to remove "${profile?.name || 'this profile'}"?` + ) + if (confirmed) { + setModelProfiles(prev => prev.filter(p => p.id !== profileId)) + if (activeProfileId === profileId) { + setActiveProfileId(null) + } + toast.success('Profile removed') + } + }, [modelProfiles, activeProfileId, setModelProfiles, setActiveProfileId]) + // OpenAI API key state const [storedCodexApiKey, setStoredCodexApiKey] = useAtom(codexApiKeyAtom) const [codexApiKey, setCodexApiKey] = useState(storedCodexApiKey) @@ -287,12 +585,6 @@ export function AgentsModelsTab() { const codexLogoutMutation = trpc.codex.logout.useMutation() const trpcUtils = trpc.useUtils() - useEffect(() => { - setModel(storedConfig.model) - setBaseUrl(storedConfig.baseUrl) - setToken(storedConfig.token) - }, [storedConfig.model, storedConfig.baseUrl, storedConfig.token]) - useEffect(() => { setOpenaiKey(storedOpenAIKey) }, [storedOpenAIKey]) @@ -301,48 +593,6 @@ export function AgentsModelsTab() { setCodexApiKey(storedCodexApiKey) }, [storedCodexApiKey]) - const savedConfigRef = useRef(storedConfig) - - const handleBlurSave = useCallback(() => { - const trimmedModel = model.trim() - const trimmedBaseUrl = baseUrl.trim() - const trimmedToken = token.trim() - - // Only save if all fields are filled - if (trimmedModel && trimmedBaseUrl && trimmedToken) { - const next: CustomClaudeConfig = { - model: trimmedModel, - token: trimmedToken, - baseUrl: trimmedBaseUrl, - } - if ( - next.model !== savedConfigRef.current.model || - next.token !== savedConfigRef.current.token || - next.baseUrl !== savedConfigRef.current.baseUrl - ) { - setStoredConfig(next) - savedConfigRef.current = next - } - } else if (!trimmedModel && !trimmedBaseUrl && !trimmedToken) { - // All cleared — reset - if (savedConfigRef.current.model || savedConfigRef.current.token || savedConfigRef.current.baseUrl) { - setStoredConfig(EMPTY_CONFIG) - savedConfigRef.current = EMPTY_CONFIG - } - } - }, [model, baseUrl, token, setStoredConfig]) - - const handleReset = () => { - setStoredConfig(EMPTY_CONFIG) - savedConfigRef.current = EMPTY_CONFIG - setModel("") - setBaseUrl("") - setToken("") - toast.success("Model settings reset") - } - - const canReset = Boolean(model.trim() || baseUrl.trim() || token.trim()) - const handleClaudeCodeSetup = () => { setClaudeLoginModalConfig({ hideCustomModelSettingsLink: true, @@ -502,6 +752,17 @@ export function AgentsModelsTab() { return allModels.filter((m) => m.name.toLowerCase().includes(q)) }, [allModels, modelSearch]) + // Filter custom models by search + const filteredCustomModels = useMemo(() => { + if (!modelSearch.trim()) return customProfiles + const q = modelSearch.toLowerCase().trim() + return customProfiles.filter((profile) => { + // Match profile name or any model name within the profile + if (profile.name.toLowerCase().includes(q)) return true + return profile.models.some(m => m.name.toLowerCase().includes(q)) + }) + }, [customProfiles, modelSearch]) + const [isApiKeysOpen, setIsApiKeysOpen] = useState(false) return ( @@ -529,7 +790,7 @@ export function AgentsModelsTab() { - {/* Model list */} + {/* Standard Model list */}
{filteredModels.map((m) => { const isEnabled = !hiddenModels.includes(m.id) @@ -553,12 +814,51 @@ export function AgentsModelsTab() {
) })} - {filteredModels.length === 0 && ( + {filteredModels.length === 0 && modelSearch.trim() && (
No models found
)} + + {/* Custom Models sub-section */} + {filteredCustomModels.length > 0 && ( + <> +
+ + Custom Models + +
+
+ {filteredCustomModels.map((profile) => + profile.models.map((model) => { + const customModelId = `custom-${profile.id}-${model.id}` + const isEnabled = !hiddenModels.includes(customModelId) + return ( +
+
+
+ {model.name} + + {profile.name} + +
+ +
+ toggleModelVisibility(customModelId)} + /> +
+ ) + }) + )} +
+ + )} @@ -734,74 +1034,56 @@ export function AgentsModelsTab() { - {/* Override Model */} + {/* Custom Model Profiles */}
-

- Override Model -

- {canReset && ( - - )} +
+

+ Custom Models +

+

+ Add custom API endpoints and models +

+
+
-
-
-
- -

- Model identifier to use for requests -

-
-
- setModel(e.target.value)} - onBlur={handleBlurSave} - className="w-full" - placeholder="claude-3-7-sonnet-20250219" + + {customProfiles.length > 0 ? ( +
+ {customProfiles.map((profile) => ( + handleEditProfile(profile)} + onRemove={() => handleRemoveProfile(profile.id)} /> -
+ ))}
- -
-
- -

- ANTHROPIC_AUTH_TOKEN env -

-
-
- setToken(e.target.value)} - onBlur={handleBlurSave} - className="w-full" - placeholder="sk-ant-..." - /> -
+ ) : ( +
+

+ No custom models configured +

+

+ Add a custom model to use alternative API endpoints +

+ )} -
-
- -

- ANTHROPIC_BASE_URL env -

-
-
- setBaseUrl(e.target.value)} - onBlur={handleBlurSave} - className="w-full" - placeholder="https://api.anthropic.com" - /> -
-
-
+ {/* Profile Edit Dialog */} +
diff --git a/src/renderer/features/agents/components/agent-model-selector.tsx b/src/renderer/features/agents/components/agent-model-selector.tsx index 56cc4333..4963a6e9 100644 --- a/src/renderer/features/agents/components/agent-model-selector.tsx +++ b/src/renderer/features/agents/components/agent-model-selector.tsx @@ -1,6 +1,6 @@ "use client" -import { Brain, ChevronRight, Zap } from "lucide-react" +import { Brain, ChevronRight, Settings, Zap } from "lucide-react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "motion/react" @@ -25,6 +25,7 @@ import { import { cn } from "../../../lib/utils" import type { CodexThinkingLevel } from "../lib/models" import { formatCodexThinkingLabel } from "../lib/models" +import type { ModelProfile, CustomModelConfig } from "../../../lib/atoms" const CROSS_PROVIDER_DIALOG_DISMISSED_KEY = "agent-model-selector:skip-cross-provider-dialog" @@ -59,6 +60,7 @@ interface AgentModelSelectorProps { contentClassName?: string onOpenModelsSettings?: () => void onContinueWithProvider?: (provider: AgentProviderId) => void + hiddenModelIds?: string[] // IDs of hidden models to filter out claude: { models: ClaudeModelOption[] selectedModelId?: string @@ -72,6 +74,12 @@ interface AgentModelSelectorProps { isConnected: boolean thinkingEnabled: boolean onThinkingChange: (enabled: boolean) => void + // Custom profiles support - multiple models per profile + customProfiles: ModelProfile[] + selectedProfileId: string | null + selectedCustomModelId: string | null + onSelectCustomModel: (profileId: string, modelId: string) => void + onClearCustomModel: () => void } codex: { models: CodexModelOption[] @@ -87,7 +95,7 @@ type FlatModelItem = | { type: "claude"; model: ClaudeModelOption } | { type: "codex"; model: CodexModelOption } | { type: "ollama"; modelName: string; isRecommended: boolean } - | { type: "custom" } + | { type: "customModel"; profile: ModelProfile; model: CustomModelConfig } function CodexThinkingSubMenu({ thinkings, @@ -102,7 +110,7 @@ function CodexThinkingSubMenu({ const subMenuRef = useRef(null) const [showSub, setShowSub] = useState(false) const [subPos, setSubPos] = useState({ top: 0, left: 0 }) - const closeTimeout = useRef>() + const closeTimeout = useRef | undefined>(undefined) const scheduleClose = useCallback(() => { closeTimeout.current = setTimeout(() => setShowSub(false), 150) @@ -321,6 +329,7 @@ export function AgentModelSelector({ contentClassName, onOpenModelsSettings, onContinueWithProvider, + hiddenModelIds = [], claude, codex, }: AgentModelSelectorProps) { @@ -343,20 +352,35 @@ export function AgentModelSelector({ isRecommended: m === claude.recommendedOllamaModel, }) } - } else if (claude.hasCustomModelConfig) { - items.push({ type: "custom" }) } else { + // Add standard Claude models (filter by hidden) for (const m of claude.models) { - items.push({ type: "claude", model: m }) + if (!hiddenModelIds.includes(m.id)) { + items.push({ type: "claude", model: m }) + } + } + // Add custom models from profiles (filter by hidden) + for (const profile of claude.customProfiles) { + if (!profile.isOffline) { + for (const model of profile.models) { + const customModelId = `custom-${profile.id}-${model.id}` + if (!hiddenModelIds.includes(customModelId)) { + items.push({ type: "customModel", profile, model }) + } + } + } } } + // Add Codex models (filter by hidden) for (const m of codex.models) { - items.push({ type: "codex", model: m }) + if (!hiddenModelIds.includes(m.id)) { + items.push({ type: "codex", model: m }) + } } return items - }, [claude, codex]) + }, [claude, codex, hiddenModelIds]) // Filter by search const filteredModels = useMemo(() => { @@ -374,8 +398,10 @@ export function AgentModelSelector({ return item.model.name.toLowerCase().includes(q) case "ollama": return item.modelName.toLowerCase().includes(q) - case "custom": - return "custom model".includes(q) + case "customModel": + return item.profile.name.toLowerCase().includes(q) || + item.model.name.toLowerCase().includes(q) || + item.model.modelId.toLowerCase().includes(q) } }) }, [allModels, search]) @@ -404,13 +430,15 @@ export function AgentModelSelector({ const isItemSelected = (item: FlatModelItem): boolean => { switch (item.type) { case "claude": - return selectedAgentId === "claude-code" && claude.selectedModelId === item.model.id + return selectedAgentId === "claude-code" && claude.selectedModelId === item.model.id && !claude.selectedProfileId case "codex": return selectedAgentId === "codex" && codex.selectedModelId === item.model.id case "ollama": return selectedAgentId === "claude-code" && claude.selectedOllamaModel === item.modelName - case "custom": - return selectedAgentId === "claude-code" + case "customModel": + return selectedAgentId === "claude-code" && + claude.selectedProfileId === item.profile.id && + claude.selectedCustomModelId === item.model.id } } @@ -474,6 +502,7 @@ export function AgentModelSelector({ if (!canSelectProvider("claude-code")) return onSelectedAgentIdChange("claude-code") claude.onSelectModel(item.model.id) + claude.onClearCustomModel() break case "codex": if (!canSelectProvider("codex")) return @@ -484,10 +513,12 @@ export function AgentModelSelector({ if (!canSelectProvider("claude-code")) return onSelectedAgentIdChange("claude-code") claude.onSelectOllamaModel(item.modelName) + claude.onClearCustomModel() break - case "custom": + case "customModel": if (!canSelectProvider("claude-code")) return onSelectedAgentIdChange("claude-code") + claude.onSelectCustomModel(item.profile.id, item.model.id) break } handleOpenChange(false) @@ -501,8 +532,8 @@ export function AgentModelSelector({ return case "ollama": return - case "custom": - return + case "customModel": + return } } @@ -514,9 +545,17 @@ export function AgentModelSelector({ return item.model.name case "ollama": return item.modelName + (item.isRecommended ? " (recommended)" : "") - case "custom": - return "Custom Model" + case "customModel": + return item.model.name + } + } + + // Get sub-label for items that need it (custom models show profile name) + const getItemSubLabel = (item: FlatModelItem): string | null => { + if (item.type === "customModel") { + return item.profile.name } + return null } const getItemKey = (item: FlatModelItem): string => { @@ -527,8 +566,8 @@ export function AgentModelSelector({ return `codex-${item.model.id}` case "ollama": return `ollama-${item.modelName}` - case "custom": - return "custom" + case "customModel": + return `custom-${item.profile.id}-${item.model.id}` } } @@ -561,7 +600,7 @@ export function AgentModelSelector({ {/* Claude thinking toggle */} {selectedAgentId === "claude-code" && !claude.isOffline && - !claude.hasCustomModelConfig && ( + !claude.selectedProfileId && ( <>
{getItemIcon(item)} - {getItemLabel(item)} +
+ {getItemLabel(item)} + {subLabel && ( + + {subLabel} + + )} +
{crossProvider && ( New chat )} diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index c014f151..728674c7 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -5,12 +5,10 @@ import { claudeLoginModalConfigAtom, agentsLoginModalOpenAtom, autoOfflineModeAtom, - type CustomClaudeConfig, - customClaudeConfigAtom, + activeConfigAtom, enableTasksAtom, extendedThinkingEnabledAtom, historyEnabledAtom, - normalizeCustomClaudeConfig, selectedOllamaModelAtom, sessionInfoAtom, showOfflineModeFeaturesAtom, @@ -173,10 +171,8 @@ export class IPCChatTransport implements ChatTransport { const selectedModelId = appStore.get(subChatModelIdAtomFamily(this.config.subChatId)) const modelString = MODEL_ID_MAP[selectedModelId] || MODEL_ID_MAP["opus"] - const storedCustomConfig = appStore.get( - customClaudeConfigAtom, - ) as CustomClaudeConfig - const customConfig = normalizeCustomClaudeConfig(storedCustomConfig) + // Use activeConfigAtom which considers both legacy config and new model profiles + const customConfig = appStore.get(activeConfigAtom) // Get selected Ollama model for offline mode const selectedOllamaModel = appStore.get(selectedOllamaModelAtom) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index cf85ed17..49ebacdd 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -64,10 +64,9 @@ import { trackMessageSent } from "../../../lib/analytics" import { apiFetch } from "../../../lib/api-fetch" import { chatSourceModeAtom, - customClaudeConfigAtom, + activeConfigAtom, defaultAgentModeAtom, isDesktopAtom, isFullscreenAtom, - normalizeCustomClaudeConfig, sessionInfoAtom, selectedOllamaModelAtom, soundNotificationsEnabledAtom @@ -4871,11 +4870,9 @@ export function ChatView({ const isDesktop = useAtomValue(isDesktopAtom) const isFullscreen = useAtomValue(isFullscreenAtom) const sidebarOpen = useAtomValue(agentsSidebarOpenAtom) - const customClaudeConfig = useAtomValue(customClaudeConfigAtom) + const customClaudeConfig = useAtomValue(activeConfigAtom) const selectedOllamaModel = useAtomValue(selectedOllamaModelAtom) - const normalizedCustomClaudeConfig = - normalizeCustomClaudeConfig(customClaudeConfig) - const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig) + const hasCustomClaudeConfig = Boolean(customClaudeConfig) const setLoadingSubChats = useSetAtom(loadingSubChatsAtom) const unseenChanges = useAtomValue(agentsUnseenChangesAtom) const setUnseenChanges = useSetAtom(agentsUnseenChangesAtom) diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 30ad3f25..7614cd2b 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -44,11 +44,13 @@ import { apiKeyOnboardingCompletedAtom, codexApiKeyAtom, codexOnboardingCompletedAtom, - customClaudeConfigAtom, + activeConfigAtom, extendedThinkingEnabledAtom, hiddenModelsAtom, + modelProfilesAtom, + activeProfileIdAtom, + activeCustomModelIdAtom, normalizeCodexApiKey, - normalizeCustomClaudeConfig, selectedOllamaModelAtom, showOfflineModeFeaturesAtom, } from "../../../lib/atoms" @@ -575,10 +577,23 @@ export const ChatInputArea = memo(function ChatInputArea({ setSelectedSubChatCodexThinking, ]) - const customClaudeConfig = useAtomValue(customClaudeConfigAtom) - const normalizedCustomClaudeConfig = - normalizeCustomClaudeConfig(customClaudeConfig) - const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig) + const customClaudeConfig = useAtomValue(activeConfigAtom) + const hasCustomClaudeConfig = Boolean(customClaudeConfig) + + // Custom model profiles + const [modelProfiles, setModelProfiles] = useAtom(modelProfilesAtom) + const [activeProfileId, setActiveProfileId] = useAtom(activeProfileIdAtom) + const [activeCustomModelId, setActiveCustomModelId] = useAtom(activeCustomModelIdAtom) + const customProfiles = modelProfiles.filter(p => !p.isOffline) + + // Helper to find active custom model + const activeCustomModel = useMemo(() => { + if (!activeProfileId || !activeCustomModelId) return null + const profile = customProfiles.find(p => p.id === activeProfileId) + if (!profile) return null + return profile.models.find(m => m.id === activeCustomModelId) || null + }, [activeProfileId, activeCustomModelId, customProfiles]) + const isClaudeConnected = Boolean(claudeCodeIntegration?.isConnected) || anthropicOnboardingCompleted || @@ -607,8 +622,9 @@ export const ChatInputArea = memo(function ChatInputArea({ return currentOllamaModel || "Ollama" } - if (hasCustomClaudeConfig) { - return "Custom Model" + // Show custom model name with profile if selected + if (activeProfileId && activeCustomModel) { + return activeCustomModel.name } if (!selectedModel) { @@ -622,7 +638,8 @@ export const ChatInputArea = memo(function ChatInputArea({ availableModels.isOffline, availableModels.hasOllama, currentOllamaModel, - hasCustomClaudeConfig, + activeProfileId, + activeCustomModel, selectedModel, ]) const canSwitchProvider = @@ -1560,6 +1577,7 @@ export const ChatInputArea = memo(function ChatInputArea({ setSettingsTab("models") setSettingsOpen(true) }} + hiddenModelIds={hiddenModels} claude={{ models: availableModels.models.filter((m) => !hiddenModels.includes(m.id)), selectedModelId: selectedModel?.id, @@ -1571,6 +1589,8 @@ export const ChatInputArea = memo(function ChatInputArea({ setSelectedModel(model) setSelectedSubChatModelId(model.id) setLastSelectedModelId(model.id) + // Clear profile selection when selecting standard model + setActiveProfileId(null) }, hasCustomModelConfig: hasCustomClaudeConfig, isOffline: availableModels.isOffline && availableModels.hasOllama, @@ -1581,6 +1601,18 @@ export const ChatInputArea = memo(function ChatInputArea({ isConnected: isClaudeConnected, thinkingEnabled, onThinkingChange: setThinkingEnabled, + // Custom profiles + customProfiles, + selectedProfileId: activeProfileId, + selectedCustomModelId: activeCustomModelId, + onSelectCustomModel: (profileId, modelId) => { + setActiveProfileId(profileId) + setActiveCustomModelId(modelId) + }, + onClearCustomModel: () => { + setActiveProfileId(null) + setActiveCustomModelId(null) + }, }} codex={{ models: codexUiModels, diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 6f0f6138..fb8f77cf 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -57,11 +57,13 @@ import { apiKeyOnboardingCompletedAtom, codexApiKeyAtom, codexOnboardingCompletedAtom, - customClaudeConfigAtom, + activeConfigAtom, extendedThinkingEnabledAtom, hiddenModelsAtom, + modelProfilesAtom, + activeProfileIdAtom, + activeCustomModelIdAtom, normalizeCodexApiKey, - normalizeCustomClaudeConfig, showOfflineModeFeaturesAtom, selectedOllamaModelAtom, customHotkeysAtom, @@ -236,10 +238,23 @@ export function NewChatForm({ }, []) const [workMode, setWorkMode] = useAtom(lastSelectedWorkModeAtom) const debugMode = useAtomValue(agentsDebugModeAtom) - const customClaudeConfig = useAtomValue(customClaudeConfigAtom) - const normalizedCustomClaudeConfig = - normalizeCustomClaudeConfig(customClaudeConfig) - const hasCustomClaudeConfig = Boolean(normalizedCustomClaudeConfig) + const customClaudeConfig = useAtomValue(activeConfigAtom) + const hasCustomClaudeConfig = Boolean(customClaudeConfig) + + // Custom model profiles + const [modelProfiles, setModelProfiles] = useAtom(modelProfilesAtom) + const [activeProfileId, setActiveProfileId] = useAtom(activeProfileIdAtom) + const [activeCustomModelId, setActiveCustomModelId] = useAtom(activeCustomModelIdAtom) + const customProfiles = modelProfiles.filter(p => !p.isOffline) + + // Helper to find active custom model + const activeCustomModel = useMemo(() => { + if (!activeProfileId || !activeCustomModelId) return null + const profile = customProfiles.find(p => p.id === activeProfileId) + if (!profile) return null + return profile.models.find(m => m.id === activeCustomModelId) || null + }, [activeProfileId, activeCustomModelId, customProfiles]) + // Connection status for providers const anthropicOnboardingCompleted = useAtomValue(anthropicOnboardingCompletedAtom) const apiKeyOnboardingCompleted = useAtomValue(apiKeyOnboardingCompletedAtom) @@ -422,8 +437,9 @@ export function NewChatForm({ return currentOllamaModel || "Ollama" } - if (hasCustomClaudeConfig) { - return "Custom Model" + // Show custom model name with profile if selected + if (activeProfileId && activeCustomModel) { + return activeCustomModel.name } if (!selectedModel) { @@ -437,7 +453,8 @@ export function NewChatForm({ availableModels.isOffline, availableModels.hasOllama, currentOllamaModel, - hasCustomClaudeConfig, + activeProfileId, + activeCustomModel, selectedModel, ]) const [repoPopoverOpen, setRepoPopoverOpen] = useState(false) @@ -1887,6 +1904,7 @@ export function NewChatForm({ setSettingsActiveTab("models") setSettingsDialogOpen(true) }} + hiddenModelIds={hiddenModels} claude={{ models: availableModels.models.filter((m) => !hiddenModels.includes(m.id)), selectedModelId: selectedModel?.id, @@ -1897,6 +1915,8 @@ export function NewChatForm({ if (!model) return setSelectedModel(model) setLastSelectedModelId(model.id) + // Clear profile selection when selecting standard model + setActiveProfileId(null) }, hasCustomModelConfig: hasCustomClaudeConfig, isOffline: availableModels.isOffline && availableModels.hasOllama, @@ -1907,6 +1927,18 @@ export function NewChatForm({ isConnected: isClaudeConnected, thinkingEnabled, onThinkingChange: setThinkingEnabled, + // Custom profiles + customProfiles, + selectedProfileId: activeProfileId, + selectedCustomModelId: activeCustomModelId, + onSelectCustomModel: (profileId, modelId) => { + setActiveProfileId(profileId) + setActiveCustomModelId(modelId) + }, + onClearCustomModel: () => { + setActiveProfileId(null) + setActiveCustomModelId(null) + }, }} codex={{ models: codexUiModels, diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index e2899995..b74b07e6 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -210,12 +210,21 @@ export type CustomClaudeConfig = { baseUrl: string } -// Model profile system - support multiple configs +// Custom model configuration for a single model within a profile +export type CustomModelConfig = { + id: string + name: string // Display name (e.g., "Claude 3 Opus") + modelId: string // API model ID (e.g., "anthropic/claude-3-opus") +} + +// Model profile system - support multiple models per profile export type ModelProfile = { id: string - name: string - config: CustomClaudeConfig - isOffline?: boolean // Mark as offline/Ollama profile + name: string // Profile name (e.g., "OpenRouter") + baseUrl: string // API endpoint + token: string // API key + models: CustomModelConfig[] // Multiple models + isOffline?: boolean // Mark as offline/Ollama profile } // Selected Ollama model for offline mode @@ -226,28 +235,35 @@ export const selectedOllamaModelAtom = atomWithStorage( { getOnInit: true }, ) +// Helper to generate unique IDs +const generateId = () => crypto.randomUUID() + // Helper to get offline profile with selected model export const getOfflineProfile = (modelName?: string | null): ModelProfile => ({ id: 'offline-ollama', name: 'Offline (Ollama)', + baseUrl: 'http://localhost:11434', + token: 'ollama', + models: [{ + id: 'ollama-default', + name: modelName || 'Qwen 2.5 Coder', + modelId: modelName || 'qwen2.5-coder:7b', + }], isOffline: true, - config: { - model: modelName || 'qwen2.5-coder:7b', - token: 'ollama', - baseUrl: 'http://localhost:11434', - }, }) // Predefined offline profile for Ollama (legacy, uses default model) export const OFFLINE_PROFILE: ModelProfile = { id: 'offline-ollama', name: 'Offline (Ollama)', + baseUrl: 'http://localhost:11434', + token: 'ollama', + models: [{ + id: 'ollama-default', + name: 'Qwen 2.5 Coder', + modelId: 'qwen2.5-coder:7b', + }], isOffline: true, - config: { - model: 'qwen2.5-coder:7b', - token: 'ollama', - baseUrl: 'http://localhost:11434', - }, } // Legacy single config (deprecated, kept for backwards compatibility) @@ -270,11 +286,60 @@ export const openaiApiKeyAtom = atomWithStorage( { getOnInit: true }, ) -// New: Model profiles storage +// Helper to migrate old profile structure to new structure +function migrateProfile(profile: any): ModelProfile { + // If already has models array, it's the new structure + if (profile.models && Array.isArray(profile.models)) { + return profile as ModelProfile + } + + // If has old config structure, migrate to new structure + if (profile.config) { + return { + id: profile.id, + name: profile.name, + baseUrl: profile.config.baseUrl || '', + token: profile.config.token || '', + models: [{ + id: `migrated-${profile.id}`, + name: profile.config.model || profile.name, + modelId: profile.config.model || '', + }], + isOffline: profile.isOffline, + } + } + + // Fallback for malformed profiles + return profile as ModelProfile +} + +// New: Model profiles storage with migration export const modelProfilesAtom = atomWithStorage( "agents:model-profiles", [OFFLINE_PROFILE], // Start with offline profile - undefined, + { + getItem: (key, initialValue) => { + const storedValue = localStorage.getItem(key) + if (!storedValue) return initialValue + + try { + const parsed = JSON.parse(storedValue) + // Migrate old profile structures + if (Array.isArray(parsed)) { + return parsed.map(migrateProfile) + } + return initialValue + } catch { + return initialValue + } + }, + setItem: (key, value) => { + localStorage.setItem(key, JSON.stringify(value)) + }, + removeItem: (key) => { + localStorage.removeItem(key) + }, + }, { getOnInit: true }, ) @@ -286,6 +351,14 @@ export const activeProfileIdAtom = atomWithStorage( { getOnInit: true }, ) +// Active custom model ID within a profile (null = no custom model selected) +export const activeCustomModelIdAtom = atomWithStorage( + "agents:active-custom-model-id", + null, + undefined, + { getOnInit: true }, +) + // Auto-fallback to offline mode when internet is unavailable export const autoOfflineModeAtom = atomWithStorage( "agents:auto-offline-mode", @@ -328,6 +401,7 @@ export function normalizeCustomClaudeConfig( // Get active config (considering network status and auto-fallback) export const activeConfigAtom = atom((get) => { const activeProfileId = get(activeProfileIdAtom) + const activeCustomModelId = get(activeCustomModelIdAtom) const profiles = get(modelProfilesAtom) const legacyConfig = get(customClaudeConfigAtom) const networkOnline = get(networkOnlineAtom) @@ -336,16 +410,34 @@ export const activeConfigAtom = atom((get) => { // If auto-offline enabled and no internet, use offline profile if (!networkOnline && autoOffline) { const offlineProfile = profiles.find(p => p.isOffline) - if (offlineProfile) { - return offlineProfile.config + if (offlineProfile && offlineProfile.models.length > 0) { + const model = activeCustomModelId + ? offlineProfile.models.find(m => m.id === activeCustomModelId) + : offlineProfile.models[0] + if (model) { + return { + model: model.modelId, + token: offlineProfile.token, + baseUrl: offlineProfile.baseUrl, + } + } } } // If specific profile is selected, use it if (activeProfileId) { const profile = profiles.find(p => p.id === activeProfileId) - if (profile) { - return profile.config + if (profile && profile.models.length > 0) { + const model = activeCustomModelId + ? profile.models.find(m => m.id === activeCustomModelId) + : profile.models[0] + if (model) { + return { + model: model.modelId, + token: profile.token, + baseUrl: profile.baseUrl, + } + } } }