mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
Merge pull request #629 from prakhar1605/feat/byok-onboarding-simplify
Simplify BYOK onboarding to provider + key (tasks 4 & 6)
This commit is contained in:
commit
51ca8778b5
5 changed files with 136 additions and 166 deletions
|
|
@ -26,7 +26,7 @@ import { RunEvent } from '@x/shared/dist/runs.js';
|
|||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
|
|
@ -853,6 +853,15 @@ export function setupIpcHandlers() {
|
|||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
},
|
||||
'models:listForProvider': async (_event, args) => {
|
||||
try {
|
||||
const models = await listModelsForProvider(args.provider);
|
||||
return { success: true, models };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list models';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'llm:getDefaultModel': async () => {
|
||||
return await getDefaultModelAndProvider();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,13 +2,6 @@ import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
OpenAIIcon,
|
||||
|
|
@ -40,16 +33,22 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s
|
|||
|
||||
export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||
const {
|
||||
llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError,
|
||||
llmProvider, setLlmProvider, modelsLoading, modelsError,
|
||||
activeConfig, testState, setTestState, showApiKey,
|
||||
showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders,
|
||||
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleBack,
|
||||
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
|
||||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
const modelsForProvider = modelsCatalog[llmProvider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
// Hosted providers (openai/anthropic/google) get a default model, so we only
|
||||
// ask for a model on providers that truly need one (local/custom/gateway),
|
||||
// or as a fallback if no model is set yet.
|
||||
// Hosted providers (openai/anthropic/google) fetch their models from the API
|
||||
// key on test, so they never need a manual model field. Only local/custom/
|
||||
// gateway providers, where the user must specify a model, show the input.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const showModelInput = !hostedProviders.includes(llmProvider)
|
||||
|
||||
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
|
||||
const isSelected = llmProvider === provider.id
|
||||
|
|
@ -87,7 +86,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Choose your model
|
||||
Choose your provider
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center mb-6">
|
||||
Select a provider and configure your API key
|
||||
|
|
@ -145,153 +144,33 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
{/* Separator */}
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
{/* Model configuration */}
|
||||
{/* Provider configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Model Configuration</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 min-w-0">
|
||||
{/* Cloud providers get a default model auto-selected; only local/custom
|
||||
providers (no catalog) need a model here. Users can pick any of the
|
||||
provider's models later in the chat view. */}
|
||||
{showModelInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Assistant Model
|
||||
Model
|
||||
</label>
|
||||
{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.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Knowledge Graph Model
|
||||
</label>
|
||||
{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) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Meeting Notes Model
|
||||
</label>
|
||||
{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) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Track Block Model
|
||||
</label>
|
||||
{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) => updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
|
||||
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const canTest =
|
||||
activeConfig.model.trim().length > 0 &&
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
|
|
@ -416,37 +415,45 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
try {
|
||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
apiKey,
|
||||
baseURL,
|
||||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
liveNoteAgentModel,
|
||||
const provider = {
|
||||
flavor: llmProvider,
|
||||
apiKey,
|
||||
baseURL,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
setTestState({ status: "success" })
|
||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
handleNext()
|
||||
} else {
|
||||
|
||||
// Fetch the provider's models from the key — this both validates the
|
||||
// credentials and gives us the list to populate the chat picker.
|
||||
const result = await window.ipc.invoke("models:listForProvider", { provider })
|
||||
if (!result.success) {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
return
|
||||
}
|
||||
|
||||
const models: string[] = result.models ?? []
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
const model =
|
||||
(preferred && models.includes(preferred) && preferred) ||
|
||||
models[0] ||
|
||||
activeConfig.model.trim() ||
|
||||
""
|
||||
|
||||
const providerConfig = {
|
||||
provider,
|
||||
model,
|
||||
models,
|
||||
}
|
||||
|
||||
setTestState({ status: "success" })
|
||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
handleNext()
|
||||
} catch (error) {
|
||||
console.error("Connection test failed:", error)
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext])
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider, handleNext])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,71 @@ export async function testModelConnection(
|
|||
}
|
||||
}
|
||||
|
||||
export async function listModelsForProvider(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
timeoutMs = 8000,
|
||||
): Promise<string[]> {
|
||||
const { flavor, apiKey, baseURL } = providerConfig;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
let url = "";
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
switch (flavor) {
|
||||
case "openai":
|
||||
url = "https://api.openai.com/v1/models";
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
break;
|
||||
case "anthropic":
|
||||
url = "https://api.anthropic.com/v1/models";
|
||||
headers["x-api-key"] = apiKey ?? "";
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
break;
|
||||
case "google":
|
||||
url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`;
|
||||
break;
|
||||
case "openrouter":
|
||||
url = "https://openrouter.ai/api/v1/models";
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
break;
|
||||
case "ollama":
|
||||
url = `${(baseURL ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`;
|
||||
break;
|
||||
case "openai-compatible":
|
||||
case "aigateway":
|
||||
url = `${(baseURL ?? "").replace(/\/$/, "")}/models`;
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported provider flavor: ${flavor}`);
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers, signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to list models (${res.status}): ${body.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Normalize each provider's response shape into a flat list of model id strings.
|
||||
let ids: string[] = [];
|
||||
if (flavor === "google") {
|
||||
// { models: [{ name: "models/gemini-..." }] }
|
||||
ids = (data.models ?? []).map((m: { name: string }) => m.name.replace(/^models\//, ""));
|
||||
} else if (flavor === "ollama") {
|
||||
// { models: [{ name: "llama3:latest" }] }
|
||||
ids = (data.models ?? []).map((m: { name: string }) => m.name);
|
||||
} else {
|
||||
// OpenAI-shaped: { data: [{ id: "..." }] }
|
||||
ids = (data.data ?? []).map((m: { id: string }) => m.id);
|
||||
}
|
||||
return ids.filter((id: string) => typeof id === "string" && id.length > 0);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateTextOptions {
|
||||
prompt: string;
|
||||
system?: string;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||||
import { ListToolsResponse } from './mcp.js';
|
||||
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
|
||||
import { LlmModelConfig } from './models.js';
|
||||
import { LlmModelConfig, LlmProvider } from './models.js';
|
||||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
|
|
@ -403,6 +403,16 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'models:listForProvider': {
|
||||
req: z.object({
|
||||
provider: LlmProvider,
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
models: z.array(z.string()).optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'llm:getDefaultModel': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue