diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a558f4ae..cb6e98b1 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1080,6 +1080,11 @@ export function setupIpcHandlers() { await repo.setConfig(args); return { success: true }; }, + 'models:updateConfig': async (_event, args) => { + const repo = container.resolve('modelConfigRepo'); + await repo.updateConfig(args); + return { success: true }; + }, 'oauth:connect': async (_event, args) => { const credentials = args.clientId && args.clientSecret ? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() } 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 13540e6f..223bfac8 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 @@ -400,8 +400,10 @@ function ChatInputInner({ return cleanup }, []) - // Load the list of models the user can choose from. - // Signed-in: gateway model list. Signed-out: providers configured in models.json. + // Load the list of models the user can choose from. Hybrid mode: signed-in + // users get the gateway list AND every BYOK provider configured in + // models.json (selecting a BYOK model routes that message through the + // user's own key / local server). Signed-out users get BYOK only. const loadModelConfig = useCallback(async () => { // Concurrent runs race (mount fires one before the sign-in state resolves, // which fires another) — only the newest run may write state, else a slow @@ -415,44 +417,37 @@ function ChatInputInner({ if (loadModelConfigEpoch.current === epoch) setDefaultModel(null) } try { - if (isRowboatConnected) { + const models: ConfiguredModel[] = [] + const seen = new Set() + const push = (provider: string, model: string) => { + if (!model) return + const key = `${provider}/${model}` + if (seen.has(key)) return + seen.add(key) + models.push({ provider: provider as ProviderName, model }) + } + + // Full catalog per provider (gateway + cloud). Providers with no + // catalog (Ollama, OpenAI-compatible) fall back to the models saved in + // config below. + const catalog: Record = {} + try { const listResult = await window.ipc.invoke('models:list', null) - const rowboatProvider = listResult.providers?.find( - (p: { id: string }) => p.id === 'rowboat' - ) - const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( - (m: { id: string }) => ({ provider: 'rowboat', model: m.id }) - ) - if (loadModelConfigEpoch.current !== epoch) return - setConfiguredModels(models) - } else { + for (const p of listResult.providers || []) { + catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id) + } + } catch { /* offline / no catalog — fall back to saved config below */ } + + if (isRowboatConnected) { + for (const m of catalog['rowboat'] || []) push('rowboat', m) + } + + try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) - // Offer every model the configured key supports — the same full catalog - // Settings uses for its dropdowns — so BYOK chat matches the signed-in - // gateway picker. The picker is no longer limited to a hand-curated - // config.models list. Providers with no catalog (Ollama, OpenAI-compatible) - // fall back to the model saved in config. - const catalog: Record = {} - try { - const listResult = await window.ipc.invoke('models:list', null) - for (const p of listResult.providers || []) { - catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id) - } - } catch { /* offline / no catalog — fall back to saved config below */ } - - const models: ConfiguredModel[] = [] - const seen = new Set() - const push = (provider: string, model: string) => { - if (!model) return - const key = `${provider}/${model}` - if (seen.has(key)) return - seen.add(key) - models.push({ provider: provider as ProviderName, model }) - } - - // List the default provider first so its default model leads the picker. + // List the default provider first so its default model leads the + // BYOK section of the picker. const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : '' const flavors = Object.keys(parsed?.providers || {}) .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)) @@ -474,9 +469,21 @@ function ChatInputInner({ for (const m of saved) push(flavor, m) } } - if (loadModelConfigEpoch.current !== epoch) return - setConfiguredModels(models) - } + + // The user's explicit default selection leads the whole picker. + const sel = parsed?.defaultSelection + if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') { + const selKey = `${sel.provider}/${sel.model}` + const index = models.findIndex((m) => `${m.provider}/${m.model}` === selKey) + if (index > 0) { + const [entry] = models.splice(index, 1) + models.unshift(entry) + } + } + } catch { /* no BYOK config yet */ } + + if (loadModelConfigEpoch.current !== epoch) return + setConfiguredModels(models) } catch (err) { // No config yet — but surface unexpected failures for diagnosis. console.error('[chat-input] failed to load model list', err) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index d548ef32..f5e6f213 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -434,10 +434,29 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { } const catalog: string[] = result.models ?? [] + const typed = activeConfig.model.trim() + // Hosted providers hide the model field (it holds an auto-seeded + // default), so only treat it as user intent where the field is shown — + // mirrors showModelInput in llm-setup-step. + const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + const modelInputShown = !hostedProviders.includes(llmProvider) + + if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) { + // Ollama's tag list is authoritative: an unlisted model isn't pulled, + // so saving it would break chat at runtime with no obvious cause. + const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}` + setTestState({ status: "error", error }) + toast.error(error) + return false + } + const preferred = preferredDefaults[llmProvider] - const model = - (preferred && catalog.includes(preferred) && preferred) || - catalog[0] || activeConfig.model.trim() || "" + // A model the user explicitly entered always wins — this used to prefer + // catalog[0], which silently replaced the user's Ollama model with + // whatever model the local server happened to list first. + const model = modelInputShown + ? (typed || catalog[0] || "") + : ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "") // `models` is the user's curated assistant-model list (shown in Settings), // NOT the full provider catalog. Onboarding seeds it with just the selected diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 259a7253..248c6cc6 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -346,7 +346,7 @@ type ProviderModelConfig = { autoPermissionDecisionModel: string } -function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { +function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) { const [provider, setProvider] = useState("openai") const [defaultProvider, setDefaultProvider] = useState(null) const [providerConfigs, setProviderConfigs] = useState>({ @@ -364,6 +364,11 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" }) const [configLoading, setConfigLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) + // "Defer background tasks while a chat is running" — a top-level + // models.json flag. deferExplicit tracks whether the user (or the Ollama + // auto-enable) has ever set it, so we only auto-enable once. + const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false) + const [deferExplicit, setDeferExplicit] = useState(false) const activeConfig = providerConfigs[provider] const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible" @@ -397,6 +402,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { useEffect(() => { if (!dialogOpen) return + const asString = (v: unknown): string => (typeof v === "string" ? v : "") + async function loadCurrentConfig() { try { setConfigLoading(true) @@ -404,6 +411,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { path: "config/models.json", }) const parsed = JSON.parse(result.data) + setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true) + setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean") if (parsed?.provider?.flavor && parsed?.model) { const flavor = parsed.provider.flavor as LlmProviderFlavor setProvider(flavor) @@ -422,10 +431,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { apiKey: e.apiKey || "", baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), models: savedModels, - knowledgeGraphModel: e.knowledgeGraphModel || "", - meetingNotesModel: e.meetingNotesModel || "", - liveNoteAgentModel: e.liveNoteAgentModel || "", - autoPermissionDecisionModel: e.autoPermissionDecisionModel || "", + knowledgeGraphModel: asString(e.knowledgeGraphModel), + meetingNotesModel: asString(e.meetingNotesModel), + liveNoteAgentModel: asString(e.liveNoteAgentModel), + autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel), }; } } @@ -441,10 +450,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { apiKey: parsed.provider.apiKey || "", baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), models: activeModels.length > 0 ? activeModels : [""], - knowledgeGraphModel: parsed.knowledgeGraphModel || "", - meetingNotesModel: parsed.meetingNotesModel || "", - liveNoteAgentModel: parsed.liveNoteAgentModel || "", - autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "", + knowledgeGraphModel: asString(parsed.knowledgeGraphModel), + meetingNotesModel: asString(parsed.meetingNotesModel), + liveNoteAgentModel: asString(parsed.liveNoteAgentModel), + autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel), }; } return next; @@ -460,6 +469,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { loadCurrentConfig() }, [dialogOpen]) + const handleDeferToggle = useCallback(async (value: boolean) => { + setDeferBackgroundTasks(value) + setDeferExplicit(true) + try { + await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value }) + window.dispatchEvent(new Event("models-config-changed")) + } catch { + toast.error("Failed to save setting") + } + }, []) + // Load models catalog useEffect(() => { if (!dialogOpen) return @@ -517,10 +537,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }, model: allModels[0] || "", models: allModels, - knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, + ...(rowboatConnected ? {} : { + knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, + meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, + liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, + }), } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -528,7 +550,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { setDefaultProvider(provider) setTestState({ status: "success" }) window.dispatchEvent(new Event('models-config-changed')) - toast.success("Model configuration saved") + // Local models compete with background agents for the same hardware: + // when the user connects Ollama and has never touched the defer + // flag, enable it for them (they can switch it off below). + if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) { + void handleDeferToggle(true) + } + // Capability probe caveats (local models): saved, but the user should + // know when the model can't do tools or has a too-small context. + const warnings: string[] = result.warnings ?? [] + if (warnings.length > 0) { + for (const warning of warnings) { + toast.warning(warning, { duration: 12000 }) + } + toast.success("Model configuration saved (with warnings)") + } else { + toast.success("Model configuration saved") + } } else { setTestState({ status: "error", error: result.error }) toast.error(result.error || "Connection test failed") @@ -537,7 +575,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { setTestState({ status: "error", error: "Connection test failed" }) toast.error("Connection test failed") } - }, [canTest, provider, activeConfig]) + }, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle]) const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { const config = providerConfigs[prov] @@ -552,10 +590,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }, model: allModels[0], models: allModels, - knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: config.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, + ...(rowboatConnected ? {} : { + knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, + meetingNotesModel: config.meetingNotesModel.trim() || undefined, + liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, + autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, + }), }) setDefaultProvider(prov) window.dispatchEvent(new Event('models-config-changed')) @@ -563,7 +603,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { } catch { toast.error("Failed to set default provider") } - }, [providerConfigs]) + }, [providerConfigs, rowboatConnected]) const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { try { @@ -584,10 +624,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { } parsed.model = defModels[0] || "" parsed.models = defModels - parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined - parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined - parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined - parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined + if (!rowboatConnected) { + parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined + parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined + parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined + parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined + } } await window.ipc.invoke("workspace:writeFile", { path: "config/models.json", @@ -603,7 +645,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { } catch { toast.error("Failed to remove provider") } - }, [defaultProvider, providerConfigs]) + }, [defaultProvider, providerConfigs, rowboatConnected]) const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => { const isDefault = defaultProvider === p.id @@ -625,7 +667,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { >
{p.name} - {isDefault && ( + {isDefault && !rowboatConnected && ( Default @@ -634,16 +676,18 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
{p.description}
{!isDefault && hasModel && isSelected && (
- { - e.stopPropagation() - handleSetDefault(p.id) - }} - className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer" - > - Set as default - + {!rowboatConnected && ( + { + e.stopPropagation() + handleSetDefault(p.id) + }} + className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer" + > + Set as default + + )} { @@ -695,7 +739,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
{/* Assistant models (left column) */}
- Assistant model + {rowboatConnected ? "Model" : "Assistant model"} {modelsLoading ? (
@@ -733,6 +777,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ {!rowboatConnected && (<> {/* Knowledge graph model (right column) */}
Knowledge graph model @@ -868,6 +913,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ )}
{/* API Key */} @@ -916,6 +962,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)} + {/* Defer background tasks while chatting */} +
+
+
Defer background tasks while chatting
+
+ Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models. +
+
+ +
+ {/* Test & Save button */}