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 29b64da1..a4501039 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 @@ -126,20 +126,23 @@ function ChatInputInner({ const [configuredModels, setConfiguredModels] = useState([]) const [activeModelKey, setActiveModelKey] = useState('') - // Load model config from disk - useEffect(() => { - async function loadModels() { - 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 - if (e.model && typeof e.model === 'string') { + // 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: e.model, + model, apiKey: (e.apiKey as string) || undefined, baseURL: (e.baseURL as string) || undefined, headers: (e.headers as Record) || undefined, @@ -148,17 +151,20 @@ function ChatInputInner({ } } } - setConfiguredModels(models) - if (parsed?.provider?.flavor && parsed?.model) { - setActiveModelKey(`${parsed.provider.flavor}/${parsed.model}`) - } - } catch { - // No config yet } + setConfiguredModels(models) + if (parsed?.provider?.flavor && parsed?.model) { + setActiveModelKey(`${parsed.provider.flavor}/${parsed.model}`) + } + } catch { + // No config yet } - loadModels() }, []) + useEffect(() => { + loadModelConfig() + }, [isActive, loadModelConfig]) + const handleModelChange = useCallback(async (key: string) => { const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) if (!entry) return diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 42c753b4..2965308e 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, Loader2, CheckCircle2 } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X } from "lucide-react" import { Dialog, @@ -167,14 +167,14 @@ const defaultBaseURLs: Partial> = { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, + anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, + google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, + openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, + aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -193,13 +193,14 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const showModelInput = isLocalProvider || modelsForProvider.length === 0 const isMoreProvider = moreProviders.some(p => p.id === provider) + const primaryModel = activeConfig.models[0] || "" const canTest = - activeConfig.model.trim().length > 0 && + primaryModel.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; knowledgeGraphModel: string }>) => { + (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -209,6 +210,39 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { [] ) + const updateModelAt = useCallback( + (prov: LlmProviderFlavor, index: number, value: string) => { + setProviderConfigs(prev => { + const models = [...prev[prov].models] + models[index] = value + return { ...prev, [prov]: { ...prev[prov], models } } + }) + setTestState({ status: "idle" }) + }, + [] + ) + + const addModel = useCallback( + (prov: LlmProviderFlavor) => { + setProviderConfigs(prev => ({ + ...prev, + [prov]: { ...prev[prov], models: [...prev[prov].models, ""] }, + })) + }, + [] + ) + + const removeModel = useCallback( + (prov: LlmProviderFlavor, index: number) => { + setProviderConfigs(prev => { + const models = prev[prov].models.filter((_, i) => i !== index) + return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } } + }) + setTestState({ status: "idle" }) + }, + [] + ) + // Load current config from file useEffect(() => { if (!dialogOpen) return @@ -230,20 +264,27 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { for (const [key, entry] of Object.entries(parsed.providers)) { if (key in next) { const e = entry as any; + const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0 + ? e.models + : e.model ? [e.model] : [""]; next[key as LlmProviderFlavor] = { apiKey: e.apiKey || "", baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), - model: e.model || "", + models: savedModels, knowledgeGraphModel: e.knowledgeGraphModel || "", }; } } } // Active provider takes precedence from top-level config + const existingModels = next[flavor].models; + const activeModels = existingModels[0] === parsed.model + ? existingModels + : [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)]; next[flavor] = { apiKey: parsed.provider.apiKey || "", baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), - model: parsed.model, + models: activeModels.length > 0 ? activeModels : [""], knowledgeGraphModel: parsed.knowledgeGraphModel || "", }; return next; @@ -291,11 +332,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { 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 catalog = modelsCatalog[prov] + if (catalog?.length && !next[prov].models[0]) { const preferred = preferredDefaults[prov] - const hasPreferred = preferred && models.some(m => m.id === preferred) - next[prov] = { ...next[prov], model: hasPreferred ? preferred : (models[0]?.id || "") } + const hasPreferred = preferred && catalog.some(m => m.id === preferred) + const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "") + next[prov] = { ...next[prov], models: [defaultModel] } } } return next @@ -306,13 +348,15 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { if (!canTest) return setTestState({ status: "testing" }) try { + const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean) const providerConfig = { provider: { flavor: provider, apiKey: activeConfig.apiKey.trim() || undefined, baseURL: activeConfig.baseURL.trim() || undefined, }, - model: activeConfig.model.trim(), + model: allModels[0] || "", + models: allModels, knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) @@ -382,6 +426,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { {/* Model selection - side by side */}
+ {/* Assistant models (left column) */}
Assistant model {modelsLoading ? ( @@ -389,34 +434,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 ? ( @@ -428,7 +497,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { updateConfig(provider, { knowledgeGraphModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} + placeholder={primaryModel || "Enter model"} /> ) : (