mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
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:
parent
e8457b1f54
commit
b0d91e9745
3 changed files with 242 additions and 63 deletions
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue