working chat + dashboard design sidebar + cli/dmg setup

This commit is contained in:
Gabriel Bo
2026-03-02 20:20:02 -08:00
parent 22835266fb
commit c1f722f7e1
15 changed files with 697 additions and 25 deletions
+10 -2
View File
@@ -4,6 +4,7 @@ import { Layout } from './components/Layout';
import { ChatPage } from './pages/ChatPage';
import { DashboardPage } from './pages/DashboardPage';
import { SettingsPage } from './pages/SettingsPage';
import { GetStartedPage } from './pages/GetStartedPage';
import { CommandPalette } from './components/CommandPalette';
import { useAppStore } from './lib/store';
import { fetchModels, fetchServerInfo, fetchSavings } from './lib/api';
@@ -51,17 +52,23 @@ export default function App() {
return () => clearInterval(interval);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Global Cmd+K
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
// Global keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setCommandPaletteOpen(!commandPaletteOpen);
}
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
e.preventDefault();
toggleSystemPanel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [commandPaletteOpen, setCommandPaletteOpen]);
}, [commandPaletteOpen, setCommandPaletteOpen, toggleSystemPanel]);
return (
<>
@@ -70,6 +77,7 @@ export default function App() {
<Route index element={<ChatPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="get-started" element={<GetStartedPage />} />
</Route>
</Routes>
{commandPaletteOpen && <CommandPalette />}
+16 -1
View File
@@ -3,7 +3,7 @@ import { MessageBubble } from './MessageBubble';
import { InputArea } from './InputArea';
import { StreamingDots } from './StreamingDots';
import { useAppStore } from '../../lib/store';
import { Sparkles } from 'lucide-react';
import { Sparkles, PanelRightOpen, PanelRightClose } from 'lucide-react';
function getGreeting(): string {
const hour = new Date().getHours();
@@ -15,6 +15,8 @@ function getGreeting(): string {
export function ChatArea() {
const messages = useAppStore((s) => s.messages);
const streamState = useAppStore((s) => s.streamState);
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
@@ -32,8 +34,21 @@ export function ChatArea() {
const isEmpty = messages.length === 0 && !streamState.isStreaming;
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
return (
<div className="flex flex-col h-full">
{/* Toggle bar */}
<div className="flex items-center justify-end px-3 py-1.5 shrink-0">
<button
onClick={toggleSystemPanel}
className="p-1.5 rounded-md transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title={`${systemPanelOpen ? 'Hide' : 'Show'} system panel (${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+I)`}
>
<PanelIcon size={16} />
</button>
</div>
<div
ref={listRef}
onScroll={handleScroll}
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import { Send, Square, Paperclip } from 'lucide-react';
import { useAppStore, generateId } from '../../lib/store';
import { streamChat } from '../../lib/sse';
import { fetchSavings } from '../../lib/api';
import type { ChatMessage, ToolCallInfo, TokenUsage } from '../../types';
export function InputArea() {
@@ -176,6 +177,10 @@ export function InputArea() {
}
resetStream();
abortRef.current = null;
fetchSavings()
.then((data) => useAppStore.getState().setSavings(data))
.catch(() => {});
}
}, [
input,
+12 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkGfm from 'remark-gfm';
@@ -6,6 +6,12 @@ import { Copy, Check } from 'lucide-react';
import { ToolCallCard } from './ToolCallCard';
import type { ChatMessage } from '../../types';
function stripThinkTags(text: string): string {
let cleaned = text.replace(/<think>[\s\S]*?<\/think>\s*/gi, '');
cleaned = cleaned.replace(/^[\s\S]*?<\/think>\s*/i, '');
return cleaned.trim();
}
interface Props {
message: ChatMessage;
}
@@ -100,6 +106,8 @@ export function MessageBubble({ message }: Props) {
);
}
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
return (
<div className="group mb-6">
{/* Tool calls */}
@@ -112,7 +120,7 @@ export function MessageBubble({ message }: Props) {
)}
{/* Assistant message */}
{message.content && (
{cleanContent && (
<div className="prose max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -121,14 +129,14 @@ export function MessageBubble({ message }: Props) {
code: CodeBlock,
}}
>
{message.content}
{cleanContent}
</ReactMarkdown>
</div>
)}
{/* Footer: usage + copy */}
<div className="flex items-center gap-2 mt-1.5 min-h-[24px]">
<CopyMessageButton content={message.content} />
<CopyMessageButton content={cleanContent} />
{message.usage && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
{message.usage.total_tokens} tokens
@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback } from 'react';
import {
Zap,
Activity,
Thermometer,
DollarSign,
TrendingDown,
Cloud,
HardDrive,
Hash,
X,
} from 'lucide-react';
import { useAppStore } from '../../lib/store';
interface EnergyData {
total_energy_j?: number;
energy_per_token_j?: number;
avg_power_w?: number;
}
interface TelemetryStats {
total_requests?: number;
total_tokens?: number;
}
const CLOUD_PRICING = [
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
];
export function SystemPanel() {
const savings = useAppStore((s) => s.savings);
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
const [energy, setEnergy] = useState<EnergyData | null>(null);
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
const fetchData = useCallback(async () => {
try {
const base = import.meta.env.VITE_API_URL || '';
const [energyRes, telRes] = await Promise.allSettled([
fetch(`${base}/v1/telemetry/energy`).then((r) => (r.ok ? r.json() : null)),
fetch(`${base}/v1/telemetry/stats`).then((r) => (r.ok ? r.json() : null)),
]);
if (energyRes.status === 'fulfilled' && energyRes.value) {
setEnergy(energyRes.value as EnergyData);
}
if (telRes.status === 'fulfilled' && telRes.value) {
setTelemetry(telRes.value as TelemetryStats);
}
} catch {
// best-effort
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 3000);
return () => clearInterval(interval);
}, [fetchData]);
// Re-fetch energy/telemetry when savings updates (after a chat message)
useEffect(() => {
if (savings) fetchData();
}, [savings, fetchData]);
const thermalStatus =
(energy?.avg_power_w ?? 0) < 50
? { label: 'Cool', color: 'var(--color-success)' }
: (energy?.avg_power_w ?? 0) < 150
? { label: 'Warm', color: 'var(--color-warning)' }
: { label: 'Hot', color: 'var(--color-error)' };
const promptK = (savings?.total_prompt_tokens ?? 0) / 1000;
const completionK = (savings?.total_completion_tokens ?? 0) / 1000;
return (
<div
className="flex flex-col h-full overflow-y-auto"
style={{
width: 280,
minWidth: 280,
background: 'var(--color-bg)',
borderLeft: '1px solid var(--color-border)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<span className="text-xs font-semibold tracking-wide uppercase" style={{ color: 'var(--color-text-secondary)' }}>
System
</span>
<button
onClick={toggleSystemPanel}
className="p-1 rounded-md transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title="Close panel"
>
<X size={14} />
</button>
</div>
<div className="flex flex-col gap-4 p-4">
{/* Session Stats */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Session
</h4>
<div className="grid grid-cols-2 gap-2">
<MiniStat icon={Hash} label="Requests" value={String(telemetry?.total_requests ?? savings?.total_calls ?? 0)} />
<MiniStat icon={Activity} label="Tokens" value={formatNumber(telemetry?.total_tokens ?? savings?.total_tokens ?? 0)} />
</div>
</section>
{/* Energy */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Energy
</h4>
<div className="grid grid-cols-2 gap-2">
<MiniStat
icon={Zap}
label="Total"
value={((energy?.total_energy_j ?? 0) / 1000).toFixed(1)}
unit="kJ"
/>
<MiniStat
icon={Activity}
label="Per Token"
value={(energy?.energy_per_token_j ?? 0).toFixed(3)}
unit="J"
/>
<MiniStat
icon={Thermometer}
label="Avg Power"
value={(energy?.avg_power_w ?? 0).toFixed(1)}
unit="W"
/>
<div
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: thermalStatus.color }} />
<span className="text-[11px]" style={{ color: 'var(--color-text-secondary)' }}>
{thermalStatus.label}
</span>
</div>
</div>
</section>
{/* Cost Comparison */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Cost Comparison
</h4>
{/* Local */}
<div
className="flex items-center gap-2 rounded-lg px-3 py-2 mb-2"
style={{ background: 'var(--color-accent-subtle)', border: '1px solid var(--color-accent)' }}
>
<HardDrive size={14} style={{ color: 'var(--color-accent)' }} />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate" style={{ color: 'var(--color-text)' }}>Local</div>
</div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-success)' }}>
${(savings?.local_cost ?? 0).toFixed(4)}
</div>
</div>
{/* Cloud providers */}
<div className="flex flex-col gap-1.5">
{CLOUD_PRICING.map((provider) => {
const cost = (promptK * provider.input) / 1000 + (completionK * provider.output) / 1000;
const saved = cost - (savings?.local_cost ?? 0);
return (
<div
key={provider.name}
className="flex items-center gap-2 rounded-lg px-3 py-2"
style={{
background: provider.primary ? 'var(--color-bg-secondary)' : 'var(--color-bg-secondary)',
border: provider.primary ? '1px solid var(--color-border-accent, var(--color-accent))' : '1px solid transparent',
}}
>
<Cloud size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<div className="flex-1 min-w-0">
<div
className="text-xs truncate"
style={{
color: provider.primary ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontWeight: provider.primary ? 500 : 400,
}}
>
{provider.name}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-mono" style={{ color: 'var(--color-text)' }}>
${cost.toFixed(4)}
</div>
{saved > 0.0001 && (
<div className="text-[9px] flex items-center gap-0.5 justify-end" style={{ color: 'var(--color-success)' }}>
<TrendingDown size={8} />
${saved.toFixed(4)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Server-reported savings */}
{savings && savings.per_provider.length > 0 && (
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--color-border)' }}>
<div className="text-[10px] mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Server-reported
</div>
{savings.per_provider.map((p) => (
<div key={p.provider} className="flex justify-between text-[11px] py-0.5">
<span style={{ color: 'var(--color-text-secondary)' }}>{p.label}</span>
<span className="font-mono" style={{ color: 'var(--color-success)' }}>${p.total_cost.toFixed(4)}</span>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
function MiniStat({
icon: Icon,
label,
value,
unit,
}: {
icon: typeof Zap;
label: string;
value: string;
unit?: string;
}) {
return (
<div
className="rounded-lg px-2.5 py-2"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-1 mb-0.5">
<Icon size={10} style={{ color: 'var(--color-accent)' }} />
<span className="text-[10px]" style={{ color: 'var(--color-text-tertiary)' }}>
{label}
</span>
</div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{value}
{unit && (
<span className="text-[10px] font-normal ml-0.5" style={{ color: 'var(--color-text-tertiary)' }}>
{unit}
</span>
)}
</div>
</div>
);
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
@@ -2,9 +2,9 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
import { useAppStore } from '../../lib/store';
const CLOUD_PRICING = [
{ name: 'GPT-4o', input: 2.50, output: 10.00 },
{ name: 'Claude 3.5', input: 3.00, output: 15.00 },
{ name: 'GPT-4o-mini', input: 0.15, output: 0.60 },
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
];
export function CostComparison() {
@@ -9,6 +9,7 @@ import {
PanelLeftClose,
PanelLeft,
Cpu,
Rocket,
} from 'lucide-react';
import { ConversationList } from './ConversationList';
import { useAppStore } from '../../lib/store';
@@ -34,6 +35,7 @@ export function Sidebar() {
{ path: '/', icon: MessageSquare, label: 'Chat' },
{ path: '/dashboard', icon: BarChart3, label: 'Dashboard' },
{ path: '/settings', icon: Settings, label: 'Settings' },
{ path: '/get-started', icon: Rocket, label: 'Get Started' },
];
return (
+8
View File
@@ -109,6 +109,9 @@ interface AppState {
// Sidebar
sidebarOpen: boolean;
// System panel
systemPanelOpen: boolean;
// Actions: conversations
loadConversations: () => void;
createConversation: (model?: string) => string;
@@ -139,6 +142,8 @@ interface AppState {
setCommandPaletteOpen: (open: boolean) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
toggleSystemPanel: () => void;
setSystemPanelOpen: (open: boolean) => void;
}
export const useAppStore = create<AppState>((set, get) => {
@@ -166,6 +171,7 @@ export const useAppStore = create<AppState>((set, get) => {
commandPaletteOpen: false,
sidebarOpen: true,
systemPanelOpen: true,
// ── Conversations ───────────────────────────────────────────────
@@ -313,6 +319,8 @@ export const useAppStore = create<AppState>((set, get) => {
setCommandPaletteOpen: (open: boolean) => set({ commandPaletteOpen: open }),
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
toggleSystemPanel: () => set((s) => ({ systemPanelOpen: !s.systemPanelOpen })),
setSystemPanelOpen: (open: boolean) => set({ systemPanelOpen: open }),
};
});
+12 -1
View File
@@ -1,5 +1,16 @@
import { ChatArea } from '../components/Chat/ChatArea';
import { SystemPanel } from '../components/Chat/SystemPanel';
import { useAppStore } from '../lib/store';
export function ChatPage() {
return <ChatArea />;
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
return (
<div className="flex h-full overflow-hidden">
<div className="flex-1 min-w-0">
<ChatArea />
</div>
{systemPanelOpen && <SystemPanel />}
</div>
);
}
+316
View File
@@ -0,0 +1,316 @@
import { useState, useMemo } from 'react';
import {
Sparkles,
Download,
Terminal,
Globe,
Monitor,
Apple,
ChevronDown,
ChevronRight,
Copy,
Check,
Cpu,
} from 'lucide-react';
const GITHUB_BASE =
'https://github.com/hazy/OpenJarvis/releases/latest/download';
interface Platform {
id: string;
label: string;
shortLabel: string;
file: string;
icon: typeof Apple;
}
const PLATFORMS: Platform[] = [
{
id: 'mac-arm',
label: 'macOS (Apple Silicon)',
shortLabel: 'macOS (Apple Silicon)',
file: 'OpenJarvis_aarch64.dmg',
icon: Apple,
},
{
id: 'mac-intel',
label: 'macOS (Intel)',
shortLabel: 'macOS (Intel)',
file: 'OpenJarvis_x64.dmg',
icon: Apple,
},
{
id: 'windows',
label: 'Windows (64-bit)',
shortLabel: 'Windows (64-bit)',
file: 'OpenJarvis_x64-setup.msi',
icon: Monitor,
},
{
id: 'linux-deb',
label: 'Linux (DEB)',
shortLabel: 'Linux (DEB)',
file: 'OpenJarvis_amd64.deb',
icon: Terminal,
},
{
id: 'linux-rpm',
label: 'Linux (RPM)',
shortLabel: 'Linux (RPM)',
file: 'OpenJarvis_x86_64.rpm',
icon: Terminal,
},
];
function detectPlatform(): string {
const ua = navigator.userAgent.toLowerCase();
const platform = navigator.platform?.toLowerCase() || '';
if (platform.includes('mac') || ua.includes('macintosh')) {
// Apple Silicon detection via WebGL renderer or default to arm
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
if (gl) {
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (ext) {
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
if (renderer && /apple m/i.test(renderer)) return 'mac-arm';
}
}
} catch {}
return 'mac-arm';
}
if (platform.includes('win') || ua.includes('windows')) return 'windows';
if (ua.includes('ubuntu') || ua.includes('debian')) return 'linux-deb';
if (ua.includes('linux')) return 'linux-deb';
return 'mac-arm';
}
function CodeBlock({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className="relative group rounded-lg px-4 py-3 text-sm font-mono overflow-x-auto"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text)' }}
>
<pre className="whitespace-pre-wrap break-all">{code}</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text-tertiary)' }}
title="Copy"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
);
}
function Section({
icon: Icon,
title,
children,
defaultOpen = false,
}: {
icon: typeof Terminal;
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div
className="rounded-xl overflow-hidden"
style={{ border: '1px solid var(--color-border)' }}
>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-3 w-full px-5 py-4 text-left cursor-pointer transition-colors"
style={{ background: open ? 'var(--color-bg-secondary)' : 'var(--color-surface)' }}
onMouseEnter={(e) => {
if (!open) e.currentTarget.style.background = 'var(--color-bg-secondary)';
}}
onMouseLeave={(e) => {
if (!open) e.currentTarget.style.background = 'var(--color-surface)';
}}
>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Icon size={16} />
</div>
<span className="text-sm font-medium flex-1" style={{ color: 'var(--color-text)' }}>
{title}
</span>
<Chevron size={16} style={{ color: 'var(--color-text-tertiary)' }} />
</button>
{open && (
<div className="px-5 pb-5 pt-3 flex flex-col gap-3" style={{ background: 'var(--color-surface)' }}>
{children}
</div>
)}
</div>
);
}
export function GetStartedPage() {
const detectedId = useMemo(() => detectPlatform(), []);
const primary = PLATFORMS.find((p) => p.id === detectedId) || PLATFORMS[0];
const others = PLATFORMS.filter((p) => p.id !== primary.id);
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-16">
{/* Hero */}
<div className="text-center mb-14">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-5"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
OpenJarvis
</h1>
<p
className="text-sm mb-4 leading-relaxed max-w-md mx-auto"
style={{ color: 'var(--color-text-secondary)' }}
>
Private AI that runs on your hardware. Chat, tools, agents, and
energy profiling &mdash; no cloud required.
</p>
<span
className="inline-block text-[11px] font-mono px-2.5 py-1 rounded-full"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
>
v2.8
</span>
</div>
{/* Desktop Download */}
<div className="mb-10">
<div
className="rounded-xl p-8 text-center"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center justify-center gap-2 mb-1">
<Monitor size={18} style={{ color: 'var(--color-text-secondary)' }} />
<h2 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
Desktop App
</h2>
</div>
<p className="text-xs mb-6" style={{ color: 'var(--color-text-tertiary)' }}>
Native app with system tray, auto-updates, and global shortcuts.
</p>
{/* Primary download */}
<a
href={`${GITHUB_BASE}/${primary.file}`}
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-xl text-sm font-medium transition-opacity cursor-pointer"
style={{ background: 'var(--color-accent)', color: 'white' }}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
>
<Download size={18} />
Download for {primary.label}
</a>
{/* Other platforms */}
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
Or
</span>
{others.map((p) => (
<a
key={p.id}
href={`${GITHUB_BASE}/${p.file}`}
className="text-[11px] underline underline-offset-2 transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-secondary)')}
>
{p.shortLabel}
</a>
))}
</div>
</div>
</div>
{/* CLI + Browser sections */}
<div className="flex flex-col gap-3 mb-10">
<Section icon={Terminal} title="Command Line (macOS / Linux)" defaultOpen>
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Install with pip (Python 3.10+ required):
</p>
<CodeBlock code="pip install openjarvis" />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Or with uv for faster installs:
</p>
<CodeBlock code="uv pip install openjarvis" />
<p className="text-xs mt-1" style={{ color: 'var(--color-text-secondary)' }}>
Then get started:
</p>
<CodeBlock code="jarvis init\njarvis doctor\njarvis chat" />
</Section>
<Section icon={Globe} title="Browser App (Self-Hosted)">
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Launch the API server to get the full UI in your browser:
</p>
<CodeBlock code="pip install 'openjarvis[server]'\njarvis serve --port 8000" />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Then open{' '}
<a
href="http://localhost:8000"
className="underline"
style={{ color: 'var(--color-accent)' }}
>
localhost:8000
</a>
{' '}in your browser. Chat, dashboard, energy profiling, and cost
comparison all run locally.
</p>
</Section>
</div>
{/* System Requirements */}
<div
className="rounded-xl px-6 py-5"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2 mb-3">
<Cpu size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<h3 className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--color-text-tertiary)' }}>
System Requirements
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<div>
<div className="font-medium mb-0.5" style={{ color: 'var(--color-text)' }}>Runtime</div>
Python 3.10+
</div>
<div>
<div className="font-medium mb-0.5" style={{ color: 'var(--color-text)' }}>Inference Engine</div>
Ollama, vLLM, llama.cpp, MLX, or LM Studio
</div>
<div>
<div className="font-medium mb-0.5" style={{ color: 'var(--color-text)' }}>Memory</div>
8 GB+ RAM recommended
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/components/Chat/ChatArea.tsx","./src/components/Chat/CopyButton.tsx","./src/components/Chat/InputArea.tsx","./src/components/Chat/MessageBubble.tsx","./src/components/Chat/MessageList.tsx","./src/components/Chat/StreamingIndicator.tsx","./src/components/Chat/ToolCallIndicator.tsx","./src/components/Sidebar/ConversationList.tsx","./src/components/Sidebar/ModelSelector.tsx","./src/components/Sidebar/SavingsPanel.tsx","./src/components/Sidebar/Sidebar.tsx","./src/hooks/useChat.ts","./src/hooks/useConversations.ts","./src/hooks/useModels.ts","./src/hooks/useSavings.ts","./src/hooks/useServerInfo.ts","./src/storage/conversations.ts","./src/types/index.ts"],"version":"5.7.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/lib/api.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/getstartedpage.tsx","./src/pages/settingspage.tsx","./src/types/index.ts"],"version":"5.7.3"}
+2 -1
View File
@@ -228,6 +228,7 @@ class OrchestratorAgent(ToolUsingAgent):
# No tool calls -> check continuation, then final answer
if not raw_tool_calls:
content = self._check_continuation(result, messages)
content = self._strip_think_tags(content)
self._emit_turn_end(turns=turns, content_length=len(content))
return AgentResult(
content=content,
@@ -286,7 +287,7 @@ class OrchestratorAgent(ToolUsingAgent):
))
# Max turns exceeded
final_content = content if content else ""
final_content = self._strip_think_tags(content) if content else ""
return self._max_turns_result(all_tool_results, turns, content=final_content)
+19 -2
View File
@@ -45,7 +45,11 @@ class OllamaEngine(InferenceEngine):
"model": model,
"messages": messages_to_dicts(messages),
"stream": False,
"options": {"temperature": temperature, "num_predict": max_tokens},
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
},
}
# Pass tools if provided
tools = kwargs.get("tools")
@@ -53,11 +57,20 @@ class OllamaEngine(InferenceEngine):
payload["tools"] = tools
try:
resp = self._client.post("/api/chat", json=payload)
if resp.status_code == 400 and tools:
# Model may not support function calling -- retry without tools
payload.pop("tools", None)
resp = self._client.post("/api/chat", json=payload)
resp.raise_for_status()
except (httpx.ConnectError, httpx.TimeoutException) as exc:
raise EngineConnectionError(
f"Ollama not reachable at {self._host}"
) from exc
except httpx.HTTPStatusError as exc:
body = exc.response.text[:500] if exc.response else ""
raise RuntimeError(
f"Ollama returned {exc.response.status_code}: {body}"
) from exc
data = resp.json()
prompt_tokens = data.get("prompt_eval_count", 0)
completion_tokens = data.get("eval_count", 0)
@@ -102,7 +115,11 @@ class OllamaEngine(InferenceEngine):
"model": model,
"messages": messages_to_dicts(messages),
"stream": True,
"options": {"temperature": temperature, "num_predict": max_tokens},
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
},
}
try:
with self._client.stream("POST", "/api/chat", json=payload) as resp:
+6 -7
View File
@@ -10,14 +10,13 @@ from typing import Any, Dict, List
# ---------------------------------------------------------------------------
CLOUD_PRICING: Dict[str, Dict[str, float]] = {
"gpt-5.2": {
"input_per_1m": 1.75,
"output_per_1m": 14.00,
"label": "GPT 5.2",
"gpt-5.3": {
"input_per_1m": 2.00,
"output_per_1m": 10.00,
"label": "GPT-5.3",
"provider": "OpenAI",
# Rough estimates for energy/compute
"energy_wh_per_1k_tokens": 0.4, # Wh per 1K tokens (datacenter)
"flops_per_token": 3.0e12, # ~1.5T params * 2 (forward pass)
"energy_wh_per_1k_tokens": 0.4,
"flops_per_token": 3.0e12,
},
"claude-opus-4.6": {
"input_per_1m": 5.00,
+12 -3
View File
@@ -161,14 +161,23 @@ class AgentStreamBridge:
try:
agent_result = agent_task.result()
except Exception as exc:
# Engine or agent error — emit a user-friendly error chunk
# instead of crashing the SSE stream.
import logging
logger = logging.getLogger("openjarvis.server")
logger.error("Agent stream error: %s", exc, exc_info=True)
error_str = str(exc)
if "400" in error_str:
if "context length" in error_str.lower() or (
"400" in error_str and "too long" in error_str.lower()
):
error_content = (
"The input is too long for the model's context window. "
"Please try a shorter message."
)
elif "400" in error_str:
error_content = (
f"The model returned an error: {error_str}"
)
else:
error_content = f"Sorry, an error occurred: {error_str}"
error_chunk = ChatCompletionChunk(