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.

This commit is contained in:
tusharmagar 2026-03-16 23:02:03 +05:30
parent e8457b1f54
commit b0d91e9745
3 changed files with 242 additions and 63 deletions

View file

@ -66,6 +66,7 @@ const providerDisplayNames: Record<string, string> = {
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<string, unknown>
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<string, string>) || 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<string, unknown>
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<string, string>) || 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')
}

View file

@ -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 })

View file

@ -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<LlmModelOption[]>([])
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 (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Select the models Rowboat uses. These are provided through your Rowboat account.
</p>
{/* Assistant model */}
<div className="space-y-2">
<label className="text-sm font-medium">Assistant model</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Knowledge graph model */}
<div className="space-y-2">
<label className="text-sm font-medium">Knowledge graph model</label>
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Same as assistant" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Save */}
<Button onClick={handleSave} disabled={!selectedModel || saving}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
"Save"
)}
</Button>
</div>
)
}
// --- Note Tagging Settings ---
interface TagDef {
@ -1114,27 +1234,18 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState<string | null>(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) {
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{activeTabConfig.description}
{activeTab === "models" && rowboatConnected
? "Select your default models"
: activeTabConfig.description}
</p>
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "models" ? (
<ModelSettings dialogOpen={open} />
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
: <ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (