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" ? (