useAppStore.getState().setSavings(data))
+ .catch(() => {});
}
}, [
input,
diff --git a/frontend/src/components/Chat/MessageBubble.tsx b/frontend/src/components/Chat/MessageBubble.tsx
index cbdf2d84..1214b858 100644
--- a/frontend/src/components/Chat/MessageBubble.tsx
+++ b/frontend/src/components/Chat/MessageBubble.tsx
@@ -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(/
[\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 (
{/* Tool calls */}
@@ -112,7 +120,7 @@ export function MessageBubble({ message }: Props) {
)}
{/* Assistant message */}
- {message.content && (
+ {cleanContent && (
- {message.content}
+ {cleanContent}
)}
{/* Footer: usage + copy */}
-
+
{message.usage && (
{message.usage.total_tokens} tokens
diff --git a/frontend/src/components/Chat/SystemPanel.tsx b/frontend/src/components/Chat/SystemPanel.tsx
new file mode 100644
index 00000000..f339dd7b
--- /dev/null
+++ b/frontend/src/components/Chat/SystemPanel.tsx
@@ -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(null);
+ const [telemetry, setTelemetry] = useState(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 (
+
+ {/* Header */}
+
+
+ System
+
+
+
+
+
+ {/* Session Stats */}
+
+
+ {/* Energy */}
+
+
+ Energy
+
+
+
+
+
+
+
+
+ {thermalStatus.label}
+
+
+
+
+
+ {/* Cost Comparison */}
+
+
+ Cost Comparison
+
+
+ {/* Local */}
+
+
+
+
+ ${(savings?.local_cost ?? 0).toFixed(4)}
+
+
+
+ {/* Cloud providers */}
+
+ {CLOUD_PRICING.map((provider) => {
+ const cost = (promptK * provider.input) / 1000 + (completionK * provider.output) / 1000;
+ const saved = cost - (savings?.local_cost ?? 0);
+ return (
+
+
+
+
+
+ ${cost.toFixed(4)}
+
+ {saved > 0.0001 && (
+
+
+ ${saved.toFixed(4)}
+
+ )}
+
+
+ );
+ })}
+
+
+ {/* Server-reported savings */}
+ {savings && savings.per_provider.length > 0 && (
+
+
+ Server-reported
+
+ {savings.per_provider.map((p) => (
+
+ {p.label}
+ ${p.total_cost.toFixed(4)}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function MiniStat({
+ icon: Icon,
+ label,
+ value,
+ unit,
+}: {
+ icon: typeof Zap;
+ label: string;
+ value: string;
+ unit?: string;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+
+ {value}
+ {unit && (
+
+ {unit}
+
+ )}
+
+
+ );
+}
+
+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);
+}
diff --git a/frontend/src/components/Dashboard/CostComparison.tsx b/frontend/src/components/Dashboard/CostComparison.tsx
index 8563008a..41858f44 100644
--- a/frontend/src/components/Dashboard/CostComparison.tsx
+++ b/frontend/src/components/Dashboard/CostComparison.tsx
@@ -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() {
diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx
index 15ca632d..572eec28 100644
--- a/frontend/src/components/Sidebar/Sidebar.tsx
+++ b/frontend/src/components/Sidebar/Sidebar.tsx
@@ -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 (
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts
index 8fefe5db..c73b5b15 100644
--- a/frontend/src/lib/store.ts
+++ b/frontend/src/lib/store.ts
@@ -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((set, get) => {
@@ -166,6 +171,7 @@ export const useAppStore = create((set, get) => {
commandPaletteOpen: false,
sidebarOpen: true,
+ systemPanelOpen: true,
// ── Conversations ───────────────────────────────────────────────
@@ -313,6 +319,8 @@ export const useAppStore = create((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 }),
};
});
diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx
index 43ff34e0..b7742aa6 100644
--- a/frontend/src/pages/ChatPage.tsx
+++ b/frontend/src/pages/ChatPage.tsx
@@ -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 ;
+ const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
+
+ return (
+
+
+
+
+ {systemPanelOpen &&
}
+
+ );
}
diff --git a/frontend/src/pages/GetStartedPage.tsx b/frontend/src/pages/GetStartedPage.tsx
new file mode 100644
index 00000000..a89d9027
--- /dev/null
+++ b/frontend/src/pages/GetStartedPage.tsx
@@ -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 (
+
+
{code}
+
+
+ );
+}
+
+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 (
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+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 (
+
+
+ {/* Hero */}
+
+
+
+
+
+ OpenJarvis
+
+
+ Private AI that runs on your hardware. Chat, tools, agents, and
+ energy profiling — no cloud required.
+
+
+ v2.8
+
+
+
+ {/* Desktop Download */}
+
+
+ {/* CLI + Browser sections */}
+
+
+
+ Install with pip (Python 3.10+ required):
+
+
+
+ Or with uv for faster installs:
+
+
+
+ Then get started:
+
+
+
+
+
+
+ Launch the API server to get the full UI in your browser:
+
+
+
+ Then open{' '}
+
+ localhost:8000
+
+ {' '}in your browser. Chat, dashboard, energy profiling, and cost
+ comparison all run locally.
+
+
+
+
+ {/* System Requirements */}
+
+
+
+
+ System Requirements
+
+
+
+
+
Runtime
+ Python 3.10+
+
+
+
Inference Engine
+ Ollama, vLLM, llama.cpp, MLX, or LM Studio
+
+
+
Memory
+ 8 GB+ RAM recommended
+
+
+
+
+
+ );
+}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index d6510fe9..72504a2b 100644
--- a/frontend/tsconfig.tsbuildinfo
+++ b/frontend/tsconfig.tsbuildinfo
@@ -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"}
\ No newline at end of file
+{"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"}
\ No newline at end of file
diff --git a/src/openjarvis/agents/orchestrator.py b/src/openjarvis/agents/orchestrator.py
index 8c96c0c8..28af5140 100644
--- a/src/openjarvis/agents/orchestrator.py
+++ b/src/openjarvis/agents/orchestrator.py
@@ -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)
diff --git a/src/openjarvis/engine/ollama.py b/src/openjarvis/engine/ollama.py
index 2c823aa9..f8b41f7e 100644
--- a/src/openjarvis/engine/ollama.py
+++ b/src/openjarvis/engine/ollama.py
@@ -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:
diff --git a/src/openjarvis/server/savings.py b/src/openjarvis/server/savings.py
index e273291a..af6b95cb 100644
--- a/src/openjarvis/server/savings.py
+++ b/src/openjarvis/server/savings.py
@@ -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,
diff --git a/src/openjarvis/server/stream_bridge.py b/src/openjarvis/server/stream_bridge.py
index 8b3cb4ae..aa39fc00 100644
--- a/src/openjarvis/server/stream_bridge.py
+++ b/src/openjarvis/server/stream_bridge.py
@@ -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(