Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/core/chorus/api/ProviderVisibilityAPI.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { db } from "../DB";
import { ProviderVisibility, ProviderName } from "../Models";
import { ProviderVisibility, ProviderName, getProviderName } from "../Models";
import { useApiKeys } from "./AppMetadataAPI";
import { useModelConfigs } from "./ModelsAPI";
import { hasApiKey } from "@core/utilities/ProxyUtils";

const providerVisibilityKeys = {
all: () => ["providerVisibility"] as const,
list: () => [...providerVisibilityKeys.all(), "list"] as const,
};

const API_KEY_REQUIRED_HIDDEN_PROVIDERS = new Set<ProviderName>([
"openrouter",
"google",
"openai",
"anthropic",
]);

type ProviderVisibilityDBRow = {
provider_name: string;
model_id: string;
Expand Down Expand Up @@ -113,7 +123,29 @@ export function useSetAllProviderModelsVisible() {
*/
export function useProviderVisibilityMap(): Map<string, boolean> | undefined {
const { data } = useProviderVisibleModels();
if (!data) return undefined;
const { data: apiKeys } = useApiKeys();
const { data: allModels } = useModelConfigs();

if (!data && !allModels) return undefined;

const visibilityMap = new Map(
(data ?? []).map((v) => [v.modelId, v.isVisible]),
);

if (!allModels || apiKeys === undefined) {
return visibilityMap;
}

for (const model of allModels) {
const provider = getProviderName(model.modelId);
if (!API_KEY_REQUIRED_HIDDEN_PROVIDERS.has(provider)) {
continue;
}

if (!hasApiKey(provider as keyof typeof apiKeys, apiKeys)) {
visibilityMap.set(model.modelId, false);
}
}

return new Map(data.map((v) => [v.modelId, v.isVisible]));
return visibilityMap;
}
3 changes: 2 additions & 1 deletion src/core/utilities/ProxyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function hasApiKey(
providerKey: keyof ApiKeys,
apiKeys: ApiKeys,
): boolean {
return Boolean(apiKeys[providerKey]);
const key = apiKeys[providerKey];
return typeof key === "string" && key.trim().length > 0;
}

/**
Expand Down
40 changes: 35 additions & 5 deletions src/ui/components/ManageModelsBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,46 @@ import {
} from "./ui/select";

// Helper function to filter models by search terms
const normalizeSearchValue = (value: string): string =>
value.toLowerCase().replace(/[^a-z0-9]/g, "");

const isSubsequenceMatch = (needle: string, haystack: string): boolean => {
if (!needle) return true;
let index = 0;
for (const char of haystack) {
if (char === needle[index]) {
index += 1;
if (index === needle.length) {
return true;
}
}
}
return false;
};

const filterBySearch = (models: ModelConfig[], searchTerms: string[]) => {
if (searchTerms.length === 0) return models;
return models.filter((m) => {
const providerLabel = getProviderLabel(m.modelId);

return searchTerms.every(
(term) =>
m.displayName.toLowerCase().includes(term) ||
providerLabel.toLowerCase().includes(term),
const displayName = m.displayName.toLowerCase();
const providerLabelLower = providerLabel.toLowerCase();
const modelIdLower = m.modelId.toLowerCase();
const normalizedHaystack = normalizeSearchValue(
`${m.displayName} ${providerLabel} ${m.modelId}`,
);

return searchTerms.every((term) => {
const normalizedTerm = normalizeSearchValue(term);

return (
displayName.includes(term) ||
providerLabelLower.includes(term) ||
modelIdLower.includes(term) ||
(normalizedTerm.length > 0 &&
(normalizedHaystack.includes(normalizedTerm) ||
isSubsequenceMatch(normalizedTerm, normalizedHaystack)))
);
});
});
};

Expand Down
172 changes: 114 additions & 58 deletions src/ui/components/Onboarding.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,128 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { SettingsManager } from "@core/utilities/Settings";
import * as AppMetadataAPI from "@core/chorus/api/AppMetadataAPI";
import { useQueryClient } from "@tanstack/react-query";

type OnboardingProvider = "openrouter" | "google" | "openai" | "anthropic";

const ONBOARDING_API_KEY_FIELDS: Array<{
provider: OnboardingProvider;
label: string;
placeholder: string;
}> = [
{
provider: "openrouter",
label: "OpenRouter API key",
placeholder: "sk-or-v1-...",
},
{
provider: "google",
label: "Google AI API key",
placeholder: "AIza...",
},
{
provider: "openai",
label: "OpenAI API key",
placeholder: "sk-...",
},
{
provider: "anthropic",
label: "Anthropic API key",
placeholder: "sk-ant-...",
},
];

const EMPTY_API_KEY_INPUTS: Record<OnboardingProvider, string> = {
openrouter: "",
google: "",
openai: "",
anthropic: "",
};

export default function Onboarding({ onComplete }: { onComplete: () => void }) {
const onboardingStep = AppMetadataAPI.useOnboardingStep();
const setOnboardingStep = AppMetadataAPI.useSetOnboardingStep();
const [openRouterKey, setOpenRouterKey] = useState("");
const [apiKeyInputs, setApiKeyInputs] = useState(EMPTY_API_KEY_INPUTS);
const [isSaving, setIsSaving] = useState(false);
const queryClient = useQueryClient();

const hasAnyApiKey = useMemo(
() =>
Object.values(apiKeyInputs).some(
(apiKey) => apiKey.trim().length > 0,
),
[apiKeyInputs],
);

const handleNextStep = useCallback(() => {
setOnboardingStep.mutate({ step: 1 });
}, [setOnboardingStep]);

const handleSaveAndComplete = useCallback(async () => {
if (openRouterKey.trim()) {
setIsSaving(true);
setIsSaving(true);
try {
const settingsManager = SettingsManager.getInstance();
const currentSettings = await settingsManager.get();
const newApiKeys = {
...currentSettings.apiKeys,
openrouter: openRouterKey.trim(),
};
await settingsManager.set({

const trimmedApiKeys: Partial<Record<OnboardingProvider, string>> =
{};
for (const field of ONBOARDING_API_KEY_FIELDS) {
const value = apiKeyInputs[field.provider].trim();
if (value.length > 0) {
trimmedApiKeys[field.provider] = value;
}
}

const updatedSettings = {
...currentSettings,
apiKeys: newApiKeys,
});
apiKeys: {
...currentSettings.apiKeys,
...trimmedApiKeys,
},
};

await settingsManager.set(updatedSettings);
await queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
} finally {
setIsSaving(false);
}

onComplete();
}, [openRouterKey, queryClient, onComplete]);
}, [apiKeyInputs, queryClient, onComplete]);

const handleApiKeyChange = useCallback(
(provider: OnboardingProvider, value: string) => {
setApiKeyInputs((previous) => ({
...previous,
[provider]: value,
}));
},
[],
);

// Allow pressing Enter to continue quickly
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (onboardingStep === 0) {
handleNextStep();
} else {
} else if (onboardingStep === 1 && !isSaving) {
void handleSaveAndComplete();
}
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onboardingStep, handleNextStep, handleSaveAndComplete]);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [onboardingStep, handleNextStep, handleSaveAndComplete, isSaving]);

if (onboardingStep === 0) {
return (
<div
data-tauri-drag-region
className="fixed inset-0 z-50 flex flex-col items-center justify-center min-h-screen bg-background/95 backdrop-blur-sm px-4"
>
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center space-y-6 max-w-3xl w-full">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Expand All @@ -82,44 +146,38 @@ export default function Onboarding({ onComplete }: { onComplete: () => void }) {
);
}

// Step 2: OpenRouter API key
return (
<div
data-tauri-drag-region
className="fixed inset-0 z-50 flex flex-col items-center justify-center min-h-screen bg-background/95 backdrop-blur-sm px-4"
>
<div className="text-center space-y-6 max-w-md w-full">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Add an API Key
</h1>
<p className="text-muted-foreground">
Chorus runs on API keys. We recommend{" "}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-4"
>
OpenRouter
</a>{" "}
to get started.
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-xl font-semibold tracking-tight">
Optional API keys
</h2>
<p className="text-sm text-muted-foreground">
Add keys now or skip and configure them later in
Settings.
</p>
</div>

<div className="space-y-2 text-left">
<Label htmlFor="openrouter-key">OpenRouter API Key</Label>
<Input
id="openrouter-key"
type="password"
placeholder="sk-or-..."
value={openRouterKey}
onChange={(e) => setOpenRouterKey(e.target.value)}
autoFocus
/>
<p className="text-xs text-muted-foreground">
You can add more API keys later in Settings.
</p>
<div className="space-y-4">
{ONBOARDING_API_KEY_FIELDS.map((field) => (
<div key={field.provider} className="space-y-2">
<Label htmlFor={`${field.provider}-api-key`}>
{field.label}
</Label>
<Input
id={`${field.provider}-api-key`}
placeholder={field.placeholder}
value={apiKeyInputs[field.provider]}
onChange={(event) =>
handleApiKeyChange(
field.provider,
event.target.value,
)
}
/>
</div>
))}
</div>

<div className="flex flex-col gap-2">
Expand All @@ -128,9 +186,7 @@ export default function Onboarding({ onComplete }: { onComplete: () => void }) {
onClick={() => void handleSaveAndComplete()}
disabled={isSaving}
>
{openRouterKey.trim()
? "Save and continue"
: "Skip for now"}{" "}
{hasAnyApiKey ? "Save and continue" : "Skip for now"}{" "}
<span className="text-sm">↵</span>
</Button>
</div>
Expand Down
Loading
Loading