diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 3afff59a..aa0298a8 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -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(null); const [deleting, setDeleting] = useState(null); const [customModel, setCustomModel] = useState(''); + const [showKeys, setShowKeys] = useState>({}); + const [apiKeys, setApiKeys] = useState>(() => { + const keys: Record = {}; + for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey); + return keys; + }); const inputRef = useRef(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 = { + installed: `Installed Models (${models.length})`, + catalogue: 'Download', + cloud: 'Cloud Models', + }; + return (
setCommandPaletteOpen(false)} > - {/* Backdrop */}
- {/* Palette */}
e.stopPropagation()} > {/* Tabs */} -
- {(['installed', 'catalogue'] as Tab[]).map((t) => ( +
+ {(['installed', 'catalogue', 'cloud'] as Tab[]).map((t) => ( ))}
- {/* Search input */} -
- - 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)' }} - /> - -
+ + 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)' }} + /> + +
+ )} {/* Status messages */} {pullError && ( @@ -236,13 +313,12 @@ export function CommandPalette() { )} {/* Results */} -
+
{tab === 'installed' ? ( - /* ── Installed models tab ── */ filtered.length === 0 ? (
{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'}
) : ( @@ -254,9 +330,7 @@ export function CommandPalette() {
setSelectedIdx(idx)} > @@ -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 ( -
+
-
- {model.id} -
-
- {model.desc} · {model.size} -
+
{model.id}
+
{model.desc} · {model.size}
); })} - - {/* Custom model input */} -
-
- Or enter any Ollama model name: -
+
+
Or enter any Ollama model name:
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)' }} />
+ ) : ( + /* ── Cloud Models tab ── */ +
+
+ Add your API keys to use cloud models. Keys are stored locally on your device only. +
+ + {CLOUD_PROVIDERS.map((provider) => { + const key = apiKeys[provider.storageKey] || ''; + const hasKey = !!key; + const isVisible = showKeys[provider.storageKey]; + + return ( +
+
+ + {provider.name} + {hasKey && ( + + Connected + + )} +
+ + {/* API key input */} +
+
+ + 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)' }} + /> + +
+ {hasKey && ( + + )} +
+ + {/* Models for this provider (only show if key is set) */} + {hasKey && ( +
+ {provider.models.map((model) => { + const isActive = model.id === selectedModel; + return ( + + ); + })} +
+ )} +
+ ); + })} +
)}
@@ -394,8 +521,10 @@ export function CommandPalette() { Enter Select Esc Close - ) : ( + ) : tab === 'catalogue' ? ( Models are downloaded from the Ollama registry + ) : ( + API keys are stored locally and never sent to OpenJarvis servers )}
diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx index a9400014..284fed1d 100644 --- a/frontend/src/components/Sidebar/Sidebar.tsx +++ b/frontend/src/components/Sidebar/Sidebar.tsx @@ -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('/'); };