mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-31 03:12:16 +00:00
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:
@@ -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} · {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} · {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('/');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user