mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 19:02:16 +00:00
working chat + dashboard design sidebar + cli/dmg setup
This commit is contained in:
+10
-2
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 — 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 @@
|
||||
{"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"}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user