diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index d3554c009..42ea45bb1 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { ArrowUp, AudioLines, + ChevronDown, FileArchive, FileCode2, FileIcon, @@ -15,6 +16,13 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { type AttachmentIconKind, getAttachmentDisplayName, @@ -45,6 +53,25 @@ export type StagedAttachment = { const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB +const providerDisplayNames: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Gemini', + ollama: 'Ollama', + openrouter: 'OpenRouter', + aigateway: 'AI Gateway', + 'openai-compatible': 'OpenAI-Compatible', +} + +interface ConfiguredModel { + flavor: string + model: string + apiKey?: string + baseURL?: string + headers?: Record + knowledgeGraphModel?: string +} + function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -96,6 +123,90 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing + const [configuredModels, setConfiguredModels] = useState([]) + const [activeModelKey, setActiveModelKey] = useState('') + + // Load model config from disk (on mount and whenever tab becomes active) + const loadModelConfig = useCallback(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + const models: ConfiguredModel[] = [] + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers)) { + const e = entry as Record + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] + const singleModel = typeof e.model === 'string' ? e.model : '' + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] + for (const model of allModels) { + if (model) { + models.push({ + flavor, + model, + apiKey: (e.apiKey as string) || undefined, + baseURL: (e.baseURL as string) || undefined, + headers: (e.headers as Record) || undefined, + knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, + }) + } + } + } + } + const defaultKey = parsed?.provider?.flavor && parsed?.model + ? `${parsed.provider.flavor}/${parsed.model}` + : '' + models.sort((a, b) => { + const aKey = `${a.flavor}/${a.model}` + const bKey = `${b.flavor}/${b.model}` + if (aKey === defaultKey) return -1 + if (bKey === defaultKey) return 1 + return 0 + }) + setConfiguredModels(models) + if (defaultKey) { + setActiveModelKey(defaultKey) + } + } catch { + // No config yet + } + }, []) + + useEffect(() => { + loadModelConfig() + }, [isActive, loadModelConfig]) + + // Reload when model config changes (e.g. from settings dialog) + useEffect(() => { + const handler = () => { loadModelConfig() } + window.addEventListener('models-config-changed', handler) + return () => window.removeEventListener('models-config-changed', handler) + }, [loadModelConfig]) + + const handleModelChange = useCallback(async (key: string) => { + const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + if (!entry) return + setActiveModelKey(key) + // Collect all models for this provider so the full list is preserved + const providerModels = configuredModels + .filter((m) => m.flavor === entry.flavor) + .map((m) => m.model) + try { + await window.ipc.invoke('models:saveConfig', { + provider: { + flavor: entry.flavor, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }, + model: entry.model, + models: providerModels, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } catch { + toast.error('Failed to switch model') + } + }, [configuredModels]) + // Restore the tab draft when this input mounts. useEffect(() => { if (initialDraft) { @@ -239,24 +350,33 @@ function ChatInputInner({ })} )} -
- { - const files = e.target.files - if (!files || files.length === 0) return - const paths = Array.from(files) - .map((file) => window.electronUtils?.getPathForFile(file)) - .filter(Boolean) as string[] - if (paths.length > 0) { - void addFiles(paths) - } - e.target.value = '' - }} + { + const files = e.target.files + if (!files || files.length === 0) return + const paths = Array.from(files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + if (paths.length > 0) { + void addFiles(paths) + } + e.target.value = '' + }} + /> +
+ +
+
- +
+ {configuredModels.length > 0 && ( + + + + + + + {configuredModels.map((m) => { + const key = `${m.flavor}/${m.model}` + return ( + + {m.model} + {providerDisplayNames[m.flavor] || m.flavor} + + ) + })} + + + + )} {isProcessing ? ( - ) + const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { + const config = providerConfigs[prov] + const allModels = config.models.map(m => m.trim()).filter(Boolean) + if (!allModels[0]) return + try { + await window.ipc.invoke("models:saveConfig", { + provider: { + flavor: prov, + apiKey: config.apiKey.trim() || undefined, + baseURL: config.baseURL.trim() || undefined, + }, + model: allModels[0], + models: allModels, + knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, + }) + setDefaultProvider(prov) + window.dispatchEvent(new Event('models-config-changed')) + toast.success("Default provider updated") + } catch { + toast.error("Failed to set default provider") + } + }, [providerConfigs]) + + const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { + try { + const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) + const parsed = JSON.parse(result.data) + if (parsed?.providers?.[prov]) { + delete parsed.providers[prov] + } + // If the deleted provider is the current top-level active one, + // switch top-level config to the current default provider + if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) { + const defConfig = providerConfigs[defaultProvider] + const defModels = defConfig.models.map(m => m.trim()).filter(Boolean) + parsed.provider = { + flavor: defaultProvider, + apiKey: defConfig.apiKey.trim() || undefined, + baseURL: defConfig.baseURL.trim() || undefined, + } + parsed.model = defModels[0] || "" + parsed.models = defModels + parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined + } + await window.ipc.invoke("workspace:writeFile", { + path: "config/models.json", + data: JSON.stringify(parsed, null, 2), + }) + setProviderConfigs(prev => ({ + ...prev, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" }, + })) + setTestState({ status: "idle" }) + window.dispatchEvent(new Event('models-config-changed')) + toast.success("Provider configuration removed") + } catch { + toast.error("Failed to remove provider") + } + }, [defaultProvider, providerConfigs]) + + const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => { + const isDefault = defaultProvider === p.id + const isSelected = provider === p.id + const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0 + return ( + + ) + } if (configLoading) { return ( @@ -366,6 +529,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { {/* Model selection - side by side */}
+ {/* Assistant models (left column) */}
Assistant model {modelsLoading ? ( @@ -373,34 +537,58 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { Loading...
- ) : showModelInput ? ( - updateConfig(provider, { model: e.target.value })} - placeholder="Enter model" - /> ) : ( - +
+ {activeConfig.models.map((model, index) => ( +
+ {showModelInput ? ( + updateModelAt(provider, index, e.target.value)} + placeholder="Enter model" + /> + ) : ( + + )} + {activeConfig.models.length > 1 && ( + + )} +
+ ))} + +
)} {modelsError && (
{modelsError}
)}
+ {/* Knowledge graph model (right column) */}
Knowledge graph model {modelsLoading ? ( @@ -412,7 +600,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { updateConfig(provider, { knowledgeGraphModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} + placeholder={primaryModel || "Enter model"} /> ) : (