feat: Cloud Models tab, prevent duplicate empty chats, rename to Installed Models

1. Cloud Models tab: third tab in Cmd+K palette with API key management
   for OpenAI, Anthropic, Google, and OpenRouter. Keys stored in
   localStorage (never sent externally). Top-3 models per provider
   shown when key is configured. Connected status indicator.

2. New chat button: skip creating a new chat if the current one is
   empty (no messages). Prevents stacking empty conversations.

3. Tab label: "Installed" renamed to "Installed Models (N)" for clarity.
This commit is contained in:
Jon Saad-Falcon
2026-03-14 14:18:56 -07:00
parent 7b0fcfa607
commit 7a1584aac3
2 changed files with 242 additions and 107 deletions
+236 -107
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Search, Cpu, X, Download, Loader2, Trash2, Check } from 'lucide-react';
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
import { useAppStore } from '../lib/store';
import { pullModel, deleteModel, fetchModels, preloadModel } from '../lib/api';
@@ -19,7 +19,68 @@ const CATALOGUE_MODELS = [
{ id: 'phi4:latest', size: '~9.1 GB', desc: 'Phi-4 14B' },
];
type Tab = 'installed' | 'catalogue';
/** Cloud provider definitions */
interface CloudProvider {
name: string;
envKey: string;
storageKey: string;
models: Array<{ id: string; desc: string }>;
}
const CLOUD_PROVIDERS: CloudProvider[] = [
{
name: 'OpenAI',
envKey: 'OPENAI_API_KEY',
storageKey: 'openjarvis-openai-key',
models: [
{ id: 'gpt-4o', desc: 'GPT-4o — fast, multimodal' },
{ id: 'gpt-4o-mini', desc: 'GPT-4o Mini — cheap, fast' },
{ id: 'o3-mini', desc: 'o3-mini — reasoning' },
],
},
{
name: 'Anthropic',
envKey: 'ANTHROPIC_API_KEY',
storageKey: 'openjarvis-anthropic-key',
models: [
{ id: 'claude-sonnet-4-6', desc: 'Claude Sonnet 4.6 — balanced' },
{ id: 'claude-opus-4-6', desc: 'Claude Opus 4.6 — most capable' },
{ id: 'claude-haiku-4-5', desc: 'Claude Haiku 4.5 — fastest' },
],
},
{
name: 'Google',
envKey: 'GEMINI_API_KEY',
storageKey: 'openjarvis-gemini-key',
models: [
{ id: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro — flagship' },
{ id: 'gemini-2.5-flash', desc: 'Gemini 2.5 Flash — fast' },
{ id: 'gemini-3-pro', desc: 'Gemini 3 Pro — latest' },
],
},
{
name: 'OpenRouter',
envKey: 'OPENROUTER_API_KEY',
storageKey: 'openjarvis-openrouter-key',
models: [
{ id: 'openrouter/auto', desc: 'Auto — best model for the task' },
{ id: 'openrouter/anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 via OpenRouter' },
{ id: 'openrouter/deepseek/deepseek-r1', desc: 'DeepSeek R1 via OpenRouter' },
],
},
];
function getStoredKey(storageKey: string): string {
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
}
function setStoredKey(storageKey: string, value: string): void {
try {
if (value) localStorage.setItem(storageKey, value);
else localStorage.removeItem(storageKey);
} catch {}
}
type Tab = 'installed' | 'catalogue' | 'cloud';
export function CommandPalette() {
const [query, setQuery] = useState('');
@@ -30,6 +91,12 @@ export function CommandPalette() {
const [pullSuccess, setPullSuccess] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [customModel, setCustomModel] = useState('');
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
const keys: Record<string, string> = {};
for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey);
return keys;
});
const inputRef = useRef<HTMLInputElement>(null);
const models = useAppStore((s) => s.models);
@@ -44,10 +111,12 @@ export function CommandPalette() {
? (query
? models.filter((m) => m.id.toLowerCase().includes(query.toLowerCase()))
: models)
: CATALOGUE_MODELS.filter((m) =>
: tab === 'catalogue'
? CATALOGUE_MODELS.filter((m) =>
!installedIds.has(m.id) &&
(!query || m.id.toLowerCase().includes(query.toLowerCase()) || m.desc.toLowerCase().includes(query.toLowerCase()))
);
)
: []; // cloud tab doesn't use filtered
useEffect(() => {
inputRef.current?.focus();
@@ -57,7 +126,6 @@ export function CommandPalette() {
setSelectedIdx(0);
}, [query, tab]);
// Clear success message after a delay
useEffect(() => {
if (pullSuccess) {
const t = setTimeout(() => setPullSuccess(null), 3000);
@@ -70,7 +138,6 @@ export function CommandPalette() {
setSelectedModel(modelId);
setCommandPaletteOpen(false);
// Switching models creates a new chat session for a clean slate
if (modelId !== previousModel) {
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
createConversation(modelId);
@@ -105,7 +172,6 @@ export function CommandPalette() {
message: `Downloaded ${modelId}`,
});
await refreshModels();
// Auto-select the newly pulled model
setSelectedModel(modelId);
} catch (e: any) {
setPullError(e.message || 'Download failed');
@@ -119,7 +185,6 @@ export function CommandPalette() {
};
const handleDelete = async (modelId: string) => {
if (!confirm(`Delete model ${modelId}? You can re-download it later.`)) return;
setDeleting(modelId);
try {
await deleteModel(modelId);
@@ -144,6 +209,15 @@ export function CommandPalette() {
setCustomModel('');
};
const handleSaveKey = (provider: CloudProvider, value: string) => {
setStoredKey(provider.storageKey, value);
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'model',
message: `${provider.name} API key ${value ? 'saved' : 'removed'}`,
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setCommandPaletteOpen(false);
@@ -159,15 +233,19 @@ export function CommandPalette() {
}
};
const TAB_LABELS: Record<Tab, string> = {
installed: `Installed Models (${models.length})`,
catalogue: 'Download',
cloud: 'Cloud Models',
};
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
onClick={() => setCommandPaletteOpen(false)}
>
{/* Backdrop */}
<div className="fixed inset-0" style={{ background: 'rgba(0,0,0,0.5)' }} />
{/* Palette */}
<div
className="relative w-full max-w-lg rounded-xl overflow-hidden"
style={{
@@ -178,50 +256,49 @@ export function CommandPalette() {
onClick={(e) => e.stopPropagation()}
>
{/* Tabs */}
<div
className="flex"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
{(['installed', 'catalogue'] as Tab[]).map((t) => (
<div className="flex" style={{ borderBottom: '1px solid var(--color-border)' }}>
{(['installed', 'catalogue', 'cloud'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className="flex-1 px-4 py-2.5 text-xs font-medium transition-colors cursor-pointer"
className="flex-1 px-3 py-2.5 text-xs font-medium transition-colors cursor-pointer"
style={{
color: tab === t ? 'var(--color-accent)' : 'var(--color-text-tertiary)',
borderBottom: tab === t ? '2px solid var(--color-accent)' : '2px solid transparent',
background: 'transparent',
}}
>
{t === 'installed' ? `Installed (${models.length})` : 'Download Models'}
{TAB_LABELS[t]}
</button>
))}
</div>
{/* Search input */}
<div
className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<Search size={18} style={{ color: 'var(--color-text-tertiary)' }} />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tab === 'installed' ? 'Search installed models...' : 'Search models to download...'}
className="flex-1 bg-transparent outline-none text-sm"
style={{ color: 'var(--color-text)' }}
/>
<button
onClick={() => setCommandPaletteOpen(false)}
className="p-1 rounded cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
{/* Search (not for cloud tab) */}
{tab !== 'cloud' && (
<div
className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<X size={16} />
</button>
</div>
<Search size={18} style={{ color: 'var(--color-text-tertiary)' }} />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tab === 'installed' ? 'Search installed models...' : 'Search models to download...'}
className="flex-1 bg-transparent outline-none text-sm"
style={{ color: 'var(--color-text)' }}
/>
<button
onClick={() => setCommandPaletteOpen(false)}
className="p-1 rounded cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
>
<X size={16} />
</button>
</div>
)}
{/* Status messages */}
{pullError && (
@@ -236,13 +313,12 @@ export function CommandPalette() {
)}
{/* Results */}
<div className="max-h-[300px] overflow-y-auto py-2">
<div className="max-h-[400px] overflow-y-auto py-2">
{tab === 'installed' ? (
/* ── Installed models tab ── */
filtered.length === 0 ? (
<div className="px-4 py-6 text-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
{models.length === 0
? 'No models available — switch to "Download Models" to get started'
? 'No models available — switch to "Download" to get started'
: 'No matching models'}
</div>
) : (
@@ -254,9 +330,7 @@ export function CommandPalette() {
<div
key={model.id}
className="flex items-center gap-3 w-full px-4 py-2.5 transition-colors"
style={{
background: isSelected ? 'var(--color-bg-secondary)' : 'transparent',
}}
style={{ background: isSelected ? 'var(--color-bg-secondary)' : 'transparent' }}
onMouseEnter={() => setSelectedIdx(idx)}
>
<button
@@ -266,21 +340,12 @@ export function CommandPalette() {
>
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
<div className="flex-1 min-w-0">
<div
className="text-sm truncate"
style={{
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
fontWeight: isActive ? 500 : 400,
}}
>
<div className="text-sm truncate" style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text)', fontWeight: isActive ? 500 : 400 }}>
{model.id}
</div>
</div>
{isActive && (
<span className="text-[10px] px-2 py-0.5 rounded-full" style={{
background: 'var(--color-accent-subtle)',
color: 'var(--color-accent)',
}}>
<span className="text-[10px] px-2 py-0.5 rounded-full" style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}>
Active
</span>
)}
@@ -288,11 +353,11 @@ export function CommandPalette() {
<button
onClick={() => handleDelete(model.id)}
disabled={isDeleting}
className="p-1 rounded transition-colors cursor-pointer opacity-0 group-hover:opacity-100"
style={{ color: 'var(--color-text-tertiary)' }}
className="p-1 rounded transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
title="Delete model"
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1', e.currentTarget.style.color = 'var(--color-error)')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0', e.currentTarget.style.color = 'var(--color-text-tertiary)')}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
>
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
</button>
@@ -300,86 +365,148 @@ export function CommandPalette() {
);
})
)
) : (
/* ── Catalogue tab ── */
) : tab === 'catalogue' ? (
<>
{(filtered as typeof CATALOGUE_MODELS).map((model) => {
const isPulling = pulling === model.id;
const justInstalled = pullSuccess === model.id;
return (
<div
key={model.id}
className="flex items-center gap-3 w-full px-4 py-2.5 transition-colors"
style={{ background: 'transparent' }}
>
<div key={model.id} className="flex items-center gap-3 w-full px-4 py-2.5">
<Download size={16} style={{ color: 'var(--color-text-tertiary)' }} />
<div className="flex-1 min-w-0">
<div className="text-sm truncate" style={{ color: 'var(--color-text)' }}>
{model.id}
</div>
<div className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
{model.desc} &middot; {model.size}
</div>
<div className="text-sm truncate" style={{ color: 'var(--color-text)' }}>{model.id}</div>
<div className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>{model.desc} &middot; {model.size}</div>
</div>
<button
onClick={() => handlePull(model.id)}
disabled={isPulling || !!pulling}
className="flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer"
className="flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-medium cursor-pointer"
style={{
background: justInstalled ? 'var(--color-accent-subtle)' : 'var(--color-accent)',
color: justInstalled ? 'var(--color-accent)' : '#fff',
opacity: (isPulling || (pulling && !isPulling)) ? 0.5 : 1,
}}
>
{isPulling ? (
<><Loader2 size={12} className="animate-spin" /> Downloading...</>
) : justInstalled ? (
<><Check size={12} /> Installed</>
) : (
<><Download size={12} /> Download</>
)}
{isPulling ? <><Loader2 size={12} className="animate-spin" /> Downloading...</> :
justInstalled ? <><Check size={12} /> Installed</> :
<><Download size={12} /> Download</>}
</button>
</div>
);
})}
{/* Custom model input */}
<div
className="px-4 py-3 mt-1"
style={{ borderTop: '1px solid var(--color-border)' }}
>
<div className="text-[11px] mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Or enter any Ollama model name:
</div>
<div className="px-4 py-3 mt-1" style={{ borderTop: '1px solid var(--color-border)' }}>
<div className="text-[11px] mb-2" style={{ color: 'var(--color-text-tertiary)' }}>Or enter any Ollama model name:</div>
<div className="flex gap-2">
<input
type="text"
value={customModel}
type="text" value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleCustomPull(); } }}
placeholder="e.g. codellama:7b"
className="flex-1 text-sm px-3 py-1.5 rounded-lg outline-none"
style={{
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }}
/>
<button
onClick={handleCustomPull}
disabled={!customModel.trim() || !!pulling}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
style={{
background: 'var(--color-accent)',
color: '#fff',
opacity: (!customModel.trim() || pulling) ? 0.5 : 1,
}}
onClick={handleCustomPull} disabled={!customModel.trim() || !!pulling}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
style={{ background: 'var(--color-accent)', color: '#fff', opacity: (!customModel.trim() || pulling) ? 0.5 : 1 }}
>
<Download size={12} /> Pull
</button>
</div>
</div>
</>
) : (
/* ── Cloud Models tab ── */
<div className="px-4 py-2">
<div className="text-[11px] mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
Add your API keys to use cloud models. Keys are stored locally on your device only.
</div>
{CLOUD_PROVIDERS.map((provider) => {
const key = apiKeys[provider.storageKey] || '';
const hasKey = !!key;
const isVisible = showKeys[provider.storageKey];
return (
<div key={provider.name} className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Cloud size={14} style={{ color: hasKey ? 'var(--color-success)' : 'var(--color-text-tertiary)' }} />
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>{provider.name}</span>
{hasKey && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(34,197,94,0.1)', color: 'var(--color-success)' }}>
Connected
</span>
)}
</div>
{/* API key input */}
<div className="flex gap-1.5 mb-2">
<div className="flex-1 flex items-center rounded-lg" style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}>
<Key size={12} className="ml-2.5 shrink-0" style={{ color: 'var(--color-text-tertiary)' }} />
<input
type={isVisible ? 'text' : 'password'}
value={key}
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.storageKey]: e.target.value }))}
onBlur={() => handleSaveKey(provider, apiKeys[provider.storageKey] || '')}
placeholder={`${provider.envKey}`}
className="flex-1 text-xs px-2 py-1.5 bg-transparent outline-none font-mono"
style={{ color: 'var(--color-text)' }}
/>
<button
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.storageKey]: !prev[provider.storageKey] }))}
className="px-2 cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}
>
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</div>
{hasKey && (
<button
onClick={() => handleSaveKey(provider, '')}
className="px-2 py-1 rounded-lg text-[10px] cursor-pointer"
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
>
Remove
</button>
)}
</div>
{/* Models for this provider (only show if key is set) */}
{hasKey && (
<div className="ml-5 flex flex-col gap-1">
{provider.models.map((model) => {
const isActive = model.id === selectedModel;
return (
<button
key={model.id}
onClick={() => handleSelect(model.id)}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-left cursor-pointer transition-colors"
style={{ background: isActive ? 'var(--color-accent-subtle)' : 'transparent' }}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<Cloud size={12} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
<div className="flex-1 min-w-0">
<div className="text-xs truncate" style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text)', fontWeight: isActive ? 500 : 400 }}>
{model.id}
</div>
<div className="text-[10px] truncate" style={{ color: 'var(--color-text-tertiary)' }}>
{model.desc}
</div>
</div>
{isActive && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full shrink-0" style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}>
Active
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
@@ -394,8 +521,10 @@ export function CommandPalette() {
<span><kbd className="font-mono">Enter</kbd> Select</span>
<span><kbd className="font-mono">Esc</kbd> Close</span>
</>
) : (
) : tab === 'catalogue' ? (
<span>Models are downloaded from the Ollama registry</span>
) : (
<span>API keys are stored locally and never sent to OpenJarvis servers</span>
)}
</div>
</div>
@@ -39,7 +39,13 @@ export function Sidebar() {
const ThemeIcon = settings.theme === 'light' ? Sun : settings.theme === 'dark' ? Moon : Monitor;
const nextTheme = settings.theme === 'light' ? 'dark' : settings.theme === 'dark' ? 'system' : 'light';
const messages = useAppStore((s) => s.messages);
const handleNewChat = () => {
// Don't create a new chat if the current one is empty
if (messages.length === 0) {
navigate('/');
return;
}
createConversation(selectedModel);
navigate('/');
};