From b0d91e97459bda431e05b0baa8854dc799cf4745 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 16 Mar 2026 23:02:03 +0530 Subject: [PATCH] Implement Rowboat model settings in the settings dialog, including loading and saving model configurations based on Rowboat connection status. Enhance chat input component to manage Rowboat connection state and update model loading logic accordingly. --- .../components/chat-input-with-mentions.tsx | 163 ++++++++++++------ .../onboarding/use-onboarding-state.ts | 1 + .../src/components/settings-dialog.tsx | 141 +++++++++++++-- 3 files changed, 242 insertions(+), 63 deletions(-) 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 057550b2..74a0b2ed 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 @@ -66,6 +66,7 @@ const providerDisplayNames: Record = { openrouter: 'OpenRouter', aigateway: 'AI Gateway', 'openai-compatible': 'OpenAI-Compatible', + rowboat: 'Rowboat', } interface ConfiguredModel { @@ -156,51 +157,103 @@ function ChatInputInner({ const [activeModelKey, setActiveModelKey] = useState('') const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) + const [isRowboatConnected, setIsRowboatConnected] = useState(false) - // Load model config from disk (on mount and whenever tab becomes active) + // Check Rowboat sign-in state + useEffect(() => { + window.ipc.invoke('oauth:getState', null).then((result) => { + setIsRowboatConnected(result.config?.rowboat?.connected ?? false) + }).catch(() => setIsRowboatConnected(false)) + }, [isActive]) + + // Update sign-in state when OAuth events fire + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', () => { + window.ipc.invoke('oauth:getState', null).then((result) => { + setIsRowboatConnected(result.config?.rowboat?.connected ?? false) + }).catch(() => setIsRowboatConnected(false)) + }) + return cleanup + }, []) + + // Load model config (gateway when signed in, local config when BYOK) 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, - }) + if (isRowboatConnected) { + // 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: ConfiguredModel[] = (rowboatProvider?.models || []).map( + (m: { id: string }) => ({ flavor: 'rowboat', model: m.id }) + ) + + // Read current default from config + let defaultModel = '' + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + defaultModel = parsed?.model || '' + } catch { /* no config yet */ } + + if (defaultModel) { + models.sort((a, b) => { + if (a.model === defaultModel) return -1 + if (b.model === defaultModel) return 1 + return 0 + }) + } + + setConfiguredModels(models) + const activeKey = defaultModel + ? `rowboat/${defaultModel}` + : models[0] ? `rowboat/${models[0].model}` : '' + if (activeKey) setActiveModelKey(activeKey) + } else { + // BYOK: read from local models.json + 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) + 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 } - }, []) + }, [isRowboatConnected]) useEffect(() => { loadModelConfig() @@ -238,22 +291,32 @@ function ChatInputInner({ 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, - }) + if (entry.flavor === 'rowboat') { + // Gateway model — save with valid Zod flavor, no credentials + await window.ipc.invoke('models:saveConfig', { + provider: { flavor: 'openrouter' as const }, + model: entry.model, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } else { + // BYOK — preserve full provider config + const providerModels = configuredModels + .filter((m) => m.flavor === entry.flavor) + .map((m) => m.model) + 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') } 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 46d21a6c..7cc50a90 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 @@ -448,6 +448,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { if (result.success) { setTestState({ status: "success" }) await window.ipc.invoke("models:saveConfig", providerConfig) + window.dispatchEvent(new Event('models-config-changed')) handleNext() } else { setTestState({ status: "error", error: result.error }) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 74b3efd6..65c5b424 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -693,6 +693,126 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { ) } +// --- Rowboat Model Settings (when signed in via Rowboat) --- + +function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [gatewayModels, setGatewayModels] = useState([]) + const [selectedModel, setSelectedModel] = useState("") + const [selectedKgModel, setSelectedKgModel] = useState("") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!dialogOpen) return + + 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) + + // Read current selection from config + 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) + } + } catch { + toast.error("Failed to load models") + } finally { + setLoading(false) + } + } + + load() + }, [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, + }) + window.dispatchEvent(new Event("models-config-changed")) + toast.success("Model configuration saved") + } catch { + toast.error("Failed to save model configuration") + } finally { + setSaving(false) + } + }, [selectedModel, selectedKgModel]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+

+ Select the models Rowboat uses. These are provided through your Rowboat account. +

+ + {/* Assistant model */} +
+ + +
+ + {/* Knowledge graph model */} +
+ + +
+ + {/* Save */} + +
+ ) +} + // --- Note Tagging Settings --- interface TagDef { @@ -1114,27 +1234,18 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(null) const [rowboatConnected, setRowboatConnected] = useState(false) - // Check if user is signed in to Rowboat (hides Models tab) + // Check if user is signed in to Rowboat useEffect(() => { if (!open) return window.ipc.invoke('oauth:getState', null).then((result) => { const connected = result.config?.rowboat?.connected ?? false setRowboatConnected(connected) - // If currently on models tab and signed in, switch to next tab - if (connected && activeTab === 'models') { - setActiveTab('mcp') - } }).catch(() => { setRowboatConnected(false) }) }, [open]) - const visibleTabs = useMemo(() => { - if (rowboatConnected) { - return tabs.filter(t => t.id !== 'models') - } - return tabs - }, [rowboatConnected]) + const visibleTabs = useMemo(() => tabs, []) const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0] const isJsonTab = activeTab === "mcp" || activeTab === "security" @@ -1249,14 +1360,18 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

{activeTabConfig.label}

- {activeTabConfig.description} + {activeTab === "models" && rowboatConnected + ? "Select your default models" + : activeTabConfig.description}

{/* Content */}
{activeTab === "models" ? ( - + rowboatConnected + ? + : ) : activeTab === "note-tagging" ? ( ) : activeTab === "appearance" ? (