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
13 changes: 13 additions & 0 deletions .changeset/multi-provider-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"think-app": patch
"think-backend": patch
---

feat: add multi-provider support for OpenRouter and Venice

- Replace generic "Cloud API" with specific provider selection (Ollama, OpenRouter, Venice)
- Add per-provider model selection with searchable combobox for cloud providers
- Store separate API keys and model preferences per provider
- Add migration to convert legacy openai settings to new provider-specific settings
- Support provider-specific headers and configurations
- Fix model switching for Venice and OpenRouter providers
8 changes: 7 additions & 1 deletion app/electron/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, Menu } = require('electron');
const { app, BrowserWindow, ipcMain, Menu, shell } = require('electron');
const { autoUpdater } = require('electron-updater');
const { spawn, execSync } = require('child_process');
const crypto = require('crypto');
Expand Down Expand Up @@ -299,6 +299,12 @@ function createWindow() {
}
});

// Open external links in system browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});

// Re-send backend-ready on page reload if backend is already running
mainWindow.webContents.on('did-finish-load', () => {
if (backendReady) {
Expand Down
3 changes: 3 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,15 @@
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/pm": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"electron-updater": "^6.6.2",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
Expand Down
70 changes: 66 additions & 4 deletions app/src/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { ChevronDown, Check, Loader2, Download } from "lucide-react";
import { ChevronDown, Check, Loader2, Download, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { apiFetch } from "@/lib/api";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -156,6 +165,61 @@ export function ModelSelector({ type = "chat", provider, selectedModel, onModelC
setPullingModel(null);
};

// Use combobox for cloud providers (non-OLLAMA)
const useCombobox = fetchedProvider !== "ollama" && fetchedProvider !== "";

// Combobox for cloud providers
if (useCombobox) {
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className="w-full justify-between"
disabled={isLoading}
>
<span className="truncate">
{isLoading ? "Loading..." : displayModel || "Select model"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup>
{models.map((model) => (
<CommandItem
key={model.name}
value={model.name}
onSelect={() => selectModel(model.name)}
className="flex flex-col items-start gap-1"
>
<div className="flex items-center gap-2 w-full">
<span className="font-medium truncate flex-1">{model.name}</span>
{model.name === displayModel && (
<Check className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
</div>
<div className="text-xs text-muted-foreground flex gap-2">
{model.size && <span>{model.size}</span>}
<span>{model.context_window.toLocaleString()} tokens</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

// Original dropdown for OLLAMA (with download buttons)
return (
<div className="relative" ref={dropdownRef}>
<Button
Expand All @@ -179,9 +243,7 @@ export function ModelSelector({ type = "chat", provider, selectedModel, onModelC
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-lg shadow-lg z-50 max-h-[300px] overflow-y-auto">
{models.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
{fetchedProvider === "ollama"
? "No models found. Pull a model to get started."
: "No models available."}
No models found. Pull a model to get started.
</div>
) : (
models.map((model) => (
Expand Down
7 changes: 6 additions & 1 deletion app/src/components/ProviderStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ function getStatusColor(status: ProviderStatus["status"]): string {
}

function getProviderLabel(provider: ProviderStatus["provider"]): string {
return provider === "ollama" ? "Ollama" : "Cloud API";
const labels: Record<string, string> = {
ollama: "Ollama",
openrouter: "OpenRouter",
venice: "Venice",
};
return labels[provider] || provider;
}

export default function ProviderStatusIndicator() {
Expand Down
151 changes: 151 additions & 0 deletions app/src/components/ui/command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"

import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"

const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName

const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}

const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))

CommandInput.displayName = CommandPrimitive.Input.displayName

const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))

CommandList.displayName = CommandPrimitive.List.displayName

const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))

CommandEmpty.displayName = CommandPrimitive.Empty.displayName

const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))

CommandGroup.displayName = CommandPrimitive.Group.displayName

const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName

const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))

CommandItem.displayName = CommandPrimitive.Item.displayName

const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"

export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
Loading