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/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index c358c816..657b66b9 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>({ @@ -397,6 +397,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) @@ -422,10 +424,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 +443,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; @@ -517,10 +519,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) { @@ -547,7 +551,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]) const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { const config = providerConfigs[prov] @@ -562,10 +566,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')) @@ -573,7 +579,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { } catch { toast.error("Failed to set default provider") } - }, [providerConfigs]) + }, [providerConfigs, rowboatConnected]) const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { try { @@ -594,10 +600,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", @@ -613,7 +621,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 @@ -635,7 +643,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { >
{p.name} - {isDefault && ( + {isDefault && !rowboatConnected && ( Default @@ -644,16 +652,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 + + )} { @@ -705,7 +715,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
{/* Assistant models (left column) */}
- Assistant model + {rowboatConnected ? "Model" : "Assistant model"} {modelsLoading ? (
@@ -743,6 +753,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ {!rowboatConnected && (<> {/* Knowledge graph model (right column) */}
Knowledge graph model @@ -878,6 +889,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
+ )}
{/* API Key */} @@ -1262,11 +1274,45 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo } // --- Rowboat Model Settings (when signed in via Rowboat) --- +// +// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from +// BYOK providers configured below. Values are provider-qualified +// ("provider::model") and saved via models:updateConfig as {provider, model} +// refs, so a signed-in user can e.g. keep the gateway assistant while +// running background agents on a local Ollama model. + +interface HybridModelOption { + provider: string + model: string + label: string +} + +const providerDisplayNames: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Gemini', + ollama: 'Ollama', + openrouter: 'OpenRouter', + aigateway: 'AI Gateway', + 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', +} + +const HYBRID_SEP = "::" +const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}` + +function parseHybridKey(key: string): { provider: string; model: string } | null { + const index = key.indexOf(HYBRID_SEP) + if (index <= 0) return null + return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) } +} function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { - const [gatewayModels, setGatewayModels] = useState([]) - const [selectedModel, setSelectedModel] = useState("") - const [selectedKgModel, setSelectedKgModel] = useState("") + const [options, setOptions] = useState([]) + const [selectedDefault, setSelectedDefault] = useState("") + const [selectedKg, setSelectedKg] = useState("") + const [selectedLiveNote, setSelectedLiveNote] = useState("") + const [selectedAutoPermission, setSelectedAutoPermission] = useState("") const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -1276,22 +1322,63 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { async function load() { setLoading(true) try { - // Fetch gateway models - const listResult = await window.ipc.invoke("models:list", null) - const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat") - const models = rowboatProvider?.models || [] - setGatewayModels(models) + const collected: HybridModelOption[] = [] + const seen = new Set() + const push = (provider: string, model: string, label?: string) => { + if (!model) return + const key = hybridKey(provider, model) + if (seen.has(key)) return + seen.add(key) + collected.push({ provider, model, label: label || model }) + } - // Read current selection from 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 || [] + } + } catch { /* offline — BYOK entries below still load */ } + for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id) + + let parsed: Record = {} try { const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) - const parsed = JSON.parse(configResult.data) - if (parsed?.model) setSelectedModel(parsed.model) - if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel) - } catch { - // No config yet — pick first model as default - if (models.length > 0) setSelectedModel(models[0].id) + parsed = JSON.parse(configResult.data) + } catch { /* no BYOK config yet */ } + + const providersMap = (parsed.providers ?? {}) as Record> + for (const [flavor, entry] of Object.entries(providersMap)) { + const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0 + const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0 + if (!hasKey && !hasBaseURL) continue + push(flavor, typeof entry.model === "string" ? entry.model : "") + const catalogModels = catalog[flavor] || [] + if (catalogModels.length > 0) { + for (const m of catalogModels) push(flavor, m.id, m.name || m.id) + } else { + for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m) + } } + setOptions(collected) + + // Current selections. Legacy string overrides pair with the BYOK + // top-level flavor (mirrors core/models/defaults.ts). + const legacyFlavor = (parsed.provider as Record | undefined)?.flavor + const toKey = (value: unknown): string => { + if (!value) return "" + if (typeof value === "string") { + return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : "" + } + const ref = value as { provider?: unknown; model?: unknown } + return typeof ref.provider === "string" && typeof ref.model === "string" + ? hybridKey(ref.provider, ref.model) + : "" + } + setSelectedDefault(toKey(parsed.defaultSelection)) + setSelectedKg(toKey(parsed.knowledgeGraphModel)) + setSelectedLiveNote(toKey(parsed.liveNoteAgentModel)) + setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel)) } catch { toast.error("Failed to load models") } finally { @@ -1303,13 +1390,14 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { }, [dialogOpen]) const handleSave = useCallback(async () => { - if (!selectedModel) return setSaving(true) try { - await window.ipc.invoke("models:saveConfig", { - provider: { flavor: "openrouter" as const }, - model: selectedModel, - knowledgeGraphModel: selectedKgModel || undefined, + const toRef = (key: string) => (key ? parseHybridKey(key) : null) + await window.ipc.invoke("models:updateConfig", { + defaultSelection: toRef(selectedDefault), + knowledgeGraphModel: toRef(selectedKg), + liveNoteAgentModel: toRef(selectedLiveNote), + autoPermissionDecisionModel: toRef(selectedAutoPermission), }) window.dispatchEvent(new Event("models-config-changed")) toast.success("Model configuration saved") @@ -1318,7 +1406,37 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { } finally { setSaving(false) } - }, [selectedModel, selectedKgModel]) + }, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission]) + + const renderSelect = ( + label: string, + value: string, + onChange: (v: string) => void, + defaultLabel: string, + ) => ( +
+ + +
+ ) if (loading) { return ( @@ -1331,46 +1449,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { return (

- Select the models Rowboat uses. These are provided through your Rowboat account. + Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.

- {/* Assistant model */} -
- - -
- - {/* Knowledge graph model */} -
- - -
+ {renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")} + {renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")} + {renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")} + {renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")} {/* Save */} -