mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
2848 lines
108 KiB
TypeScript
2848 lines
108 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { useState, useEffect, useCallback, useMemo } from "react"
|
|
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { cn } from "@/lib/utils"
|
|
import { useTheme } from "@/contexts/theme-context"
|
|
import { toast } from "sonner"
|
|
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
|
|
import { AccountSettings } from "@/components/settings/account-settings"
|
|
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
|
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
|
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
|
import type { ipc as ipcShared } from "@x/shared"
|
|
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
|
import { useProviderModels } from "@/hooks/use-provider-models"
|
|
|
|
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
|
|
|
interface TabConfig {
|
|
id: ConfigTab
|
|
label: string
|
|
icon: React.ElementType
|
|
path?: string
|
|
description: string
|
|
}
|
|
|
|
const tabs: TabConfig[] = [
|
|
{
|
|
id: "account",
|
|
label: "Account",
|
|
icon: User,
|
|
description: "Manage your Rowboat account",
|
|
},
|
|
{
|
|
id: "connections",
|
|
label: "Connections",
|
|
icon: Plug,
|
|
description: "Manage accounts and tools",
|
|
},
|
|
{
|
|
id: "mobile",
|
|
label: "Mobile",
|
|
icon: Smartphone,
|
|
description: "Chat with Rowboat from WhatsApp or Telegram",
|
|
},
|
|
{
|
|
id: "models",
|
|
label: "Models",
|
|
icon: Key,
|
|
path: "config/models.json",
|
|
description: "Configure LLM providers and API keys",
|
|
},
|
|
{
|
|
id: "mcp",
|
|
label: "MCP Servers",
|
|
icon: Server,
|
|
path: "config/mcp.json",
|
|
description: "Configure MCP server connections",
|
|
},
|
|
{
|
|
id: "security",
|
|
label: "Security",
|
|
icon: Shield,
|
|
path: "config/security.json",
|
|
description: "Configure allowed shell commands",
|
|
},
|
|
{
|
|
id: "code-mode",
|
|
label: "Code Mode",
|
|
icon: Terminal,
|
|
description: "Delegate coding tasks to Claude Code or Codex",
|
|
},
|
|
{
|
|
id: "appearance",
|
|
label: "Appearance",
|
|
icon: Palette,
|
|
description: "Customize the look and feel",
|
|
},
|
|
{
|
|
id: "notifications",
|
|
label: "Notifications",
|
|
icon: Bell,
|
|
description: "Choose which notifications you receive",
|
|
},
|
|
{
|
|
id: "note-tagging",
|
|
label: "Note Tagging",
|
|
icon: Tags,
|
|
path: "config/tags.json",
|
|
description: "Configure tags for notes and emails",
|
|
},
|
|
{
|
|
id: "help",
|
|
label: "Help",
|
|
icon: HelpCircle,
|
|
description: "Get help and support",
|
|
},
|
|
]
|
|
|
|
/** Sidebar nav grouping: identity first, capabilities, then app-level. */
|
|
const NAV_SECTIONS: { label: string | null; ids: ConfigTab[] }[] = [
|
|
{ label: null, ids: ["account", "connections", "mobile"] },
|
|
{ label: "Configure", ids: ["models", "mcp", "security", "code-mode", "note-tagging"] },
|
|
{ label: "App", ids: ["appearance", "notifications", "help"] },
|
|
]
|
|
|
|
interface SettingsDialogProps {
|
|
/** Optional trigger element. Omit when controlling `open` externally. */
|
|
children?: React.ReactNode
|
|
/** Tab to open on when the dialog is shown. Defaults to "account". */
|
|
defaultTab?: ConfigTab
|
|
/** Controlled open state. When provided, the dialog is fully controlled. */
|
|
open?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
}
|
|
|
|
// --- Updates section (Help tab) ---
|
|
|
|
type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req']
|
|
|
|
function UpdateSettings() {
|
|
const [status, setStatus] = useState<UpdaterStatus | null>(null)
|
|
|
|
useEffect(() => {
|
|
void window.ipc.invoke('updater:getStatus', null).then(setStatus)
|
|
return window.ipc.on('updater:status', setStatus)
|
|
}, [])
|
|
|
|
if (!status) return null
|
|
|
|
const checkNow = () => {
|
|
// Progress arrives via updater:status pushes; using the invoke's snapshot
|
|
// here could stomp a newer pushed state.
|
|
void window.ipc.invoke('updater:check', null)
|
|
}
|
|
|
|
let body: React.ReactNode
|
|
switch (status.state) {
|
|
case 'disabled':
|
|
body = (
|
|
<p className="text-xs text-muted-foreground">
|
|
Automatic updates are disabled in development builds.
|
|
</p>
|
|
)
|
|
break
|
|
case 'unsupported':
|
|
body = status.reason === 'not-in-applications' ? (
|
|
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
|
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-amber-500" />
|
|
Quit Rowboat and move it to the Applications folder to enable automatic updates.
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">
|
|
{"Automatic updates aren't available on this platform. "}
|
|
<a
|
|
href="https://github.com/rowboatlabs/rowboat/releases/latest"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline hover:text-foreground transition-colors"
|
|
>
|
|
Get the latest release
|
|
</a>
|
|
</p>
|
|
)
|
|
break
|
|
case 'checking':
|
|
case 'downloading':
|
|
body = (
|
|
<Button size="sm" variant="outline" disabled>
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
{status.state === 'checking' ? 'Checking for updates…' : 'Downloading update…'}
|
|
</Button>
|
|
)
|
|
break
|
|
case 'ready':
|
|
body = (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
{status.newVersion
|
|
? `Rowboat ${status.newVersion} is ready to install.`
|
|
: 'An update is ready to install.'}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => void window.ipc.invoke('updater:quitAndInstall', null)}
|
|
>
|
|
Restart to update
|
|
</Button>
|
|
</div>
|
|
)
|
|
break
|
|
case 'error':
|
|
body = (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p className="text-xs text-muted-foreground flex items-start gap-1.5">
|
|
<AlertTriangle className="size-3.5 shrink-0 mt-0.5 text-destructive" />
|
|
{`Update check failed: ${status.error ?? 'unknown error'}`}
|
|
</p>
|
|
<Button size="sm" variant="outline" className="shrink-0" onClick={checkNow}>
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
)
|
|
break
|
|
case 'idle':
|
|
body = (
|
|
<div className="space-y-2">
|
|
{/* lastCheckedAt only exists after a check that found no update
|
|
(an available update moves the state to downloading/ready), so
|
|
idle + lastCheckedAt genuinely means "on the latest version". */}
|
|
{status.lastCheckedAt !== undefined && (
|
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
|
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
|
<span>
|
|
{`You're up to date! Rowboat v${status.version} is the latest version.`}
|
|
<span className="text-muted-foreground/60">
|
|
{` Checked at ${new Date(status.lastCheckedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`}
|
|
</span>
|
|
</span>
|
|
</p>
|
|
)}
|
|
<Button size="sm" variant="outline" onClick={checkNow}>
|
|
<RefreshCw className="size-3.5" />
|
|
Check for updates
|
|
</Button>
|
|
</div>
|
|
)
|
|
break
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<h4 className="text-sm font-medium">Updates</h4>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Rowboat v{status.version}</p>
|
|
</div>
|
|
{body}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Help & Support tab ---
|
|
|
|
function HelpSettings() {
|
|
return (
|
|
<div className="space-y-4">
|
|
<UpdateSettings />
|
|
<Separator />
|
|
<div>
|
|
<h4 className="text-sm font-medium">Help & Support</h4>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start gap-3 h-auto py-3"
|
|
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
|
|
>
|
|
<GitHubIcon className="size-5 shrink-0" />
|
|
<div className="flex flex-col items-start">
|
|
<span className="text-sm font-medium">Report a bug</span>
|
|
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
|
|
</div>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start gap-3 h-auto py-3"
|
|
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
|
|
>
|
|
<DiscordIcon className="size-5 shrink-0" />
|
|
<div className="flex flex-col items-start">
|
|
<span className="text-sm font-medium">Join our Discord</span>
|
|
<span className="text-xs text-muted-foreground">Chat with the community</span>
|
|
</div>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start gap-3 h-auto py-3"
|
|
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
|
|
>
|
|
<Mail className="size-5 shrink-0" />
|
|
<div className="flex flex-col items-start">
|
|
<span className="text-sm font-medium">Contact us</span>
|
|
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
|
|
</div>
|
|
</Button>
|
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
|
<a
|
|
href="https://www.rowboatlabs.com/terms-of-service"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hover:text-foreground transition-colors"
|
|
>
|
|
Terms of Service
|
|
</a>
|
|
<span>·</span>
|
|
<a
|
|
href="https://www.rowboatlabs.com/privacy-policy"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hover:text-foreground transition-colors"
|
|
>
|
|
Privacy Policy
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Theme option for Appearance tab ---
|
|
|
|
function ThemeOption({
|
|
label,
|
|
icon: Icon,
|
|
isSelected,
|
|
onClick,
|
|
}: {
|
|
label: string
|
|
icon: React.ElementType
|
|
isSelected: boolean
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
|
|
isSelected
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<Icon className={cn("size-6", isSelected ? "text-primary" : "text-muted-foreground")} />
|
|
<span className={cn("text-sm font-medium", isSelected ? "text-primary" : "text-foreground")}>
|
|
{label}
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function LaunchAtLoginSetting() {
|
|
const [openAtLogin, setOpenAtLogin] = useState(false)
|
|
const [loaded, setLoaded] = useState(false)
|
|
|
|
useEffect(() => {
|
|
window.ipc.invoke("app:getLoginItemSettings", null)
|
|
.then(({ openAtLogin }) => setOpenAtLogin(openAtLogin))
|
|
.catch(() => { /* dev builds report off */ })
|
|
.finally(() => setLoaded(true))
|
|
}, [])
|
|
|
|
const handleToggle = async (next: boolean) => {
|
|
setOpenAtLogin(next)
|
|
try {
|
|
await window.ipc.invoke("app:setLoginItemSettings", { openAtLogin: next })
|
|
} catch {
|
|
setOpenAtLogin(!next)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">Start Rowboat when you log in</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
Keeps Rowboat in your menu bar so meeting notes and notifications work without opening the app
|
|
</div>
|
|
</div>
|
|
<Switch checked={openAtLogin} onCheckedChange={handleToggle} disabled={!loaded} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AppearanceSettings() {
|
|
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-3">System</h4>
|
|
<LaunchAtLoginSetting />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-3">Theme</h4>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
Select your preferred color scheme
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<ThemeOption
|
|
label="Light"
|
|
icon={Sun}
|
|
isSelected={theme === "light"}
|
|
onClick={() => setTheme("light")}
|
|
/>
|
|
<ThemeOption
|
|
label="Dark"
|
|
icon={Moon}
|
|
isSelected={theme === "dark"}
|
|
onClick={() => setTheme("dark")}
|
|
/>
|
|
<ThemeOption
|
|
label="System"
|
|
icon={Monitor}
|
|
isSelected={theme === "system"}
|
|
onClick={() => setTheme("system")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-3">Chat</h4>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
Choose where chat sits when another pane is open
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<ThemeOption
|
|
label="Chat right"
|
|
icon={PanelRight}
|
|
isSelected={chatPanePlacement === "right"}
|
|
onClick={() => setChatPanePlacement("right")}
|
|
/>
|
|
<ThemeOption
|
|
label="Chat middle"
|
|
icon={MessageCircle}
|
|
isSelected={chatPanePlacement === "middle"}
|
|
onClick={() => setChatPanePlacement("middle")}
|
|
/>
|
|
</div>
|
|
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
Choose how much width chat gets when another pane is open
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<ThemeOption
|
|
label="Chat smaller"
|
|
icon={MessageCircle}
|
|
isSelected={chatPaneSize === "chat-smaller"}
|
|
onClick={() => setChatPaneSize("chat-smaller")}
|
|
/>
|
|
<ThemeOption
|
|
label="Chat equal"
|
|
icon={Monitor}
|
|
isSelected={chatPaneSize === "chat-equal"}
|
|
onClick={() => setChatPaneSize("chat-equal")}
|
|
/>
|
|
<ThemeOption
|
|
label="Chat bigger"
|
|
icon={PanelRight}
|
|
isSelected={chatPaneSize === "chat-bigger"}
|
|
onClick={() => setChatPaneSize("chat-bigger")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Model Settings UI ---
|
|
|
|
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
|
|
|
interface LlmModelOption {
|
|
id: string
|
|
name?: string
|
|
release_date?: string
|
|
}
|
|
|
|
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
|
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
|
|
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
|
|
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
|
|
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
|
|
]
|
|
|
|
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
|
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
|
|
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
|
|
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
|
|
]
|
|
|
|
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
|
openai: "gpt-5.4",
|
|
anthropic: "claude-opus-4-8",
|
|
}
|
|
|
|
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
|
ollama: "http://localhost:11434",
|
|
"openai-compatible": "http://localhost:1234/v1",
|
|
aigateway: "https://ai-gateway.vercel.sh/v1",
|
|
}
|
|
|
|
type ProviderModelConfig = {
|
|
apiKey: string
|
|
baseURL: string
|
|
models: string[]
|
|
knowledgeGraphModel: string
|
|
meetingNotesModel: string
|
|
liveNoteAgentModel: string
|
|
autoPermissionDecisionModel: string
|
|
}
|
|
|
|
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
|
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
|
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
|
// Flavors present in the saved providers map — drives each card's
|
|
// "Connected" indicator, independent of which card is active.
|
|
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
|
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
|
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
})
|
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
|
const [modelsLoading, setModelsLoading] = useState(false)
|
|
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
|
const [configLoading, setConfigLoading] = useState(true)
|
|
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
|
// "Defer background tasks while a chat is running" — a top-level
|
|
// models.json flag. deferExplicit tracks whether the user (or the Ollama
|
|
// auto-enable) has ever set it, so we only auto-enable once.
|
|
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
|
const [deferExplicit, setDeferExplicit] = useState(false)
|
|
// openai-compatible only: free-text model that takes precedence over the
|
|
// fetched list (many such servers don't implement /models at all).
|
|
const [customModel, setCustomModel] = useState("")
|
|
|
|
const activeConfig = providerConfigs[provider]
|
|
// Live per-key model list for the active provider — drives the primary
|
|
// model area. The per-function fields below still use the static catalog.
|
|
const providerModels = useProviderModels({
|
|
flavor: provider,
|
|
apiKey: activeConfig.apiKey,
|
|
baseURL: activeConfig.baseURL,
|
|
})
|
|
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
|
|
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
|
|
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
|
|
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
|
|
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
|
|
const modelsForProvider = modelsCatalog[provider] || []
|
|
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
|
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
|
|
|
const primaryModel = activeConfig.models[0] || ""
|
|
// Settings no longer exposes model selection — the model is resolved
|
|
// silently when the user connects (the config schema still requires one;
|
|
// background agents/channels read it). Precedence: a typed escape-hatch
|
|
// value (openai-compatible Model field, offline manual input) > the saved
|
|
// model when the fetched list still has it > the flavor's preferred
|
|
// default > the first fetched id.
|
|
const resolvedModel = (() => {
|
|
if (provider === "openai-compatible" && customModel.trim()) return customModel.trim()
|
|
const saved = primaryModel.trim()
|
|
if (providerModels.status === "loaded" && providerModels.models.length > 0) {
|
|
if (saved && providerModels.models.includes(saved)) return saved
|
|
const preferred = preferredDefaults[provider]
|
|
if (preferred && providerModels.models.includes(preferred)) return preferred
|
|
return providerModels.models[0]
|
|
}
|
|
return saved
|
|
})()
|
|
// Gate Connect on credentials only, NOT on resolvedModel — that derives
|
|
// from the live fetch, which hasn't settled the instant a key is pasted, so
|
|
// gating on it made the first click a no-op (the model is resolved, fetching
|
|
// on demand if needed, inside handleTestAndSave).
|
|
const canTest =
|
|
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
|
|
|
const updateConfig = useCallback(
|
|
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
|
setProviderConfigs(prev => ({
|
|
...prev,
|
|
[prov]: { ...prev[prov], ...updates },
|
|
}))
|
|
setTestState({ status: "idle" })
|
|
},
|
|
[]
|
|
)
|
|
|
|
// All primary-model writes go through here: the old `models: [value]` form
|
|
// silently dropped every saved model after the first.
|
|
const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => {
|
|
setProviderConfigs(prev => {
|
|
const existing = prev[prov].models
|
|
return {
|
|
...prev,
|
|
[prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] },
|
|
}
|
|
})
|
|
setTestState({ status: "idle" })
|
|
}, [])
|
|
|
|
|
|
// Load current config from file
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
|
|
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
|
|
|
|
async function loadCurrentConfig() {
|
|
try {
|
|
setConfigLoading(true)
|
|
const result = await window.ipc.invoke("workspace:readFile", {
|
|
path: "config/models.json",
|
|
})
|
|
const parsed = JSON.parse(result.data)
|
|
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
|
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
|
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
|
|
setSavedProviders(new Set(
|
|
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
|
|
))
|
|
if (parsed?.provider?.flavor && parsed?.model) {
|
|
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
|
setProvider(flavor)
|
|
setDefaultProvider(flavor)
|
|
setProviderConfigs(prev => {
|
|
const next = { ...prev };
|
|
// Hydrate all saved providers from the providers map
|
|
if (parsed.providers) {
|
|
for (const [key, entry] of Object.entries(parsed.providers)) {
|
|
if (key in next) {
|
|
const e = entry as any;
|
|
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
|
|
? e.models
|
|
: e.model ? [e.model] : [""];
|
|
next[key as LlmProviderFlavor] = {
|
|
apiKey: e.apiKey || "",
|
|
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
|
models: savedModels,
|
|
knowledgeGraphModel: asString(e.knowledgeGraphModel),
|
|
meetingNotesModel: asString(e.meetingNotesModel),
|
|
liveNoteAgentModel: asString(e.liveNoteAgentModel),
|
|
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
// Active provider takes precedence from top-level config,
|
|
// but only if it exists in the providers map (wasn't deleted)
|
|
if (parsed.providers?.[flavor]) {
|
|
const existingModels = next[flavor].models;
|
|
const activeModels = existingModels[0] === parsed.model
|
|
? existingModels
|
|
: [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)];
|
|
next[flavor] = {
|
|
apiKey: parsed.provider.apiKey || "",
|
|
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
|
models: activeModels.length > 0 ? activeModels : [""],
|
|
knowledgeGraphModel: asString(parsed.knowledgeGraphModel),
|
|
meetingNotesModel: asString(parsed.meetingNotesModel),
|
|
liveNoteAgentModel: asString(parsed.liveNoteAgentModel),
|
|
autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel),
|
|
};
|
|
}
|
|
return next;
|
|
})
|
|
}
|
|
} catch {
|
|
// No existing config or parse error - use defaults
|
|
} finally {
|
|
setConfigLoading(false)
|
|
}
|
|
}
|
|
|
|
loadCurrentConfig()
|
|
}, [dialogOpen])
|
|
|
|
const handleDeferToggle = useCallback(async (value: boolean) => {
|
|
setDeferBackgroundTasks(value)
|
|
setDeferExplicit(true)
|
|
try {
|
|
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
|
window.dispatchEvent(new Event("models-config-changed"))
|
|
} catch {
|
|
toast.error("Failed to save setting")
|
|
}
|
|
}, [])
|
|
|
|
// Load models catalog
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
|
|
async function loadModels() {
|
|
try {
|
|
setModelsLoading(true)
|
|
const result = await window.ipc.invoke("models:list", null)
|
|
const catalog: Record<string, LlmModelOption[]> = {}
|
|
for (const p of result.providers || []) {
|
|
catalog[p.id] = p.models || []
|
|
}
|
|
setModelsCatalog(catalog)
|
|
} catch {
|
|
setModelsCatalog({})
|
|
} finally {
|
|
setModelsLoading(false)
|
|
}
|
|
}
|
|
|
|
loadModels()
|
|
}, [dialogOpen])
|
|
|
|
// A saved openai-compatible model that the server's list doesn't confirm
|
|
// (not listed, or /models unreachable) belongs in the visible Model field,
|
|
// where it stays editable and wins over the silent pick.
|
|
useEffect(() => {
|
|
if (provider !== "openai-compatible" || customModel || !primaryModel) return
|
|
if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) {
|
|
setCustomModel(primaryModel)
|
|
}
|
|
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
|
|
|
|
const handleTestAndSave = useCallback(async () => {
|
|
if (!canTest) return
|
|
setTestState({ status: "testing" })
|
|
try {
|
|
// Normally providerModels has loaded by click time and resolvedModel is
|
|
// set. But a Connect click right after pasting a key can beat the
|
|
// debounced key-change fetch — so when nothing is resolved yet, fetch
|
|
// the list on demand (one fetch, then save) instead of forcing a second
|
|
// click. Same silent precedence as resolvedModel, minus the customModel
|
|
// branch (that path only runs when resolvedModel is already empty).
|
|
let model = resolvedModel
|
|
if (!model) {
|
|
const listRes = await window.ipc.invoke("models:listForProvider", {
|
|
provider: {
|
|
flavor: provider,
|
|
apiKey: activeConfig.apiKey.trim() || undefined,
|
|
baseURL: activeConfig.baseURL.trim() || undefined,
|
|
},
|
|
})
|
|
if (listRes.success && listRes.models && listRes.models.length > 0) {
|
|
const preferred = preferredDefaults[provider]
|
|
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
|
|
}
|
|
}
|
|
if (!model) {
|
|
setTestState({ status: "error", error: "Enter a model to connect" })
|
|
toast.error("Enter a model to connect")
|
|
return
|
|
}
|
|
// The silently resolved model takes the primary slot; the rest of the
|
|
// saved list is preserved (same semantics setPrimaryModel had).
|
|
const existing = activeConfig.models.map(m => m.trim())
|
|
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
|
|
const providerConfig = {
|
|
provider: {
|
|
flavor: provider,
|
|
apiKey: activeConfig.apiKey.trim() || undefined,
|
|
baseURL: activeConfig.baseURL.trim() || undefined,
|
|
},
|
|
model: allModels[0] || "",
|
|
models: allModels,
|
|
...(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) {
|
|
await window.ipc.invoke("models:saveConfig", providerConfig)
|
|
setDefaultProvider(provider)
|
|
setProviderConfigs(prev => ({
|
|
...prev,
|
|
[provider]: { ...prev[provider], models: allModels },
|
|
}))
|
|
setSavedProviders(prev => new Set(prev).add(provider))
|
|
setTestState({ status: "success" })
|
|
window.dispatchEvent(new Event('models-config-changed'))
|
|
// Local models compete with background agents for the same hardware:
|
|
// when the user connects Ollama and has never touched the defer
|
|
// flag, enable it for them (they can switch it off below).
|
|
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
|
|
void handleDeferToggle(true)
|
|
}
|
|
// Capability probe caveats (local models): saved, but the user should
|
|
// know when the model can't do tools or has a too-small context.
|
|
const warnings: string[] = result.warnings ?? []
|
|
if (warnings.length > 0) {
|
|
for (const warning of warnings) {
|
|
toast.warning(warning, { duration: 12000 })
|
|
}
|
|
toast.success("Model configuration saved (with warnings)")
|
|
} else {
|
|
toast.success("Model configuration saved")
|
|
}
|
|
} else {
|
|
setTestState({ status: "error", error: result.error })
|
|
toast.error(result.error || "Connection test failed")
|
|
}
|
|
} catch {
|
|
setTestState({ status: "error", error: "Connection test failed" })
|
|
toast.error("Connection test failed")
|
|
}
|
|
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
|
|
|
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
|
const config = providerConfigs[prov]
|
|
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
|
if (!allModels[0]) return
|
|
try {
|
|
await window.ipc.invoke("models:saveConfig", {
|
|
provider: {
|
|
flavor: prov,
|
|
apiKey: config.apiKey.trim() || undefined,
|
|
baseURL: config.baseURL.trim() || undefined,
|
|
},
|
|
model: allModels[0],
|
|
models: allModels,
|
|
...(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'))
|
|
toast.success("Default provider updated")
|
|
} catch {
|
|
toast.error("Failed to set default provider")
|
|
}
|
|
}, [providerConfigs, rowboatConnected])
|
|
|
|
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
|
const isDefaultProv = defaultProvider === prov
|
|
try {
|
|
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
|
let parsed = JSON.parse(result.data)
|
|
|
|
// Disconnecting the default provider: silently promote another
|
|
// connected provider first — the connect-only UI has no usable
|
|
// set-as-default step to send the user to. Prefer the provider the
|
|
// user's explicit defaultSelection points at; promotion goes through
|
|
// the same models:saveConfig path Set-as-default uses, so the repo
|
|
// writes a valid top-level provider/model.
|
|
let promoted: LlmProviderFlavor | null = null
|
|
if (isDefaultProv) {
|
|
const selProvider = parsed?.defaultSelection?.provider
|
|
const candidates = Object.keys(parsed?.providers ?? {})
|
|
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
|
|
.sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0))
|
|
for (const candidate of candidates) {
|
|
const config = providerConfigs[candidate]
|
|
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
|
// Same silent precedence as connect, minus the live list we don't
|
|
// have here: the provider's saved model, else its preferred default.
|
|
const model = allModels[0] || preferredDefaults[candidate] || ""
|
|
if (!model) continue
|
|
await window.ipc.invoke("models:saveConfig", {
|
|
provider: {
|
|
flavor: candidate,
|
|
apiKey: config.apiKey.trim() || undefined,
|
|
baseURL: config.baseURL.trim() || undefined,
|
|
},
|
|
model,
|
|
models: allModels.length > 0 ? allModels : [model],
|
|
...(rowboatConnected ? {} : {
|
|
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
|
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
|
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
|
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
|
}),
|
|
})
|
|
promoted = candidate
|
|
break
|
|
}
|
|
if (promoted) {
|
|
// saveConfig rewrote top-level and the providers map — re-read so
|
|
// the deletion write below doesn't clobber the promotion.
|
|
const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
|
parsed = JSON.parse(fresh.data)
|
|
}
|
|
}
|
|
|
|
if (parsed?.providers?.[prov]) {
|
|
delete parsed.providers[prov]
|
|
}
|
|
// A defaultSelection pointing at the removed provider is dangling —
|
|
// drop it so llm:getDefaultModel falls back cleanly.
|
|
if (parsed?.defaultSelection?.provider === prov) {
|
|
delete parsed.defaultSelection
|
|
}
|
|
// If the deleted provider is the current top-level active one,
|
|
// switch top-level config to the current default provider
|
|
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
|
|
const defConfig = providerConfigs[defaultProvider]
|
|
const defModels = defConfig.models.map(m => m.trim()).filter(Boolean)
|
|
parsed.provider = {
|
|
flavor: defaultProvider,
|
|
apiKey: defConfig.apiKey.trim() || undefined,
|
|
baseURL: defConfig.baseURL.trim() || undefined,
|
|
}
|
|
parsed.model = defModels[0] || ""
|
|
parsed.models = defModels
|
|
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
|
|
}
|
|
} else if (parsed?.provider?.flavor === prov) {
|
|
// Removing the last connected provider: drop the top-level pair
|
|
// entirely. The schema requires it, so core's readConfig() treats
|
|
// the file as "no config" — signed-in falls back to the curated
|
|
// gateway default and signed-out llm:getDefaultModel rejects, which
|
|
// the composer already handles (it shows the connect hint).
|
|
delete parsed.provider
|
|
delete parsed.model
|
|
delete parsed.models
|
|
delete parsed.knowledgeGraphModel
|
|
delete parsed.meetingNotesModel
|
|
delete parsed.liveNoteAgentModel
|
|
delete parsed.autoPermissionDecisionModel
|
|
// With no BYOK providers left, any non-gateway selection is dangling.
|
|
if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat"
|
|
&& Object.keys(parsed?.providers ?? {}).length === 0) {
|
|
delete parsed.defaultSelection
|
|
}
|
|
}
|
|
await window.ipc.invoke("workspace:writeFile", {
|
|
path: "config/models.json",
|
|
data: JSON.stringify(parsed, null, 2),
|
|
})
|
|
setProviderConfigs(prev => ({
|
|
...prev,
|
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
|
}))
|
|
setSavedProviders(prev => {
|
|
const next = new Set(prev)
|
|
next.delete(prov)
|
|
return next
|
|
})
|
|
if (isDefaultProv) setDefaultProvider(promoted)
|
|
setTestState({ status: "idle" })
|
|
window.dispatchEvent(new Event('models-config-changed'))
|
|
if (promoted) {
|
|
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
|
|
toast.success(`Disconnected · ${promotedName} is now the default`)
|
|
} else {
|
|
toast.success("Provider configuration removed")
|
|
}
|
|
} catch {
|
|
toast.error("Failed to remove provider")
|
|
}
|
|
}, [defaultProvider, providerConfigs, rowboatConnected])
|
|
|
|
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
|
const isDefault = defaultProvider === p.id
|
|
const isSelected = provider === p.id
|
|
const isConnected = savedProviders.has(p.id)
|
|
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
|
return (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => {
|
|
setProvider(p.id)
|
|
setTestState({ status: "idle" })
|
|
}}
|
|
className={cn(
|
|
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
|
|
isSelected
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-accent"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<p.icon className="size-4 shrink-0" />
|
|
<span className="text-sm font-medium">{p.name}</span>
|
|
{isDefault && !rowboatConnected && (
|
|
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
|
Default
|
|
</span>
|
|
)}
|
|
{isConnected && (
|
|
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
|
Connected
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
|
{!isDefault && hasModel && isSelected && (
|
|
<div className="mt-1.5 flex items-center gap-3">
|
|
{!rowboatConnected && (
|
|
<span
|
|
role="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleSetDefault(p.id)
|
|
}}
|
|
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
|
>
|
|
Set as default
|
|
</span>
|
|
)}
|
|
<span
|
|
role="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleDeleteProvider(p.id)
|
|
}}
|
|
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
|
>
|
|
Remove
|
|
</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (configLoading) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
<Loader2 className="size-4 animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Provider selection */}
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
|
|
<div className="grid gap-2 grid-cols-2">
|
|
{primaryProviders.map(renderProviderCard)}
|
|
</div>
|
|
{(showMoreProviders || isMoreProvider) ? (
|
|
<div className="grid gap-2 grid-cols-2 mt-2">
|
|
{moreProviders.map(renderProviderCard)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowMoreProviders(true)}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
|
>
|
|
More providers...
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* API Key — key-first: the model list is fetched from it */}
|
|
{showApiKey && (
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
|
|
</span>
|
|
<Input
|
|
type="password"
|
|
value={activeConfig.apiKey}
|
|
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
|
|
onBlur={() => providerModels.refetch()}
|
|
placeholder="Paste your API key"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Base URL */}
|
|
{showBaseURL && (
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
|
|
<Input
|
|
value={activeConfig.baseURL}
|
|
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
|
|
onBlur={() => providerModels.refetch()}
|
|
placeholder={
|
|
provider === "ollama"
|
|
? "http://localhost:11434"
|
|
: provider === "openai-compatible"
|
|
? "http://localhost:1234/v1"
|
|
: "https://ai-gateway.vercel.sh/v1"
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection status — the model itself is resolved silently on save */}
|
|
<div className="space-y-2">
|
|
{providerModels.status === "idle" ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{isLocalProvider
|
|
? "Enter your base URL to connect"
|
|
: "Enter your API key to connect"}
|
|
</div>
|
|
) : providerModels.status === "loading" ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Checking connection…
|
|
</div>
|
|
) : providerModels.status === "error" ? (
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-destructive break-words">
|
|
{providerModels.error || "Connection check failed"}
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
|
|
Retry
|
|
</Button>
|
|
{provider !== "openai-compatible" && (
|
|
<Input
|
|
value={primaryModel}
|
|
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
|
placeholder="Enter a model to connect anyway"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
|
|
<div className="space-y-2">
|
|
<div className="text-sm text-muted-foreground">
|
|
Connected, but the provider reported no models — enter one manually
|
|
</div>
|
|
<Input
|
|
value={primaryModel}
|
|
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
|
placeholder="Enter model"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
|
<CheckCircle2 className="size-4" />
|
|
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
|
|
</div>
|
|
)}
|
|
{savedProviders.has(provider) && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={() => handleDeleteProvider(provider)}
|
|
>
|
|
Disconnect
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* openai-compatible escape hatch: its /models often doesn't exist, and
|
|
a typed model always wins over the silent pick */}
|
|
{provider === "openai-compatible" && (
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
|
<Input
|
|
value={customModel}
|
|
onChange={(e) => {
|
|
setCustomModel(e.target.value)
|
|
setPrimaryModel(provider, e.target.value)
|
|
}}
|
|
placeholder="Model ID (leave empty to auto-select)"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-function model overrides */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{!rowboatConnected && (<>
|
|
{/* Knowledge graph model (right column) */}
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
|
{modelsLoading ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : showModelInput ? (
|
|
<Input
|
|
value={activeConfig.knowledgeGraphModel}
|
|
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
|
|
placeholder={primaryModel || "Enter model"}
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={activeConfig.knowledgeGraphModel || "__same__"}
|
|
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
{modelsForProvider.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name || m.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Meeting notes model */}
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
|
{modelsLoading ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : showModelInput ? (
|
|
<Input
|
|
value={activeConfig.meetingNotesModel}
|
|
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
|
placeholder={primaryModel || "Enter model"}
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={activeConfig.meetingNotesModel || "__same__"}
|
|
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
{modelsForProvider.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name || m.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Track block model */}
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
|
{modelsLoading ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : showModelInput ? (
|
|
<Input
|
|
value={activeConfig.liveNoteAgentModel}
|
|
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
|
|
placeholder={primaryModel || "Enter model"}
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={activeConfig.liveNoteAgentModel || "__same__"}
|
|
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
{modelsForProvider.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name || m.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Auto-permission model */}
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
|
{modelsLoading ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : showModelInput ? (
|
|
<Input
|
|
value={activeConfig.autoPermissionDecisionModel}
|
|
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
|
placeholder={primaryModel || "Enter model"}
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
|
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
|
{modelsForProvider.map((m) => (
|
|
<SelectItem key={m.id} value={m.id}>
|
|
{m.name || m.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
</>)}
|
|
</div>
|
|
|
|
{/* Test status */}
|
|
{testState.status === "error" && (
|
|
<div className="text-sm text-destructive">
|
|
{testState.error || "Connection test failed"}
|
|
</div>
|
|
)}
|
|
{testState.status === "success" && (
|
|
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
|
<CheckCircle2 className="size-4" />
|
|
Connected and saved
|
|
</div>
|
|
)}
|
|
|
|
{/* Defer background tasks while chatting */}
|
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
|
|
</div>
|
|
</div>
|
|
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
|
</div>
|
|
|
|
{/* Test & Save button */}
|
|
<Button
|
|
onClick={handleTestAndSave}
|
|
disabled={!canTest || testState.status === "testing"}
|
|
className="w-full"
|
|
>
|
|
{testState.status === "testing" ? (
|
|
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
|
|
) : (
|
|
"Connect"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Tools Library Settings ---
|
|
|
|
interface ToolkitInfo {
|
|
slug: string
|
|
name: string
|
|
meta: { description: string; logo: string; tools_count: number; triggers_count: number }
|
|
no_auth?: boolean
|
|
auth_schemes?: string[]
|
|
composio_managed_auth_schemes?: string[]
|
|
}
|
|
|
|
function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: boolean; rowboatConnected: boolean }) {
|
|
// API key state
|
|
const [apiKeyConfigured, setApiKeyConfigured] = useState(false)
|
|
const [apiKeyInput, setApiKeyInput] = useState("")
|
|
const [apiKeySaving, setApiKeySaving] = useState(false)
|
|
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
|
|
|
|
// Toolkit browsing state
|
|
const [toolkits, setToolkits] = useState<ToolkitInfo[]>([])
|
|
const [toolkitsLoading, setToolkitsLoading] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
|
|
// Connection state
|
|
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
|
|
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null)
|
|
|
|
// Check API key configuration
|
|
const checkApiKey = useCallback(async () => {
|
|
try {
|
|
const result = await window.ipc.invoke("composio:is-configured", null)
|
|
setApiKeyConfigured(result.configured)
|
|
if (!result.configured) {
|
|
setShowApiKeyInput(true)
|
|
}
|
|
} catch {
|
|
setApiKeyConfigured(false)
|
|
}
|
|
}, [])
|
|
|
|
// Load connected toolkits
|
|
const loadConnected = useCallback(async () => {
|
|
try {
|
|
const result = await window.ipc.invoke("composio:list-connected", null)
|
|
setConnectedToolkits(new Set(result.toolkits))
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [])
|
|
|
|
// Load toolkits
|
|
const loadToolkits = useCallback(async () => {
|
|
setToolkitsLoading(true)
|
|
try {
|
|
const result = await window.ipc.invoke("composio:list-toolkits", {})
|
|
setToolkits(result.items)
|
|
} catch {
|
|
toast.error("Failed to load toolkits")
|
|
} finally {
|
|
setToolkitsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
checkApiKey()
|
|
loadConnected()
|
|
}, [dialogOpen, checkApiKey, loadConnected])
|
|
|
|
// Load toolkits when API key is configured
|
|
useEffect(() => {
|
|
if (dialogOpen && apiKeyConfigured) {
|
|
loadToolkits()
|
|
}
|
|
}, [dialogOpen, apiKeyConfigured, loadToolkits])
|
|
|
|
// Listen for composio connection events
|
|
useEffect(() => {
|
|
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
|
const { toolkitSlug, success, error } = event
|
|
setConnectingToolkit(null)
|
|
if (success) {
|
|
setConnectedToolkits(prev => new Set([...prev, toolkitSlug]))
|
|
toast.success(`Connected to ${toolkitSlug}`)
|
|
} else {
|
|
toast.error(error || `Failed to connect to ${toolkitSlug}`)
|
|
}
|
|
})
|
|
return cleanup
|
|
}, [])
|
|
|
|
// Save API key
|
|
const handleSaveApiKey = async () => {
|
|
const trimmed = apiKeyInput.trim()
|
|
if (!trimmed) return
|
|
setApiKeySaving(true)
|
|
try {
|
|
const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed })
|
|
if (result.success) {
|
|
setApiKeyConfigured(true)
|
|
setShowApiKeyInput(false)
|
|
setApiKeyInput("")
|
|
toast.success("Composio API key saved")
|
|
} else {
|
|
toast.error(result.error || "Failed to save API key")
|
|
}
|
|
} catch {
|
|
toast.error("Failed to save API key")
|
|
} finally {
|
|
setApiKeySaving(false)
|
|
}
|
|
}
|
|
|
|
// Connect a toolkit
|
|
const handleConnect = async (toolkitSlug: string) => {
|
|
setConnectingToolkit(toolkitSlug)
|
|
try {
|
|
const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug })
|
|
if (!result.success) {
|
|
toast.error(result.error || "Failed to connect")
|
|
setConnectingToolkit(null)
|
|
}
|
|
// Success will be handled by composio:didConnect event
|
|
} catch {
|
|
toast.error("Failed to connect")
|
|
setConnectingToolkit(null)
|
|
}
|
|
}
|
|
|
|
// Disconnect a toolkit
|
|
const handleDisconnect = async (toolkitSlug: string) => {
|
|
try {
|
|
await window.ipc.invoke("composio:disconnect", { toolkitSlug })
|
|
setConnectedToolkits(prev => {
|
|
const next = new Set(prev)
|
|
next.delete(toolkitSlug)
|
|
return next
|
|
})
|
|
toast.success(`Disconnected from ${toolkitSlug}`)
|
|
} catch {
|
|
toast.error("Failed to disconnect")
|
|
}
|
|
}
|
|
|
|
// Filter toolkits by search
|
|
const filteredToolkits = searchQuery.trim()
|
|
? toolkits.filter(t =>
|
|
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
t.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
t.meta.description.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
: toolkits
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Section A: API Key (only in BYOK mode) */}
|
|
{!rowboatConnected && (
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Composio API Key</span>
|
|
{apiKeyConfigured && !showApiKeyInput ? (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
|
<CheckCircle2 className="size-4" />
|
|
API key configured
|
|
</div>
|
|
<button
|
|
onClick={() => setShowApiKeyInput(true)}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Change
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
Enter your Composio API key to browse and enable tool integrations.
|
|
Get your key from{" "}
|
|
<a
|
|
href="https://app.composio.dev/settings"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
app.composio.dev/settings
|
|
</a>
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="password"
|
|
value={apiKeyInput}
|
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
|
placeholder="Paste your Composio API key"
|
|
onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
onClick={handleSaveApiKey}
|
|
disabled={!apiKeyInput.trim() || apiKeySaving}
|
|
size="sm"
|
|
>
|
|
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
|
|
</Button>
|
|
{apiKeyConfigured && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => { setShowApiKeyInput(false); setApiKeyInput("") }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Section B: Toolkit Browser (only when API key configured) */}
|
|
{apiKeyConfigured && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Toolkits</span>
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search toolkits..."
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{toolkitsLoading ? (
|
|
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
|
<Loader2 className="size-4 animate-spin mr-2" />
|
|
Loading toolkits...
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-[400px] overflow-y-auto pr-1">
|
|
{filteredToolkits.map((toolkit) => {
|
|
const isConnected = connectedToolkits.has(toolkit.slug)
|
|
const isConnecting = connectingToolkit === toolkit.slug
|
|
|
|
return (
|
|
<div key={toolkit.slug} className="border rounded-lg overflow-hidden">
|
|
<div className="flex items-center gap-3 px-3 py-2.5">
|
|
{/* Logo */}
|
|
{toolkit.meta.logo ? (
|
|
<img
|
|
src={toolkit.meta.logo}
|
|
alt=""
|
|
className="size-7 rounded object-contain shrink-0"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
/>
|
|
) : (
|
|
<div className="size-7 rounded bg-muted flex items-center justify-center shrink-0">
|
|
<Wrench className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Name & description */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm font-medium truncate">{toolkit.name}</span>
|
|
{isConnected && (
|
|
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
|
Connected
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{toolkit.meta.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Connect / Disconnect button */}
|
|
{isConnected ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleDisconnect(toolkit.slug)}
|
|
className="text-xs h-7 shrink-0"
|
|
>
|
|
Disconnect
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleConnect(toolkit.slug)}
|
|
disabled={isConnecting}
|
|
className="text-xs h-7 shrink-0"
|
|
>
|
|
{isConnecting ? (
|
|
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
|
|
) : (
|
|
<><Link2 className="size-3 mr-1" />Connect</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{filteredToolkits.length === 0 && !toolkitsLoading && (
|
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
|
{searchQuery ? "No toolkits match your search" : "No toolkits available"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- 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<string, string> = {
|
|
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 [options, setOptions] = useState<HybridModelOption[]>([])
|
|
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)
|
|
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
try {
|
|
const collected: HybridModelOption[] = []
|
|
const seen = new Set<string>()
|
|
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 })
|
|
}
|
|
|
|
const catalog: Record<string, LlmModelOption[]> = {}
|
|
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<string, unknown> = {}
|
|
try {
|
|
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
|
parsed = JSON.parse(configResult.data)
|
|
} catch { /* no BYOK config yet */ }
|
|
|
|
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
|
|
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<string, unknown> | 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 {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
load()
|
|
}, [dialogOpen])
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true)
|
|
try {
|
|
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")
|
|
} catch {
|
|
toast.error("Failed to save model configuration")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
|
|
|
|
const renderSelect = (
|
|
label: string,
|
|
value: string,
|
|
onChange: (v: string) => void,
|
|
defaultLabel: string,
|
|
) => (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{label}</label>
|
|
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder={defaultLabel} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__default__">{defaultLabel}</SelectItem>
|
|
{options.map((o) => {
|
|
const key = hybridKey(o.provider, o.model)
|
|
return (
|
|
<SelectItem key={key} value={key}>
|
|
{o.label}
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
{providerDisplayNames[o.provider] || o.provider}
|
|
</span>
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
|
|
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. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
|
|
</p>
|
|
|
|
{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 */}
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? (
|
|
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
|
|
) : (
|
|
"Save"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Note Tagging Settings ---
|
|
|
|
interface TagDef {
|
|
tag: string
|
|
type: string
|
|
applicability: "email" | "notes" | "both"
|
|
description: string
|
|
example?: string
|
|
noteEffect?: "create" | "skip" | "none"
|
|
}
|
|
|
|
const NOTE_TAG_TYPE_ORDER = [
|
|
"relationship", "relationship-sub", "topic", "action", "status", "source",
|
|
]
|
|
|
|
const EMAIL_TAG_TYPE_ORDER = [
|
|
"relationship", "topic", "email-type", "noise", "action", "status",
|
|
]
|
|
|
|
const TAG_TYPE_LABELS: Record<string, string> = {
|
|
"relationship": "Relationship",
|
|
"relationship-sub": "Relationship Sub-Tags",
|
|
"topic": "Topic",
|
|
"email-type": "Email Type",
|
|
"noise": "Noise",
|
|
"action": "Action",
|
|
"status": "Status",
|
|
"source": "Source",
|
|
}
|
|
|
|
|
|
function TagGroupTable({
|
|
group,
|
|
tags: _tags,
|
|
collapsed,
|
|
onToggle,
|
|
onAdd,
|
|
onUpdate,
|
|
onRemove,
|
|
getGlobalIndex,
|
|
isEmail,
|
|
}: {
|
|
group: { type: string; label: string; tags: TagDef[] }
|
|
tags: TagDef[]
|
|
collapsed: boolean
|
|
onToggle: () => void
|
|
onAdd: () => void
|
|
onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
|
|
onRemove: (index: number) => void
|
|
getGlobalIndex: (type: string, localIndex: number) => number
|
|
isEmail: boolean
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<button
|
|
onClick={onToggle}
|
|
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ChevronRight className={cn("size-3.5 transition-transform", !collapsed && "rotate-90")} />
|
|
{group.label}
|
|
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={onAdd}
|
|
>
|
|
<Plus className="size-3 mr-1" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
{!collapsed && group.tags.length > 0 && (
|
|
<div className="border rounded-md overflow-hidden">
|
|
<div className={cn(
|
|
"gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider grid",
|
|
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
|
)}>
|
|
<div>Label</div>
|
|
<div>Description</div>
|
|
<div>Example</div>
|
|
{isEmail && <div className="text-center" title="Emails with this label will be excluded from creating notes">Skip notes</div>}
|
|
<div />
|
|
</div>
|
|
{group.tags.map((tag, localIdx) => {
|
|
const globalIdx = getGlobalIndex(group.type, localIdx)
|
|
return (
|
|
<div key={globalIdx} className={cn(
|
|
"gap-1 border-t px-2 py-0.5 items-center grid",
|
|
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
|
)}>
|
|
<Input
|
|
value={tag.tag}
|
|
onChange={e => onUpdate(globalIdx, "tag", e.target.value)}
|
|
className="h-7 text-xs"
|
|
placeholder="tag-name"
|
|
title={tag.tag}
|
|
/>
|
|
<Input
|
|
value={tag.description}
|
|
onChange={e => onUpdate(globalIdx, "description", e.target.value)}
|
|
className="h-7 text-xs"
|
|
placeholder="Description"
|
|
title={tag.description}
|
|
/>
|
|
<Input
|
|
value={tag.example || ""}
|
|
onChange={e => onUpdate(globalIdx, "example", e.target.value)}
|
|
className="h-7 text-xs"
|
|
placeholder="Example"
|
|
title={tag.example || ""}
|
|
/>
|
|
{isEmail && (
|
|
<div className="flex justify-center">
|
|
<Switch
|
|
checked={tag.noteEffect === "skip"}
|
|
onCheckedChange={checked => onUpdate(globalIdx, "noteEffect", checked ? "skip" : "create")}
|
|
className="scale-75"
|
|
/>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => onRemove(globalIdx)}
|
|
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
{!collapsed && group.tags.length === 0 && (
|
|
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|
const [tags, setTags] = useState<TagDef[]>([])
|
|
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
|
const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
|
|
|
|
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
|
|
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
async function load() {
|
|
setLoading(true)
|
|
try {
|
|
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
|
|
const parsed = JSON.parse(result.data)
|
|
setTags(parsed)
|
|
setOriginalTags(parsed)
|
|
} catch {
|
|
setTags([])
|
|
setOriginalTags([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
load()
|
|
}, [dialogOpen])
|
|
|
|
const noteGroups = useMemo(() => {
|
|
const map = new Map<string, TagDef[]>()
|
|
for (const tag of tags) {
|
|
if (tag.applicability === "email") continue
|
|
const list = map.get(tag.type) ?? []
|
|
list.push(tag)
|
|
map.set(tag.type, list)
|
|
}
|
|
return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
|
type,
|
|
label: TAG_TYPE_LABELS[type],
|
|
tags: map.get(type) ?? [],
|
|
}))
|
|
}, [tags])
|
|
|
|
const emailGroups = useMemo(() => {
|
|
const map = new Map<string, TagDef[]>()
|
|
for (const tag of tags) {
|
|
if (tag.applicability === "notes") continue
|
|
const list = map.get(tag.type) ?? []
|
|
list.push(tag)
|
|
map.set(tag.type, list)
|
|
}
|
|
return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
|
type,
|
|
label: TAG_TYPE_LABELS[type],
|
|
tags: map.get(type) ?? [],
|
|
}))
|
|
}, [tags])
|
|
|
|
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
|
|
let count = 0
|
|
for (let i = 0; i < tags.length; i++) {
|
|
if (tags[i].type === type) {
|
|
if (count === localIndex) return i
|
|
count++
|
|
}
|
|
}
|
|
return -1
|
|
}, [tags])
|
|
|
|
const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
|
|
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
|
|
}, [])
|
|
|
|
const removeTag = useCallback((index: number) => {
|
|
setTags(prev => prev.filter((_, i) => i !== index))
|
|
}, [])
|
|
|
|
const addTag = useCallback((type: string) => {
|
|
const isEmailSection = activeSection === "email"
|
|
const applicability = isEmailSection ? "email" as const : "notes" as const
|
|
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
|
|
const emailOnlyTypes = ["email-type", "noise"]
|
|
const notesOnlyTypes = ["relationship-sub", "source"]
|
|
let finalApplicability: "email" | "notes" | "both" = "both"
|
|
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
|
|
else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
|
|
else finalApplicability = isEmailSection ? "email" : applicability
|
|
|
|
const newTag: TagDef = {
|
|
tag: "",
|
|
type,
|
|
applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
|
|
description: "",
|
|
noteEffect: isEmailSection ? "create" : "none",
|
|
}
|
|
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
|
|
if (lastIndex === -1) {
|
|
setTags(prev => [...prev, newTag])
|
|
} else {
|
|
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
|
|
}
|
|
}, [tags, activeSection])
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setSaving(true)
|
|
try {
|
|
await window.ipc.invoke("workspace:writeFile", {
|
|
path: "config/tags.json",
|
|
data: JSON.stringify(tags, null, 2),
|
|
})
|
|
setOriginalTags([...tags])
|
|
toast.success("Tag configuration saved")
|
|
} catch {
|
|
toast.error("Failed to save tag configuration")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [tags])
|
|
|
|
const toggleGroup = useCallback((type: string) => {
|
|
setCollapsedGroups(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(type)) next.delete(type)
|
|
else next.add(type)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
<Loader2 className="size-4 animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex items-center gap-1 mb-3 border-b">
|
|
<button
|
|
onClick={() => setActiveSection("notes")}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
|
activeSection === "notes"
|
|
? "border-foreground text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<BookOpen className="size-3.5" />
|
|
Note Tags
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveSection("email")}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
|
activeSection === "email"
|
|
? "border-foreground text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<Mail className="size-3.5" />
|
|
Email Labels
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
|
|
{currentGroups.map(group => (
|
|
<TagGroupTable
|
|
key={group.type}
|
|
group={group}
|
|
tags={tags}
|
|
collapsed={collapsedGroups.has(group.type)}
|
|
onToggle={() => toggleGroup(group.type)}
|
|
onAdd={() => addTag(group.type)}
|
|
onUpdate={updateTag}
|
|
onRemove={removeTag}
|
|
getGlobalIndex={getGlobalIndex}
|
|
isEmail={activeSection === "email"}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="pt-3 border-t mt-3 flex items-center justify-between">
|
|
<div>
|
|
{hasChanges && (
|
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
|
{saving ? "Saving..." : "Save"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Code Mode Settings ---
|
|
|
|
function AgentStatusRow({
|
|
name,
|
|
agent,
|
|
signInCommand,
|
|
status,
|
|
onProvisioned,
|
|
}: {
|
|
name: string
|
|
agent: 'claude' | 'codex'
|
|
signInCommand: string
|
|
status: AgentStatus | null
|
|
onProvisioned: () => void
|
|
}) {
|
|
const prov = useProvisioning(agent)
|
|
const provisioning = prov !== undefined && prov.error === undefined
|
|
const error = prov?.error ?? null
|
|
const enable = useCallback(() => startProvisioning(agent, onProvisioned), [agent, onProvisioned])
|
|
|
|
// Treat a just-enabled engine as installed even before the status refresh lands.
|
|
const installed = (status?.installed ?? false) || enabledOptimistic.has(agent)
|
|
const ready = installed && status?.signedIn
|
|
return (
|
|
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
|
{agent === 'claude' ? (
|
|
<AnthropicIcon className="size-5 shrink-0" />
|
|
) : (
|
|
<OpenAIIcon className="size-5 shrink-0" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium">{name}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
|
<span className={cn("inline-flex items-center gap-1", installed ? "text-green-600" : "text-muted-foreground")}>
|
|
{installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
|
{installed ? 'Engine ready' : 'Not enabled'}
|
|
</span>
|
|
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
|
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
|
Signed in
|
|
</span>
|
|
</div>
|
|
{error && <div className="text-xs text-red-600 mt-1 break-words">{error}</div>}
|
|
</div>
|
|
{provisioning ? (
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground shrink-0 tabular-nums">
|
|
<Loader2 className="size-3 animate-spin" />
|
|
{prov?.pct != null ? `${prov.pct}%` : null}
|
|
</span>
|
|
) : ready ? (
|
|
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
|
Ready
|
|
</span>
|
|
) : !installed ? (
|
|
<button
|
|
type="button"
|
|
onClick={enable}
|
|
className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 shrink-0"
|
|
>
|
|
Enable
|
|
</button>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|
const [enabled, setEnabled] = useState(false)
|
|
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
|
const [statusLoading, setStatusLoading] = useState(false)
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
setStatusLoading(true)
|
|
try {
|
|
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
|
setStatus(result)
|
|
} catch {
|
|
setStatus(null)
|
|
} finally {
|
|
setStatusLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
let cancelled = false
|
|
async function load() {
|
|
setLoading(true)
|
|
try {
|
|
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
|
if (!cancelled) {
|
|
setEnabled(result.enabled)
|
|
setApprovalPolicy(result.approvalPolicy ?? 'ask')
|
|
}
|
|
} catch {
|
|
if (!cancelled) setEnabled(false)
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
}
|
|
load()
|
|
loadStatus()
|
|
return () => { cancelled = true }
|
|
}, [dialogOpen, loadStatus])
|
|
|
|
const handleToggle = useCallback(async (next: boolean) => {
|
|
setSaving(true)
|
|
setEnabled(next)
|
|
try {
|
|
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
|
|
window.dispatchEvent(new Event("code-mode-config-changed"))
|
|
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
|
} catch {
|
|
setEnabled(!next)
|
|
toast.error("Failed to update code mode")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [approvalPolicy])
|
|
|
|
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
|
|
const prev = approvalPolicy
|
|
setSaving(true)
|
|
setApprovalPolicy(next)
|
|
try {
|
|
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
|
|
window.dispatchEvent(new Event("code-mode-config-changed"))
|
|
} catch {
|
|
setApprovalPolicy(prev)
|
|
toast.error("Failed to update approval policy")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [enabled, approvalPolicy])
|
|
|
|
const anyReady = status?.claude.installed && status?.claude.signedIn
|
|
|| status?.codex.installed && status?.codex.signedIn
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
<Loader2 className="size-4 animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
|
<p>
|
|
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
|
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
|
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
|
|
and streams its work — tool calls, file diffs, and approvals — back into chat.
|
|
</p>
|
|
<p>
|
|
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
|
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
|
</p>
|
|
<p>
|
|
For each agent you want to use, you must have it{' '}
|
|
<strong className="text-foreground">installed and logged in</strong> on this machine: click{' '}
|
|
<strong className="text-foreground">Enable</strong> below to download its engine, and sign in by
|
|
running <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">claude login</code>{' '}
|
|
or <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">codex login</code>{' '}
|
|
in your terminal. Code mode uses that saved login.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
|
|
<button
|
|
onClick={() => { void loadStatus() }}
|
|
disabled={statusLoading}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
|
Re-check
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<AgentStatusRow
|
|
name="Claude Code"
|
|
agent="claude"
|
|
signInCommand="claude login"
|
|
status={status?.claude ?? null}
|
|
onProvisioned={loadStatus}
|
|
/>
|
|
<AgentStatusRow
|
|
name="Codex"
|
|
agent="codex"
|
|
signInCommand="codex login"
|
|
status={status?.codex ?? null}
|
|
onProvisioned={loadStatus}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium">Enable code mode</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={handleToggle}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
{enabled && (
|
|
<div className="rounded-md border px-3 py-3 space-y-2">
|
|
<div className="text-sm font-medium">Approvals</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
How the coding agent checks in before changing files or running commands. You always see
|
|
everything it does in the timeline — this only controls the prompts.
|
|
</div>
|
|
<Select
|
|
value={approvalPolicy}
|
|
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
|
|
disabled={saving}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ask">Ask every time</SelectItem>
|
|
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
|
|
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="text-xs text-muted-foreground">
|
|
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
|
|
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
|
|
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{enabled && status && !anyReady && (
|
|
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
|
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
|
<div className="text-amber-900 dark:text-amber-200">
|
|
Neither Claude Code nor Codex is ready. Click Enable above to download an engine, sign in with a
|
|
subscription account, then click Re-check.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Notification Settings ---
|
|
|
|
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" | "meeting_detection" | "meeting_notes_ready"
|
|
|
|
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
|
|
{
|
|
key: "chat_completion",
|
|
label: "Chat responses",
|
|
description: "When an agent finishes responding while the app is in the background.",
|
|
},
|
|
{
|
|
key: "new_email",
|
|
label: "New email",
|
|
description: "When a new email arrives during sync while the app is in the background.",
|
|
},
|
|
{
|
|
key: "agent_permission",
|
|
label: "Permission requests",
|
|
description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.",
|
|
},
|
|
{
|
|
key: "background_task",
|
|
label: "Background agents",
|
|
description: "When a background agent you've set up has something to surface. Click to open it on the background tasks page.",
|
|
},
|
|
{
|
|
key: "meeting_detection",
|
|
label: "Meeting detection",
|
|
description: "A popup offering to take notes when Rowboat notices you're in a call or meeting. Nothing records until you accept.",
|
|
},
|
|
{
|
|
key: "meeting_notes_ready",
|
|
label: "Meeting notes ready",
|
|
description: "When your meeting notes finish generating after a call. Click to open the note. Only shown while the app is in the background.",
|
|
},
|
|
]
|
|
|
|
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|
const [categories, setCategories] = useState<Record<NotificationCategoryKey, boolean> | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!dialogOpen) return
|
|
let cancelled = false
|
|
async function load() {
|
|
try {
|
|
const result = await window.ipc.invoke("notifications:getSettings", null)
|
|
if (!cancelled) setCategories(result.categories)
|
|
} catch {
|
|
if (!cancelled) toast.error("Failed to load notification settings")
|
|
}
|
|
}
|
|
load()
|
|
return () => { cancelled = true }
|
|
}, [dialogOpen])
|
|
|
|
const handleToggle = useCallback(async (key: NotificationCategoryKey, next: boolean) => {
|
|
// Optimistic update with rollback on failure.
|
|
const previous = categories
|
|
if (!previous) return
|
|
const updated = { ...previous, [key]: next }
|
|
setCategories(updated)
|
|
setSaving(true)
|
|
try {
|
|
await window.ipc.invoke("notifications:setSettings", { categories: updated })
|
|
} catch {
|
|
setCategories(previous)
|
|
toast.error("Failed to update notification settings")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [categories])
|
|
|
|
if (!categories) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
<Loader2 className="size-4 animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="text-sm text-muted-foreground leading-relaxed">
|
|
Choose which desktop notifications Rowboat sends you. Ambient notifications are only shown
|
|
when the app is in the background.
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{NOTIFICATION_CATEGORIES.map((cat) => (
|
|
<div key={cat.key} className="rounded-md border px-3 py-3 flex items-start gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium">{cat.label}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">{cat.description}</div>
|
|
</div>
|
|
<Switch
|
|
checked={categories[cat.key]}
|
|
onCheckedChange={(next) => handleToggle(cat.key, next)}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Main Settings Dialog ---
|
|
|
|
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
|
const [internalOpen, setInternalOpen] = useState(false)
|
|
const open = controlledOpen ?? internalOpen
|
|
const setOpen = useCallback((next: boolean) => {
|
|
if (onOpenChange) onOpenChange(next)
|
|
else setInternalOpen(next)
|
|
}, [onOpenChange])
|
|
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
|
|
const [content, setContent] = useState("")
|
|
const [originalContent, setOriginalContent] = useState("")
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [rowboatConnected, setRowboatConnected] = useState(false)
|
|
|
|
// Reset to the requested default tab each time the dialog is opened
|
|
useEffect(() => {
|
|
if (open) setActiveTab(defaultTab)
|
|
}, [open, defaultTab])
|
|
|
|
// 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)
|
|
}).catch(() => {
|
|
setRowboatConnected(false)
|
|
})
|
|
}, [open])
|
|
|
|
// Hybrid mode: the Models tab is shown in both modes — signed-in users can
|
|
// pick gateway models AND bring their own providers/models alongside.
|
|
const visibleTabs = tabs
|
|
|
|
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
|
|
const isJsonTab = activeTab === "mcp" || activeTab === "security"
|
|
|
|
const formatJson = (jsonString: string): string => {
|
|
try {
|
|
return JSON.stringify(JSON.parse(jsonString), null, 2)
|
|
} catch {
|
|
return jsonString
|
|
}
|
|
}
|
|
|
|
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
|
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
|
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
|
if (!tabConfig.path) return
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const result = await window.ipc.invoke("workspace:readFile", {
|
|
path: tabConfig.path,
|
|
})
|
|
const formattedContent = formatJson(result.data)
|
|
setContent(formattedContent)
|
|
setOriginalContent(formattedContent)
|
|
} catch {
|
|
setError(`Failed to load ${tabConfig.label} config`)
|
|
setContent("")
|
|
setOriginalContent("")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const saveConfig = async () => {
|
|
if (!isJsonTab || !activeTabConfig.path) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
JSON.parse(content)
|
|
await window.ipc.invoke("workspace:writeFile", {
|
|
path: activeTabConfig.path,
|
|
data: content,
|
|
})
|
|
setOriginalContent(content)
|
|
} catch (err) {
|
|
if (err instanceof SyntaxError) {
|
|
setError("Invalid JSON syntax")
|
|
} else {
|
|
setError(`Failed to save ${activeTabConfig.label} config`)
|
|
}
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleFormat = () => {
|
|
setContent(formatJson(content))
|
|
}
|
|
|
|
const hasChanges = content !== originalContent
|
|
|
|
useEffect(() => {
|
|
if (open && isJsonTab) {
|
|
loadConfig(activeTab)
|
|
}
|
|
}, [open, activeTab, isJsonTab, loadConfig])
|
|
|
|
const handleTabChange = (tab: ConfigTab) => {
|
|
if (isJsonTab && hasChanges) {
|
|
if (!confirm("You have unsaved changes. Discard them?")) {
|
|
return
|
|
}
|
|
}
|
|
setActiveTab(tab)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
|
|
<DialogContent
|
|
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
|
>
|
|
<div className="flex h-full overflow-hidden">
|
|
{/* Sidebar */}
|
|
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
|
|
<div className="px-2 pt-3.5 pb-3 mb-2">
|
|
<h2 className="font-semibold text-base tracking-tight">Settings</h2>
|
|
</div>
|
|
<nav className="flex flex-col">
|
|
{NAV_SECTIONS.map((section) => {
|
|
const sectionTabs = visibleTabs.filter((tab) => section.ids.includes(tab.id))
|
|
if (sectionTabs.length === 0) return null
|
|
return (
|
|
<div key={section.label ?? "main"} className="flex flex-col gap-0.5">
|
|
{section.label ? (
|
|
<div className="px-2 pb-1 pt-4 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
{section.label}
|
|
</div>
|
|
) : null}
|
|
{sectionTabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => handleTabChange(tab.id)}
|
|
className={cn(
|
|
"flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors text-left",
|
|
activeTab === tab.id
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
|
)}
|
|
>
|
|
<tab.icon className="size-4" />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
|
{/* Header */}
|
|
<div className="px-6 pb-4 pt-5">
|
|
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{activeTab === "models" && rowboatConnected
|
|
? "Select your default models"
|
|
: activeTabConfig.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className={cn("flex-1 px-6 pb-5 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
|
{activeTab === "account" ? (
|
|
<AccountSettings dialogOpen={open} />
|
|
) : activeTab === "connections" ? (
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-semibold">Primary accounts</h4>
|
|
<ConnectedAccountsSettings dialogOpen={open} />
|
|
</div>
|
|
<Separator />
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-semibold">Library</h4>
|
|
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
|
</div>
|
|
</div>
|
|
) : activeTab === "mobile" ? (
|
|
<MobileChannelsSettings dialogOpen={open} />
|
|
) : activeTab === "models" ? (
|
|
rowboatConnected
|
|
? (
|
|
<div className="space-y-8">
|
|
<RowboatModelSettings dialogOpen={open} />
|
|
<Separator />
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-semibold">Your own providers</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
|
|
</p>
|
|
<ModelSettings dialogOpen={open} rowboatConnected />
|
|
</div>
|
|
</div>
|
|
)
|
|
: <ModelSettings dialogOpen={open} />
|
|
) : activeTab === "note-tagging" ? (
|
|
<NoteTaggingSettings dialogOpen={open} />
|
|
) : activeTab === "appearance" ? (
|
|
<AppearanceSettings />
|
|
) : activeTab === "notifications" ? (
|
|
<NotificationSettings dialogOpen={open} />
|
|
) : activeTab === "help" ? (
|
|
<HelpSettings />
|
|
) : activeTab === "code-mode" ? (
|
|
<CodeModeSettings dialogOpen={open} />
|
|
) : loading ? (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
Loading...
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
className="w-full h-full resize-none bg-muted/50 rounded-md p-3 font-mono text-sm border-0 focus:outline-none focus:ring-1 focus:ring-ring"
|
|
spellCheck={false}
|
|
placeholder="Loading configuration..."
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer - only show for JSON config tabs */}
|
|
{isJsonTab && (
|
|
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{error && (
|
|
<span className="text-xs text-destructive">{error}</span>
|
|
)}
|
|
{hasChanges && !error && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Unsaved changes
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleFormat}
|
|
disabled={loading || saving}
|
|
>
|
|
Format
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={saveConfig}
|
|
disabled={loading || saving || !hasChanges}
|
|
>
|
|
{saving ? "Saving..." : "Save"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|