initial frontend setup

This commit is contained in:
Gabriel Bo
2026-03-02 19:35:14 -08:00
parent db114d839f
commit 22835266fb
61 changed files with 18067 additions and 2046 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1e" />
<meta name="theme-color" content="#09090b" />
<meta name="description" content="OpenJarvis — on-device AI assistant" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" />
+2917 -91
View File
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -6,18 +6,37 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"build:tauri": "tsc -b && vite build --outDir dist",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-updater": "^2",
"@tauri-apps/plugin-process": "^2",
"lucide-react": "^0.576.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router": "^7.13.1",
"recharts": "^3.7.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.2.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vite-plugin-pwa": "^0.21.2"
"vite-plugin-pwa": "^1.2.0"
}
}
+6184
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "openjarvis-desktop"
version = "1.0.0"
description = "OpenJarvis Desktop — Native AI assistant with energy monitoring, trace debugging, and learning visualization"
edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-notification = "2"
tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-autostart = "2"
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

+246
View File
@@ -0,0 +1,246 @@
use tauri::Manager;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri_plugin_autostart::MacosLauncher;
/// Fetch health status from the OpenJarvis API server.
#[tauri::command]
async fn check_health(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/health", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch energy monitoring data from the API.
#[tauri::command]
async fn fetch_energy(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/telemetry/energy", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch telemetry statistics from the API.
#[tauri::command]
async fn fetch_telemetry(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/telemetry/stats", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch recent traces from the API.
#[tauri::command]
async fn fetch_traces(api_url: String, limit: u32) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/traces?limit={}", api_url, limit);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch a single trace by ID.
#[tauri::command]
async fn fetch_trace(api_url: String, trace_id: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/traces/{}", api_url, trace_id);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch learning system statistics.
#[tauri::command]
async fn fetch_learning_stats(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/learning/stats", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch learning policy configuration.
#[tauri::command]
async fn fetch_learning_policy(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/learning/policy", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch memory backend statistics.
#[tauri::command]
async fn fetch_memory_stats(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/memory/stats", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Search memory for relevant chunks.
#[tauri::command]
async fn search_memory(
api_url: String,
query: String,
top_k: u32,
) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/memory/search", api_url);
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({"query": query, "top_k": top_k}))
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Fetch list of available agents.
#[tauri::command]
async fn fetch_agents(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/agents", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Launch the `jarvis` CLI command via shell.
#[tauri::command]
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
let output = tokio::process::Command::new("jarvis")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus the main window if another instance is launched
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.setup(|app| {
let show = MenuItemBuilder::with_id("show", "Show / Hide")
.build(app)?;
let health = MenuItemBuilder::with_id("health", "Health: checking...")
.enabled(false)
.build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit OpenJarvis")
.build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.separator()
.item(&health)
.separator()
.item(&quit)
.build()?;
let _tray = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("OpenJarvis")
.menu(&menu)
.on_menu_event(move |app, event| {
match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
})
.build(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
check_health,
fetch_energy,
fetch_telemetry,
fetch_traces,
fetch_trace,
fetch_learning_stats,
fetch_learning_policy,
fetch_memory_stats,
search_memory,
fetch_agents,
run_jarvis_command,
])
.run(tauri::generate_context!())
.expect("error while running OpenJarvis Desktop");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
openjarvis_desktop::run();
}
+68
View File
@@ -0,0 +1,68 @@
{
"$schema": "https://raw.githubusercontent.com/nicknisi/tauri-v2-json-schema/main/tauri-v2-schema.json",
"productName": "OpenJarvis",
"version": "1.0.0",
"identifier": "com.openjarvis.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build:tauri"
},
"app": {
"windows": [
{
"title": "OpenJarvis",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:*; img-src 'self' data: blob:"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
],
"category": "Utility",
"shortDescription": "On-device AI assistant — private, fast, always available",
"longDescription": "OpenJarvis is a local AI assistant with full chat, tool use, energy profiling, and trace debugging. Runs your models on your hardware.",
"macOS": {
"entitlements": "Entitlements.plist",
"minimumSystemVersion": "10.15",
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFNzUzMzhEOEY2MjNEMDMKUldRRFBXS1BqVE4xSG8vK0lkUWN4WnZQYVIrbmc4RmpoOGlJWTBLTE15RlIya3JvQisvdUR3a0QK",
"endpoints": [
"https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/latest.json"
]
},
"notification": {
"permissionState": "prompt"
}
}
}
+66 -91
View File
@@ -1,103 +1,78 @@
import { useState, useEffect } from 'react';
import { Sidebar } from './components/Sidebar/Sidebar';
import { ChatArea } from './components/Chat/ChatArea';
import { useConversations } from './hooks/useConversations';
import { useModels } from './hooks/useModels';
import { useSavings } from './hooks/useSavings';
import { useChat } from './hooks/useChat';
import { useServerInfo } from './hooks/useServerInfo';
import { useEffect } from 'react';
import { Routes, Route } from 'react-router';
import { Layout } from './components/Layout';
import { ChatPage } from './pages/ChatPage';
import { DashboardPage } from './pages/DashboardPage';
import { SettingsPage } from './pages/SettingsPage';
import { CommandPalette } from './components/CommandPalette';
import { useAppStore } from './lib/store';
import { fetchModels, fetchServerInfo, fetchSavings } from './lib/api';
export default function App() {
const { models } = useModels();
const { savings, refresh: refreshSavings } = useSavings();
const serverInfo = useServerInfo();
const {
conversations,
activeId,
activeConversation,
createConversation,
selectConversation,
removeConversation,
reload: reloadConversations,
} = useConversations();
const setModels = useAppStore((s) => s.setModels);
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
const selectedModel = useAppStore((s) => s.selectedModel);
const setServerInfo = useAppStore((s) => s.setServerInfo);
const setSavings = useAppStore((s) => s.setSavings);
const settings = useAppStore((s) => s.settings);
const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen);
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const [selectedModel, setSelectedModel] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(true);
// Set default model
// Apply theme class to <html>
useEffect(() => {
if (!selectedModel && models.length > 0) {
setSelectedModel(models[0].id);
}
}, [models, selectedModel]);
const root = document.documentElement;
root.classList.remove('dark', 'light');
if (settings.theme === 'dark') root.classList.add('dark');
else if (settings.theme === 'light') root.classList.add('light');
}, [settings.theme]);
const {
messages,
streamState,
sendMessage,
stopStreaming,
reloadMessages,
} = useChat(activeId, selectedModel);
// Reload messages when active conversation changes
// Fetch models on mount
useEffect(() => {
reloadMessages();
}, [activeId, reloadMessages]);
fetchModels()
.then((m) => {
setModels(m);
if (!selectedModel && m.length > 0) setSelectedModel(m[0].id);
})
.catch(() => setModels([]))
.finally(() => setModelsLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleNewChat = () => {
createConversation(selectedModel || 'default');
reloadMessages();
};
// Fetch server info
useEffect(() => {
fetchServerInfo().then(setServerInfo).catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSendMessage = async (content: string) => {
if (!activeId) {
createConversation(selectedModel || 'default');
// Need to wait a tick for state to update
setTimeout(async () => {
reloadConversations();
await sendMessage(content);
reloadConversations();
refreshSavings();
}, 0);
return;
}
await sendMessage(content);
reloadConversations();
refreshSavings();
};
// Poll savings
useEffect(() => {
const refresh = () => fetchSavings().then(setSavings).catch(() => {});
refresh();
const interval = setInterval(refresh, 30000);
return () => clearInterval(interval);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Global Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setCommandPaletteOpen(!commandPaletteOpen);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [commandPaletteOpen, setCommandPaletteOpen]);
return (
<div className="app">
<button
className="sidebar-toggle"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle sidebar"
>
{sidebarOpen ? '\u2715' : '\u2630'}
</button>
<Sidebar
isOpen={sidebarOpen}
conversations={conversations}
activeId={activeId}
models={models}
selectedModel={selectedModel}
savings={savings}
localModel={serverInfo?.model}
onSelectModel={setSelectedModel}
onNewChat={handleNewChat}
onSelectConversation={(id) => {
selectConversation(id);
}}
onDeleteConversation={removeConversation}
/>
<ChatArea
messages={messages}
streamState={streamState}
onSendMessage={handleSendMessage}
onStopStreaming={stopStreaming}
activeConversation={activeConversation}
serverInfo={serverInfo}
/>
</div>
<>
<Routes>
<Route element={<Layout />}>
<Route index element={<ChatPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
{commandPaletteOpen && <CommandPalette />}
</>
);
}
-22
View File
@@ -1,22 +0,0 @@
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
const BASE = import.meta.env.VITE_API_URL || ''; // relative to same origin by default
export async function fetchModels(): Promise<ModelInfo[]> {
const res = await fetch(`${BASE}/v1/models`);
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
const data = await res.json();
return data.data || [];
}
export async function fetchSavings(): Promise<SavingsData> {
const res = await fetch(`${BASE}/v1/savings`);
if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
return res.json();
}
export async function fetchServerInfo(): Promise<ServerInfo> {
const res = await fetch(`${BASE}/v1/info`);
if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
return res.json();
}
+63 -65
View File
@@ -1,75 +1,73 @@
import type { ChatMessage, StreamState, Conversation, ServerInfo } from '../../types';
import { MessageList } from './MessageList';
import { StreamingIndicator } from './StreamingIndicator';
import { useRef, useEffect } from 'react';
import { MessageBubble } from './MessageBubble';
import { InputArea } from './InputArea';
import { StreamingDots } from './StreamingDots';
import { useAppStore } from '../../lib/store';
import { Sparkles } from 'lucide-react';
interface ChatAreaProps {
messages: ChatMessage[];
streamState: StreamState;
onSendMessage: (content: string) => void;
onStopStreaming: () => void;
activeConversation: Conversation | null;
serverInfo: ServerInfo | null;
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
}
export function ChatArea({
messages,
streamState,
onSendMessage,
onStopStreaming,
activeConversation,
serverInfo,
}: ChatAreaProps) {
// Cumulative token counts for the conversation
const totalPrompt = messages.reduce(
(sum, m) => sum + (m.usage?.prompt_tokens || 0),
0,
);
const totalCompletion = messages.reduce(
(sum, m) => sum + (m.usage?.completion_tokens || 0),
0,
);
const totalTokens = totalPrompt + totalCompletion;
export function ChatArea() {
const messages = useAppStore((s) => s.messages);
const streamState = useAppStore((s) => s.streamState);
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
useEffect(() => {
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, streamState.content]);
const handleScroll = () => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
};
const isEmpty = messages.length === 0 && !streamState.isStreaming;
return (
<main className="chat-area">
<div className="chat-header">
<div className="chat-header-title">
{activeConversation ? activeConversation.title : 'OpenJarvis Chat'}
</div>
<div className="chat-header-meta">
{serverInfo && (
<div className="chat-header-info">
<span className="header-badge model-badge">
{serverInfo.model || 'unknown'}
</span>
{serverInfo.agent && (
<span className="header-badge agent-badge">
{serverInfo.agent}
</span>
)}
<div className="flex flex-col h-full">
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
>
{isEmpty ? (
<div className="flex flex-col items-center justify-center h-full px-4">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center mb-4"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Sparkles size={24} />
</div>
)}
{totalTokens > 0 && (
<div className="chat-header-tokens">
{totalPrompt.toLocaleString()} in / {totalCompletion.toLocaleString()} out
</div>
)}
</div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
{getGreeting()}
</h2>
<p className="text-sm text-center max-w-sm" style={{ color: 'var(--color-text-secondary)' }}>
Ask anything. Your AI runs locally private, fast, and always available.
</p>
</div>
) : (
<div className="max-w-[var(--chat-max-width)] mx-auto px-4 py-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{streamState.isStreaming && streamState.content === '' && (
<div className="flex justify-start mb-4">
<StreamingDots phase={streamState.phase} />
</div>
)}
</div>
)}
</div>
<MessageList messages={messages} isStreaming={streamState.isStreaming} />
{streamState.isStreaming && (
<StreamingIndicator
phase={streamState.phase}
elapsedMs={streamState.elapsedMs}
toolCalls={streamState.activeToolCalls}
/>
)}
<InputArea
onSend={onSendMessage}
onStop={onStopStreaming}
isStreaming={streamState.isStreaming}
/>
</main>
<InputArea />
</div>
);
}
@@ -1,34 +0,0 @@
import { useState, useCallback } from 'react';
interface CopyButtonProps {
text: string;
}
export function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for non-secure contexts
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [text]);
return (
<button className="copy-btn" onClick={handleCopy} title="Copy to clipboard">
{copied ? 'Copied!' : 'Copy'}
</button>
);
}
+225 -132
View File
@@ -1,159 +1,252 @@
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 type { ChatMessage, ToolCallInfo, TokenUsage } from '../../types';
const COLLAPSE_CHAR_THRESHOLD = 500;
const COLLAPSE_LINE_THRESHOLD = 6;
function shouldCollapse(text: string): boolean {
return (
text.length > COLLAPSE_CHAR_THRESHOLD ||
text.split('\n').length > COLLAPSE_LINE_THRESHOLD
);
}
function formatSize(text: string): string {
const chars = text.length;
const lines = text.split('\n').length;
if (chars >= 1000) {
return `${(chars / 1000).toFixed(1)}k chars, ${lines} line${lines !== 1 ? 's' : ''}`;
}
return `${chars} chars, ${lines} line${lines !== 1 ? 's' : ''}`;
}
interface InputAreaProps {
onSend: (content: string) => void;
onStop: () => void;
isStreaming: boolean;
}
export function InputArea({ onSend, onStop, isStreaming }: InputAreaProps) {
// Pasted/long content stored separately as an "attachment"
const [attachment, setAttachment] = useState('');
// Text typed in the visible textarea
const [typed, setTyped] = useState('');
export function InputArea() {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fullMessage = attachment ? attachment + '\n' + typed : typed;
const activeId = useAppStore((s) => s.activeId);
const selectedModel = useAppStore((s) => s.selectedModel);
const streamState = useAppStore((s) => s.streamState);
const messages = useAppStore((s) => s.messages);
const createConversation = useAppStore((s) => s.createConversation);
const addMessage = useAppStore((s) => s.addMessage);
const updateLastAssistant = useAppStore((s) => s.updateLastAssistant);
const setStreamState = useAppStore((s) => s.setStreamState);
const resetStream = useAppStore((s) => s.resetStream);
const handleSend = useCallback(() => {
if (!fullMessage.trim() || isStreaming) return;
onSend(fullMessage);
setAttachment('');
setTyped('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, [input]);
const stopStreaming = useCallback(() => {
abortRef.current?.abort();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [fullMessage, isStreaming, onSend]);
resetStream();
}, [resetStream]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const sendMessage = useCallback(async () => {
const content = input.trim();
if (!content || streamState.isStreaming) return;
setInput('');
let convId = activeId;
if (!convId) {
convId = createConversation(selectedModel);
}
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content,
timestamp: Date.now(),
};
addMessage(convId, userMsg);
// Build API messages before adding assistant placeholder
const currentMessages = useAppStore.getState().messages;
const apiMessages = currentMessages.map((m) => ({
role: m.role,
content: m.content,
}));
const assistantMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
timestamp: Date.now(),
};
addMessage(convId, assistantMsg);
// Start streaming
const startTime = Date.now();
const timer = setInterval(() => {
setStreamState({ elapsedMs: Date.now() - startTime });
}, 100);
timerRef.current = timer;
const controller = new AbortController();
abortRef.current = controller;
let accumulatedContent = '';
let usage: TokenUsage | undefined;
const toolCalls: ToolCallInfo[] = [];
setStreamState({
isStreaming: true,
phase: 'Sending...',
elapsedMs: 0,
activeToolCalls: [],
content: '',
});
try {
for await (const sseEvent of streamChat(
{ model: selectedModel, messages: apiMessages, stream: true },
controller.signal,
)) {
const eventName = sseEvent.event;
if (eventName === 'agent_turn_start') {
setStreamState({ phase: 'Agent thinking...' });
} else if (eventName === 'inference_start') {
setStreamState({ phase: 'Generating...' });
} else if (eventName === 'tool_call_start') {
try {
const data = JSON.parse(sseEvent.data);
const tc: ToolCallInfo = {
id: generateId(),
tool: data.tool,
arguments: data.arguments || '',
status: 'running',
};
toolCalls.push(tc);
setStreamState({
phase: `Running ${data.tool}...`,
activeToolCalls: [...toolCalls],
});
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
} catch {}
} else if (eventName === 'tool_call_end') {
try {
const data = JSON.parse(sseEvent.data);
const tc = toolCalls.find(
(t) => t.tool === data.tool && t.status === 'running',
);
if (tc) {
tc.status = data.success ? 'success' : 'error';
tc.latency = data.latency;
tc.result = data.result;
}
setStreamState({
phase: 'Generating...',
activeToolCalls: [...toolCalls],
});
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
} catch {}
} else {
try {
const data = JSON.parse(sseEvent.data);
const delta = data.choices?.[0]?.delta;
if (data.usage) usage = data.usage;
if (delta?.content) {
accumulatedContent += delta.content;
setStreamState({ content: accumulatedContent });
updateLastAssistant(
convId,
accumulatedContent,
toolCalls.length > 0 ? [...toolCalls] : undefined,
);
}
if (data.choices?.[0]?.finish_reason === 'stop') break;
} catch {}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
accumulatedContent =
accumulatedContent || 'Error: Failed to get response.';
}
} finally {
if (!accumulatedContent) {
accumulatedContent = 'No response was generated. Please try again.';
}
updateLastAssistant(
convId,
accumulatedContent,
toolCalls.length > 0 ? toolCalls : undefined,
usage,
);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
resetStream();
abortRef.current = null;
}
}, [
input,
activeId,
selectedModel,
streamState.isStreaming,
createConversation,
addMessage,
updateLastAssistant,
setStreamState,
resetStream,
]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
sendMessage();
}
};
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setTyped(newValue);
// Auto-resize textarea
const ta = e.target;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
};
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const pasted = e.clipboardData.getData('text');
if (shouldCollapse(pasted)) {
e.preventDefault();
// Store long paste as an attachment pill
setAttachment((prev) => (prev ? prev + '\n' + pasted : pasted));
}
// Short pastes go directly into the textarea as normal
},
[],
);
const handleClearAttachment = useCallback(() => {
setAttachment('');
textareaRef.current?.focus();
}, []);
const handleExpandAttachment = useCallback(() => {
// Move attachment content back into the textarea
setTyped((prev) => (attachment + (prev ? '\n' + prev : '')));
setAttachment('');
// Let React render, then resize
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height =
Math.min(textareaRef.current.scrollHeight, 200) + 'px';
}
}, 0);
}, [attachment]);
// Focus textarea after attachment changes
useEffect(() => {
textareaRef.current?.focus();
}, [attachment]);
return (
<div className="input-area">
{attachment && (
<div className="input-attachment-row">
<div className="pasted-pill">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
<span className="pasted-pill-text">Pasted text</span>
<span className="pasted-pill-size">{formatSize(attachment)}</span>
<button
className="pasted-pill-action"
onClick={handleExpandAttachment}
title="Expand to edit"
>
Edit
</button>
<button
className="pasted-pill-action pasted-pill-remove"
onClick={handleClearAttachment}
title="Remove pasted text"
>
&times;
</button>
</div>
</div>
)}
<div className="input-container">
<div className="px-4 pb-4 pt-2" style={{ maxWidth: 'var(--chat-max-width)', margin: '0 auto', width: '100%' }}>
<div
className="flex items-end gap-2 rounded-2xl px-4 py-3 transition-shadow"
style={{
background: 'var(--color-input-bg)',
border: '1px solid var(--color-input-border)',
boxShadow: 'var(--shadow-sm)',
}}
>
<textarea
ref={textareaRef}
value={typed}
onChange={handleInput}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
attachment
? 'Add instructions for the pasted text...'
: 'Type a message... (Shift+Enter for new line)'
}
rows={3}
disabled={isStreaming}
placeholder="Message OpenJarvis..."
rows={1}
className="flex-1 bg-transparent outline-none resize-none text-sm leading-relaxed"
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
disabled={streamState.isStreaming}
/>
{isStreaming ? (
<button className="stop-btn" onClick={onStop}>
Stop
{streamState.isStreaming ? (
<button
onClick={stopStreaming}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
style={{ background: 'var(--color-error)', color: 'white' }}
title="Stop generating"
>
<Square size={16} />
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!fullMessage.trim()}
onClick={sendMessage}
disabled={!input.trim()}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
style={{
background: input.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
color: input.trim() ? 'white' : 'var(--color-text-tertiary)',
}}
title="Send message"
>
Send
<Send size={16} />
</button>
)}
</div>
<div className="flex items-center justify-center mt-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
<span>
<kbd className="font-mono">Enter</kbd> to send &middot;{' '}
<kbd className="font-mono">Shift+Enter</kbd> for new line
</span>
</div>
</div>
);
}
+121 -25
View File
@@ -1,41 +1,137 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkGfm from 'remark-gfm';
import { Copy, Check } from 'lucide-react';
import { ToolCallCard } from './ToolCallCard';
import type { ChatMessage } from '../../types';
import { ToolCallIndicator } from './ToolCallIndicator';
import { CopyButton } from './CopyButton';
interface MessageBubbleProps {
interface Props {
message: ChatMessage;
}
function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function CodeBlock({ className, children, ...props }: any) {
const [copied, setCopied] = useState(false);
const match = /language-(\w+)/.exec(className || '');
const lang = match ? match[1] : '';
const code = String(children).replace(/\n$/, '');
export function MessageBubble({ message }: MessageBubbleProps) {
const usage = message.usage;
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!className) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<div className={`message-bubble ${message.role}`}>
<div className="relative group">
<div
className="flex items-center justify-between px-4 py-1.5 text-xs rounded-t-[var(--radius-md)]"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
>
<span className="font-mono">{lang || 'code'}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-0.5 rounded transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-text-secondary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="!mt-0 !rounded-t-none">
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}
function CopyMessageButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title="Copy message"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
);
}
export function MessageBubble({ message }: Props) {
const isUser = message.role === 'user';
if (isUser) {
return (
<div className="flex justify-end mb-4">
<div
className="max-w-[85%] px-4 py-2.5 text-sm leading-relaxed"
style={{
background: 'var(--color-user-bubble)',
color: 'var(--color-user-bubble-text)',
borderRadius: 'var(--radius-xl) var(--radius-xl) var(--radius-sm) var(--radius-xl)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{message.content}
</div>
</div>
);
}
return (
<div className="group mb-6">
{/* Tool calls */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="tool-calls">
<div className="mb-3 flex flex-col gap-2">
{message.toolCalls.map((tc) => (
<ToolCallIndicator key={tc.id} toolCall={tc} />
<ToolCallCard key={tc.id} toolCall={tc} />
))}
</div>
)}
<div className="message-content">
{message.content || (message.role === 'assistant' ? '\u200B' : '')}
{message.role === 'assistant' && message.content && (
<CopyButton text={message.content} />
)}
</div>
<div className="message-meta">
<span className="message-time">{formatTime(message.timestamp)}</span>
{usage && (
<span className="message-tokens">
{usage.prompt_tokens} in / {usage.completion_tokens} out
{/* Assistant message */}
{message.content && (
<div className="prose max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code: CodeBlock,
}}
>
{message.content}
</ReactMarkdown>
</div>
)}
{/* Footer: usage + copy */}
<div className="flex items-center gap-2 mt-1.5 min-h-[24px]">
<CopyMessageButton content={message.content} />
{message.usage && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
{message.usage.total_tokens} tokens
</span>
)}
</div>
@@ -1,59 +0,0 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import type { ChatMessage } from '../../types';
import { MessageBubble } from './MessageBubble';
function getGreeting(): string {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return 'Good Morning! What shall we build today?';
if (hour >= 12 && hour < 17) return 'Good Afternoon! Ready to create something?';
if (hour >= 17 && hour < 21) return 'Good Evening! Let\'s get things done.';
return 'Late Night Session \u2014 Let\'s make it count.';
}
interface MessageListProps {
messages: ChatMessage[];
isStreaming: boolean;
}
export function MessageList({ messages, isStreaming }: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const greeting = useMemo(() => getGreeting(), []);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (autoScroll && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, autoScroll, isStreaming]);
const handleScroll = () => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setAutoScroll(isAtBottom);
};
if (messages.length === 0) {
return (
<div className="message-list">
<div className="message-list-empty" style={{ flexDirection: 'column', gap: '8px' }}>
<span style={{ fontSize: '24px', fontWeight: 600, color: '#1e293b' }}>
{greeting}
</span>
<span style={{ fontSize: '14px' }}>
Type a message below to get started.
</span>
</div>
</div>
);
}
return (
<div className="message-list" ref={listRef} onScroll={handleScroll}>
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
);
}
@@ -0,0 +1,29 @@
interface Props {
phase: string;
}
export function StreamingDots({ phase }: Props) {
return (
<div className="flex items-center gap-2 py-2">
<div className="flex gap-1">
<span
className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ background: 'var(--color-text-tertiary)', animationDelay: '0ms' }}
/>
<span
className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ background: 'var(--color-text-tertiary)', animationDelay: '150ms' }}
/>
<span
className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ background: 'var(--color-text-tertiary)', animationDelay: '300ms' }}
/>
</div>
{phase && (
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{phase}
</span>
)}
</div>
);
}
@@ -1,39 +0,0 @@
import type { ToolCallInfo } from '../../types';
import { ToolCallIndicator } from './ToolCallIndicator';
interface StreamingIndicatorProps {
phase: string;
elapsedMs: number;
toolCalls: ToolCallInfo[];
}
function formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000);
const tenths = Math.floor((ms % 1000) / 100);
return `${seconds}.${tenths}s`;
}
export function StreamingIndicator({
phase,
elapsedMs,
toolCalls,
}: StreamingIndicatorProps) {
return (
<div className="streaming-indicator">
{toolCalls.length > 0 && (
<div className="streaming-tool-calls">
{toolCalls.map((tc) => (
<ToolCallIndicator key={tc.id} toolCall={tc} />
))}
</div>
)}
<div className="streaming-progress-row">
<div className="streaming-bar-container">
<div className="streaming-bar" />
</div>
<span className="streaming-phase">{phase}</span>
<span className="streaming-elapsed">{formatElapsed(elapsedMs)}</span>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Wrench, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import type { ToolCallInfo } from '../../types';
interface Props {
toolCall: ToolCallInfo;
}
const statusConfig = {
running: { icon: Loader2, label: 'Running', color: 'var(--color-accent)' },
success: { icon: CheckCircle2, label: 'Done', color: 'var(--color-success)' },
error: { icon: XCircle, label: 'Failed', color: 'var(--color-error)' },
};
export function ToolCallCard({ toolCall }: Props) {
const [expanded, setExpanded] = useState(false);
const config = statusConfig[toolCall.status];
const StatusIcon = config.icon;
return (
<div
className="rounded-lg text-sm overflow-hidden"
style={{ border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)' }}
>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 transition-colors cursor-pointer"
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{expanded ? (
<ChevronDown size={14} style={{ color: 'var(--color-text-tertiary)' }} />
) : (
<ChevronRight size={14} style={{ color: 'var(--color-text-tertiary)' }} />
)}
<Wrench size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<span style={{ color: 'var(--color-text)' }} className="font-medium">
{toolCall.tool}
</span>
<div className="flex-1" />
<StatusIcon
size={14}
style={{ color: config.color }}
className={toolCall.status === 'running' ? 'animate-spin' : ''}
/>
{toolCall.latency != null && (
<span className="text-[11px] font-mono" style={{ color: 'var(--color-text-tertiary)' }}>
{toolCall.latency < 1000
? `${Math.round(toolCall.latency)}ms`
: `${(toolCall.latency / 1000).toFixed(1)}s`}
</span>
)}
</button>
{expanded && (
<div className="px-3 pb-3" style={{ borderTop: '1px solid var(--color-border)' }}>
{toolCall.arguments && (
<div className="mt-2">
<div className="text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Arguments
</div>
<pre
className="text-xs p-2 rounded overflow-x-auto font-mono"
style={{ background: 'var(--color-code-bg)', color: 'var(--color-text-secondary)' }}
>
{toolCall.arguments}
</pre>
</div>
)}
{toolCall.result && (
<div className="mt-2">
<div className="text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Result
</div>
<pre
className="text-xs p-2 rounded overflow-x-auto font-mono max-h-48"
style={{ background: 'var(--color-code-bg)', color: 'var(--color-text-secondary)' }}
>
{toolCall.result}
</pre>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,17 +0,0 @@
import type { ToolCallInfo } from '../../types';
interface ToolCallIndicatorProps {
toolCall: ToolCallInfo;
}
export function ToolCallIndicator({ toolCall }: ToolCallIndicatorProps) {
return (
<div className="tool-call">
<span className={`tool-status ${toolCall.status}`} />
<span className="tool-name">{toolCall.tool}</span>
{toolCall.latency !== undefined && (
<span className="tool-latency">{toolCall.latency.toFixed(0)}ms</span>
)}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { Search, Cpu, X } from 'lucide-react';
import { useAppStore } from '../lib/store';
export function CommandPalette() {
const [query, setQuery] = useState('');
const [selectedIdx, setSelectedIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const models = useAppStore((s) => s.models);
const selectedModel = useAppStore((s) => s.selectedModel);
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const filtered = query
? models.filter((m) =>
m.id.toLowerCase().includes(query.toLowerCase()),
)
: models;
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
setSelectedIdx(0);
}, [query]);
const handleSelect = (modelId: string) => {
setSelectedModel(modelId);
setCommandPaletteOpen(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setCommandPaletteOpen(false);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && filtered.length > 0) {
e.preventDefault();
handleSelect(filtered[selectedIdx].id);
}
};
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={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-lg)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 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="Search models..."
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>
{/* Results */}
<div className="max-h-[300px] overflow-y-auto py-2">
{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 — is the server running?' : 'No matching models'}
</div>
) : (
filtered.map((model, idx) => {
const isActive = model.id === selectedModel;
const isSelected = idx === selectedIdx;
return (
<button
key={model.id}
onClick={() => handleSelect(model.id)}
className="flex items-center gap-3 w-full px-4 py-2.5 text-left transition-colors cursor-pointer"
style={{
background: isSelected ? 'var(--color-bg-secondary)' : 'transparent',
}}
onMouseEnter={() => setSelectedIdx(idx)}
>
<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,
}}
>
{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)',
}}>
Active
</span>
)}
</button>
);
})
)}
</div>
{/* Footer */}
<div
className="flex items-center gap-4 px-4 py-2 text-[11px]"
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-text-tertiary)' }}
>
<span><kbd className="font-mono"></kbd> Navigate</span>
<span><kbd className="font-mono">Enter</kbd> Select</span>
<span><kbd className="font-mono">Esc</kbd> Close</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,116 @@
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 },
];
export function CostComparison() {
const savings = useAppStore((s) => s.savings);
if (!savings || savings.total_tokens === 0) {
return (
<div
className="rounded-xl p-6"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<DollarSign size={16} style={{ color: 'var(--color-success)' }} />
Cost Comparison
</h3>
<div className="h-48 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
Start chatting to see local vs. cloud cost savings.
</div>
</div>
);
}
const promptK = savings.total_prompt_tokens / 1000;
const completionK = savings.total_completion_tokens / 1000;
return (
<div
className="rounded-xl p-6"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<DollarSign size={16} style={{ color: 'var(--color-success)' }} />
Cost Comparison
</h3>
{/* Local stats */}
<div
className="flex items-center gap-3 p-3 rounded-lg mb-3"
style={{ background: 'var(--color-accent-subtle)', border: '1px solid var(--color-accent)' }}
>
<HardDrive size={18} style={{ color: 'var(--color-accent)' }} />
<div className="flex-1">
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Local (your hardware)
</div>
<div className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{savings.total_calls} requests &middot; {savings.total_tokens.toLocaleString()} tokens
</div>
</div>
<div className="text-right">
<div className="text-lg font-semibold" style={{ color: 'var(--color-success)' }}>
${savings.local_cost.toFixed(4)}
</div>
<div className="text-[10px]" style={{ color: 'var(--color-text-tertiary)' }}>
electricity only
</div>
</div>
</div>
{/* Cloud comparisons */}
<div className="flex flex-col gap-2">
{CLOUD_PRICING.map((provider) => {
const cost = (promptK * provider.input / 1000) + (completionK * provider.output / 1000);
const saved = cost - savings.local_cost;
return (
<div
key={provider.name}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ background: 'var(--color-bg-secondary)' }}
>
<Cloud size={16} style={{ color: 'var(--color-text-tertiary)' }} />
<div className="flex-1">
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{provider.name}
</div>
</div>
<div className="text-right">
<div className="text-sm font-mono" style={{ color: 'var(--color-text)' }}>
${cost.toFixed(4)}
</div>
{saved > 0 && (
<div className="text-[10px] flex items-center gap-0.5 justify-end" style={{ color: 'var(--color-success)' }}>
<TrendingDown size={10} />
${saved.toFixed(4)} saved
</div>
)}
</div>
</div>
);
})}
</div>
{/* Savings from API if available */}
{savings.per_provider.length > 0 && (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--color-border)' }}>
<div className="text-xs mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Server-reported savings
</div>
{savings.per_provider.map((p) => (
<div key={p.provider} className="flex justify-between text-xs py-1">
<span style={{ color: 'var(--color-text-secondary)' }}>{p.label}</span>
<span style={{ color: 'var(--color-success)' }}>${p.total_cost.toFixed(4)}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,201 @@
import { useState, useEffect, useCallback } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Zap, Activity, Thermometer, Hash } from 'lucide-react';
interface EnergySample {
timestamp: string;
power_w: number;
energy_j: number;
}
interface EnergyData {
total_energy_j?: number;
energy_per_token_j?: number;
avg_power_w?: number;
samples?: EnergySample[];
}
interface TelemetryStats {
total_requests?: number;
total_tokens?: number;
}
interface ChartPoint {
time: string;
power: number;
}
function StatCard({
icon: Icon,
label,
value,
unit,
}: {
icon: typeof Zap;
label: string;
value: string;
unit?: string;
}) {
return (
<div
className="rounded-lg p-4"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2 mb-2">
<Icon size={14} style={{ color: 'var(--color-accent)' }} />
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{label}
</span>
</div>
<div className="text-xl font-semibold" style={{ color: 'var(--color-text)' }}>
{value}
{unit && (
<span className="text-xs font-normal ml-1" style={{ color: 'var(--color-text-tertiary)' }}>
{unit}
</span>
)}
</div>
</div>
);
}
export function EnergyDashboard() {
const [energy, setEnergy] = useState<EnergyData | null>(null);
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
const [chartData, setChartData] = useState<ChartPoint[]>([]);
const [error, setError] = useState<string | 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) {
const data = energyRes.value as EnergyData;
setEnergy(data);
if (data.samples) {
setChartData(
data.samples.map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
power: Math.round(s.power_w * 10) / 10,
})),
);
}
setError(null);
}
if (telRes.status === 'fulfilled' && telRes.value) {
setTelemetry(telRes.value as TelemetryStats);
}
} catch {
setError('Cannot connect to server');
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, [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)' };
if (error || !energy) {
return (
<div
className="rounded-xl p-6"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<Zap size={16} style={{ color: 'var(--color-accent)' }} />
Energy Monitoring
</h3>
<div className="h-48 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
{error || 'Waiting for energy data from the server...'}
</div>
</div>
);
}
return (
<div
className="rounded-xl p-6"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<Zap size={16} style={{ color: 'var(--color-accent)' }} />
Energy Monitoring
</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<StatCard
icon={Zap}
label="Total Energy"
value={((energy.total_energy_j ?? 0) / 1000).toFixed(1)}
unit="kJ"
/>
<StatCard
icon={Activity}
label="Energy / Token"
value={(energy.energy_per_token_j ?? 0).toFixed(3)}
unit="J"
/>
<StatCard
icon={Thermometer}
label="Avg Power"
value={(energy.avg_power_w ?? 0).toFixed(1)}
unit="W"
/>
<StatCard
icon={Hash}
label="Total Requests"
value={String(telemetry?.total_requests ?? 0)}
/>
</div>
{/* Thermal indicator */}
<div className="flex items-center gap-2 mb-4 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: thermalStatus.color }} />
Thermal: {thermalStatus.label}
<span className="ml-auto">{telemetry?.total_tokens ?? 0} tokens processed</span>
</div>
{/* Chart */}
{chartData.length > 1 && (
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: 'var(--color-text-tertiary)' }} />
<YAxis tick={{ fontSize: 10, fill: 'var(--color-text-tertiary)' }} unit="W" />
<Tooltip
contentStyle={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
fontSize: 12,
color: 'var(--color-text)',
}}
/>
<Line type="monotone" dataKey="power" stroke="var(--color-accent)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -0,0 +1,210 @@
import { useState, useEffect, useCallback } from 'react';
import { GitBranch, Clock, ChevronRight, ChevronDown } from 'lucide-react';
interface TraceStepData {
model?: string;
tokens?: number;
tool?: string;
input?: string;
output?: string;
[key: string]: unknown;
}
interface TraceStep {
step_type: string;
duration_ms: number;
data: TraceStepData;
}
interface TraceSummary {
id: string;
query: string;
steps: TraceStep[];
created_at: string;
}
const STEP_COLORS: Record<string, string> = {
route: 'var(--color-accent)',
retrieve: 'var(--color-success)',
generate: 'var(--color-warning)',
tool_call: '#a855f7',
respond: '#ec4899',
};
function StepBadge({ type }: { type: string }) {
const color = STEP_COLORS[type] || 'var(--color-text-tertiary)';
return (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium"
style={{ background: `color-mix(in srgb, ${color} 15%, transparent)`, color }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
{type}
</span>
);
}
function TraceCard({ trace, isActive, onClick }: { trace: TraceSummary; isActive: boolean; onClick: () => void }) {
const totalMs = trace.steps.reduce((sum, s) => sum + s.duration_ms, 0);
return (
<button
onClick={onClick}
className="w-full text-left p-3 rounded-lg transition-colors cursor-pointer"
style={{
background: isActive ? 'var(--color-bg-tertiary)' : 'transparent',
border: isActive ? '1px solid var(--color-border)' : '1px solid transparent',
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<div className="text-sm truncate mb-1" style={{ color: 'var(--color-text)' }}>
{trace.query || 'Untitled query'}
</div>
<div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
<span>{trace.steps.length} steps</span>
<span>&middot;</span>
<span>{totalMs.toFixed(0)}ms</span>
<span>&middot;</span>
<span>{new Date(trace.created_at).toLocaleTimeString()}</span>
</div>
</button>
);
}
function StepDetail({ step, index }: { step: TraceStep; index: number }) {
const [expanded, setExpanded] = useState(false);
const dataEntries = Object.entries(step.data).filter(([_, v]) => v != null);
return (
<div
className="rounded-lg overflow-hidden"
style={{ border: '1px solid var(--color-border)' }}
>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors cursor-pointer"
style={{ background: 'var(--color-bg-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-mono" style={{ color: 'var(--color-text-tertiary)' }}>
{index + 1}
</span>
<StepBadge type={step.step_type} />
<span className="flex-1" />
<span className="text-xs font-mono flex items-center gap-1" style={{ color: 'var(--color-text-tertiary)' }}>
<Clock size={10} />
{step.duration_ms.toFixed(0)}ms
</span>
</button>
{expanded && dataEntries.length > 0 && (
<div className="px-3 py-2 text-xs" style={{ borderTop: '1px solid var(--color-border)' }}>
{dataEntries.map(([key, value]) => (
<div key={key} className="flex gap-2 py-1">
<span className="font-mono shrink-0" style={{ color: 'var(--color-text-tertiary)', minWidth: '80px' }}>
{key}
</span>
<span className="truncate" style={{ color: 'var(--color-text-secondary)' }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
)}
</div>
);
}
export function TraceDebugger() {
const [traces, setTraces] = useState<TraceSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchTraces = useCallback(async () => {
try {
const base = import.meta.env.VITE_API_URL || '';
const res = await fetch(`${base}/v1/traces?limit=50`);
if (!res.ok) throw new Error();
const data = await res.json();
setTraces(data.traces || []);
setError(null);
} catch {
setError('Cannot load traces');
}
}, []);
useEffect(() => {
fetchTraces();
}, [fetchTraces]);
const selected = traces.find((t) => t.id === selectedId);
if (error) {
return (
<div
className="rounded-xl p-6"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<GitBranch size={16} style={{ color: 'var(--color-accent)' }} />
Trace Debugger
</h3>
<div className="h-48 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
{error}
</div>
</div>
);
}
return (
<div
className="rounded-xl p-6"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<GitBranch size={16} style={{ color: 'var(--color-accent)' }} />
Trace Debugger
</h3>
{traces.length === 0 ? (
<div className="h-48 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
No traces yet. Start making queries to see them here.
</div>
) : (
<div className="flex gap-4 h-80">
{/* Trace list */}
<div className="w-1/3 overflow-y-auto flex flex-col gap-1 pr-2" style={{ borderRight: '1px solid var(--color-border)' }}>
{traces.map((trace) => (
<TraceCard
key={trace.id}
trace={trace}
isActive={trace.id === selectedId}
onClick={() => setSelectedId(trace.id)}
/>
))}
</div>
{/* Trace detail */}
<div className="flex-1 overflow-y-auto">
{selected ? (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium mb-2" style={{ color: 'var(--color-text)' }}>
{selected.query}
</div>
{selected.steps.map((step, i) => (
<StepDetail key={i} step={step} index={i} />
))}
</div>
) : (
<div className="h-full flex items-center justify-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
Select a trace to view details
</div>
)}
</div>
</div>
)}
</div>
);
}
+19 -45
View File
@@ -1,5 +1,6 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import type { ReactNode, ErrorInfo } from 'react';
import { AlertTriangle, RotateCcw } from 'lucide-react';
interface Props {
children: ReactNode;
@@ -21,66 +22,39 @@ export class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div style={styles.container}>
<div style={styles.card}>
<h2 style={styles.heading}>Something went wrong</h2>
<p style={styles.message}>
<div className="flex items-center justify-center h-full p-8" style={{ background: 'var(--color-bg)' }}>
<div className="text-center max-w-sm">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center mx-auto mb-4"
style={{ background: 'rgba(220,38,38,0.1)', color: 'var(--color-error)' }}
>
<AlertTriangle size={24} />
</div>
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
Something went wrong
</h2>
<p className="text-sm mb-4" style={{ color: 'var(--color-text-secondary)' }}>
{this.state.error?.message || 'An unexpected error occurred.'}
</p>
<button
style={styles.button}
onClick={() => this.setState({ hasError: false, error: null })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ background: 'var(--color-accent)', color: 'white' }}
>
<RotateCcw size={14} />
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: '#1a1a1e',
color: '#e2e8f0',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
},
card: {
textAlign: 'center' as const,
padding: 32,
maxWidth: 420,
},
heading: {
fontSize: 20,
fontWeight: 600,
marginBottom: 12,
},
message: {
fontSize: 14,
color: '#94a3b8',
marginBottom: 24,
lineHeight: 1.5,
},
button: {
padding: '10px 24px',
borderRadius: 8,
border: 'none',
backgroundColor: '#2563eb',
color: 'white',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
},
};
+23
View File
@@ -0,0 +1,23 @@
import { Outlet } from 'react-router';
import { Sidebar } from './Sidebar/Sidebar';
import { useAppStore } from '../lib/store';
export function Layout() {
const sidebarOpen = useAppStore((s) => s.sidebarOpen);
return (
<div className="flex h-full w-full overflow-hidden">
<Sidebar />
{/* Overlay for mobile when sidebar is open */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => useAppStore.getState().setSidebarOpen(false)}
/>
)}
<main className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--color-bg)' }}>
<Outlet />
</main>
</div>
);
}
@@ -1,44 +1,98 @@
import type { Conversation } from '../../types';
import { Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useAppStore } from '../../lib/store';
interface ConversationListProps {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
interface Props {
searchQuery: string;
}
export function ConversationList({
conversations,
activeId,
onSelect,
onDelete,
}: ConversationListProps) {
function formatRelativeTime(timestamp: number): string {
const diff = Date.now() - timestamp;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString();
}
export function ConversationList({ searchQuery }: Props) {
const navigate = useNavigate();
const conversations = useAppStore((s) => s.conversations);
const activeId = useAppStore((s) => s.activeId);
const selectConversation = useAppStore((s) => s.selectConversation);
const deleteConversation = useAppStore((s) => s.deleteConversation);
const filtered = searchQuery
? conversations.filter((c) =>
c.title.toLowerCase().includes(searchQuery.toLowerCase()),
)
: conversations;
if (filtered.length === 0) {
return (
<div className="px-3 py-8 text-center text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{searchQuery ? 'No matching chats' : 'No conversations yet'}
</div>
);
}
return (
<div className="conversation-list">
{conversations.length === 0 && (
<div style={{ padding: '16px', color: 'var(--color-text-secondary)', fontSize: '13px', textAlign: 'center' }}>
No conversations yet
</div>
)}
{conversations.map((conv) => (
<div
key={conv.id}
className={`conversation-item ${conv.id === activeId ? 'active' : ''}`}
onClick={() => onSelect(conv.id)}
>
<span className="conv-title">{conv.title}</span>
<button
className="conv-delete"
onClick={(e) => {
e.stopPropagation();
onDelete(conv.id);
<div className="flex flex-col gap-0.5 py-1">
{filtered.map((conv) => {
const isActive = conv.id === activeId;
return (
<div
key={conv.id}
className="group flex items-center rounded-lg cursor-pointer transition-colors"
style={{
background: isActive ? 'var(--color-bg-tertiary)' : 'transparent',
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
aria-label="Delete conversation"
>
&times;
</button>
</div>
))}
<button
onClick={() => {
selectConversation(conv.id);
navigate('/');
}}
className="flex-1 text-left px-3 py-2 min-w-0 cursor-pointer"
>
<div
className="text-sm truncate"
style={{
color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontWeight: isActive ? 500 : 400,
}}
>
{conv.title}
</div>
<div className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>
{formatRelativeTime(conv.updatedAt)}
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
title="Delete conversation"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
);
}
@@ -1,29 +0,0 @@
import type { ModelInfo } from '../../types';
interface ModelSelectorProps {
models: ModelInfo[];
selected: string;
onSelect: (model: string) => void;
}
export function ModelSelector({ models, selected, onSelect }: ModelSelectorProps) {
return (
<div className="model-selector">
<label htmlFor="model-select">Model</label>
<select
id="model-select"
value={selected}
onChange={(e) => onSelect(e.target.value)}
>
{models.length === 0 && (
<option value="">No models available</option>
)}
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))}
</select>
</div>
);
}
@@ -1,85 +0,0 @@
import type { SavingsData } from '../../types';
interface SavingsPanelProps {
savings: SavingsData | null;
localModel?: string;
}
function formatDollars(n: number): string {
if (n >= 1) return '$' + n.toFixed(2);
if (n > 0) return '$' + n.toFixed(4);
return '$0.00';
}
function formatJoules(joules: number): string {
if (joules >= 1e6) return (joules / 1e6).toFixed(1) + ' MJ';
if (joules >= 1000) return (joules / 1000).toFixed(1) + ' kJ';
if (joules >= 1) return joules.toFixed(0) + ' J';
return '0 J';
}
function formatFlops(n: number): string {
if (n >= 1e15) return (n / 1e15).toFixed(1) + ' PF';
if (n >= 1e12) return (n / 1e12).toFixed(1) + ' TF';
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GF';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MF';
return n.toFixed(0) + ' F';
}
export function SavingsPanel({ savings, localModel }: SavingsPanelProps) {
if (!savings) return null;
// Use max savings across providers as the headline
const maxSavings = savings.per_provider.reduce(
(max, p) => (p.total_cost > max ? p.total_cost : max),
0,
);
// Average energy/flops across providers
const avgJoules = savings.per_provider.reduce(
(sum, p) => sum + p.energy_joules,
0,
) / (savings.per_provider.length || 1);
const avgFlops = savings.per_provider.reduce(
(sum, p) => sum + p.flops,
0,
) / (savings.per_provider.length || 1);
// Cloud model labels for comparison
const cloudModels = savings.per_provider.map((p) => p.label);
return (
<div className="savings-panel">
<h3>Savings vs Cloud</h3>
<div className="savings-models">
<div className="savings-model-card local">
<span className="savings-model-card-label">LOCAL</span>
<span className="savings-model-card-name">{localModel || 'local model'}</span>
</div>
<div className="savings-model-card cloud">
<span className="savings-model-card-label">CLOUD</span>
<span className="savings-model-card-name">{cloudModels.join(', ')}</span>
</div>
</div>
<div className="savings-grid">
<div className="savings-item calls">
<div className="savings-label">Requests</div>
<div className="savings-value">{savings.total_calls.toLocaleString()}</div>
</div>
<div className="savings-item">
<div className="savings-label">$ Saved</div>
<div className="savings-value">{formatDollars(maxSavings)}</div>
</div>
<div className="savings-item">
<div className="savings-label">Energy</div>
<div className="savings-value">{formatJoules(avgJoules)}</div>
</div>
<div className="savings-item">
<div className="savings-label">FLOPs</div>
<div className="savings-value">{formatFlops(avgFlops)}</div>
</div>
</div>
</div>
);
}
+157 -48
View File
@@ -1,55 +1,164 @@
import type { Conversation, ModelInfo, SavingsData } from '../../types';
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import {
MessageSquare,
Plus,
BarChart3,
Settings,
Search,
PanelLeftClose,
PanelLeft,
Cpu,
} from 'lucide-react';
import { ConversationList } from './ConversationList';
import { ModelSelector } from './ModelSelector';
import { SavingsPanel } from './SavingsPanel';
import { useAppStore } from '../../lib/store';
interface SidebarProps {
isOpen: boolean;
conversations: Conversation[];
activeId: string | null;
models: ModelInfo[];
selectedModel: string;
savings: SavingsData | null;
localModel?: string;
onSelectModel: (model: string) => void;
onNewChat: () => void;
onSelectConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
}
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const [searchQuery, setSearchQuery] = useState('');
const sidebarOpen = useAppStore((s) => s.sidebarOpen);
const toggleSidebar = useAppStore((s) => s.toggleSidebar);
const createConversation = useAppStore((s) => s.createConversation);
const selectedModel = useAppStore((s) => s.selectedModel);
const serverInfo = useAppStore((s) => s.serverInfo);
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const handleNewChat = () => {
createConversation(selectedModel);
navigate('/');
};
const navItems = [
{ path: '/', icon: MessageSquare, label: 'Chat' },
{ path: '/dashboard', icon: BarChart3, label: 'Dashboard' },
{ path: '/settings', icon: Settings, label: 'Settings' },
];
export function Sidebar({
isOpen,
conversations,
activeId,
models,
selectedModel,
savings,
localModel,
onSelectModel,
onNewChat,
onSelectConversation,
onDeleteConversation,
}: SidebarProps) {
return (
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<h1>OpenJarvis</h1>
<button className="new-chat-btn" onClick={onNewChat}>
+ New Chat
<>
{/* Collapse button when sidebar is closed */}
{!sidebarOpen && (
<button
onClick={toggleSidebar}
className="fixed top-3 left-3 z-30 p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--color-text-secondary)', background: 'var(--color-bg-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
>
<PanelLeft size={18} />
</button>
</div>
<ModelSelector
models={models}
selected={selectedModel}
onSelect={onSelectModel}
/>
<ConversationList
conversations={conversations}
activeId={activeId}
onSelect={onSelectConversation}
onDelete={onDeleteConversation}
/>
<SavingsPanel savings={savings} localModel={localModel} />
</aside>
)}
<aside
className={`
flex flex-col h-full shrink-0 transition-all duration-200 ease-in-out overflow-hidden
fixed md:relative z-30
${sidebarOpen ? 'w-[260px]' : 'w-0'}
`}
style={{ background: 'var(--color-sidebar)', borderRight: sidebarOpen ? '1px solid var(--color-border)' : 'none' }}
>
<div className="flex flex-col h-full w-[260px]">
{/* Header */}
<div className="flex items-center justify-between px-3 pt-3 pb-2">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<PanelLeftClose size={18} />
</button>
<button
onClick={handleNewChat}
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
title="New chat"
>
<Plus size={18} />
</button>
</div>
{/* Model badge */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="mx-3 mb-2 flex items-center gap-2 px-3 py-2 rounded-lg text-xs transition-colors cursor-pointer"
style={{
background: 'var(--color-bg-secondary)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
>
<Cpu size={14} />
<span className="truncate flex-1 text-left" style={{ color: 'var(--color-text)' }}>
{selectedModel || serverInfo?.model || 'Select model'}
</span>
<kbd
className="text-[10px] px-1.5 py-0.5 rounded font-mono"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
>
K
</kbd>
</button>
{/* Search */}
<div className="px-3 mb-2">
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<Search size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<input
type="text"
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
style={{ color: 'var(--color-text)' }}
/>
</div>
</div>
{/* Conversation list */}
<div className="flex-1 overflow-y-auto px-2">
<ConversationList searchQuery={searchQuery} />
</div>
{/* Bottom nav */}
<nav className="px-2 pb-3 pt-2 flex flex-col gap-0.5" style={{ borderTop: '1px solid var(--color-border)' }}>
{navItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left cursor-pointer"
style={{
background: isActive ? 'var(--color-bg-tertiary)' : 'transparent',
color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontWeight: isActive ? 500 : 400,
}}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)';
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<item.icon size={16} />
{item.label}
</button>
);
})}
</nav>
</div>
</aside>
</>
);
}
-248
View File
@@ -1,248 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import type { ChatMessage, StreamState, ToolCallInfo, TokenUsage } from '../types';
import { streamChat } from '../api/sse';
import * as storage from '../storage/conversations';
const INITIAL_STREAM_STATE: StreamState = {
isStreaming: false,
phase: '',
elapsedMs: 0,
activeToolCalls: [],
content: '',
};
export function useChat(conversationId: string | null, model: string) {
const [streamState, setStreamState] = useState<StreamState>(INITIAL_STREAM_STATE);
const [messages, setMessages] = useState<ChatMessage[]>(() => {
if (!conversationId) return [];
const conv = storage.getConversation(conversationId);
return conv?.messages || [];
});
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
// Reload messages when conversation changes
const reloadMessages = useCallback(() => {
if (!conversationId) {
setMessages([]);
return;
}
const conv = storage.getConversation(conversationId);
setMessages(conv?.messages || []);
}, [conversationId]);
const stopStreaming = useCallback(() => {
abortRef.current?.abort();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setStreamState(INITIAL_STREAM_STATE);
}, []);
const sendMessage = useCallback(
async (content: string) => {
if (!conversationId || !content.trim()) return;
// Add user message
const userMsg: ChatMessage = {
id: storage.generateMessageId(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
};
storage.addMessage(conversationId, userMsg);
// Build API messages BEFORE adding the assistant placeholder,
// so the placeholder's empty content isn't sent to the backend.
const conv = storage.getConversation(conversationId);
const apiMessages = (conv?.messages || []).map((m) => ({
role: m.role,
content: m.content,
}));
// Add placeholder assistant message (after building apiMessages)
const assistantMsg: ChatMessage = {
id: storage.generateMessageId(),
role: 'assistant',
content: '',
timestamp: Date.now(),
};
storage.addMessage(conversationId, assistantMsg);
// Update local state
setMessages((prev) => [...prev, userMsg, assistantMsg]);
// Start timer
startTimeRef.current = Date.now();
const timer = setInterval(() => {
setStreamState((s) => ({
...s,
elapsedMs: Date.now() - startTimeRef.current,
}));
}, 100);
timerRef.current = timer;
const controller = new AbortController();
abortRef.current = controller;
let accumulatedContent = '';
let usage: TokenUsage | undefined;
const toolCalls: ToolCallInfo[] = [];
setStreamState({
isStreaming: true,
phase: 'Sending request...',
elapsedMs: 0,
activeToolCalls: [],
content: '',
});
try {
for await (const sseEvent of streamChat(
{ model, messages: apiMessages, stream: true },
controller.signal,
)) {
const eventName = sseEvent.event;
if (eventName === 'agent_turn_start') {
setStreamState((s) => ({ ...s, phase: 'Agent thinking...' }));
} else if (eventName === 'inference_start') {
setStreamState((s) => ({ ...s, phase: 'Generating response...' }));
} else if (eventName === 'inference_end') {
// Just update phase
} else if (eventName === 'tool_call_start') {
try {
const data = JSON.parse(sseEvent.data);
const tc: ToolCallInfo = {
id: storage.generateMessageId(),
tool: data.tool,
arguments: data.arguments || '',
status: 'running',
};
toolCalls.push(tc);
setStreamState((s) => ({
...s,
phase: `Running ${data.tool}...`,
activeToolCalls: [...toolCalls],
}));
// Update message with live tool call progress
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === 'assistant') {
updated[updated.length - 1] = {
...last,
toolCalls: [...toolCalls],
};
}
return updated;
});
} catch {}
} else if (eventName === 'tool_call_end') {
try {
const data = JSON.parse(sseEvent.data);
const tc = toolCalls.find((t) => t.tool === data.tool && t.status === 'running');
if (tc) {
tc.status = data.success ? 'success' : 'error';
tc.latency = data.latency;
tc.result = data.result;
}
setStreamState((s) => ({
...s,
phase: 'Generating response...',
activeToolCalls: [...toolCalls],
}));
// Update message with completed tool call
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === 'assistant') {
updated[updated.length - 1] = {
...last,
toolCalls: [...toolCalls],
};
}
return updated;
});
} catch {}
} else {
// Content chunk (no event name or event: content)
try {
const data = JSON.parse(sseEvent.data);
const delta = data.choices?.[0]?.delta;
if (data.usage) {
usage = data.usage;
}
if (delta?.content) {
accumulatedContent += delta.content;
setStreamState((s) => ({
...s,
content: accumulatedContent,
}));
// Update messages in state
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === 'assistant') {
updated[updated.length - 1] = {
...last,
content: accumulatedContent,
toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined,
};
}
return updated;
});
}
if (data.choices?.[0]?.finish_reason === 'stop') break;
} catch {}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
accumulatedContent = accumulatedContent || 'Error: Failed to get response.';
}
} finally {
// Show a message if streaming completed with no content
if (!accumulatedContent) {
accumulatedContent = 'No response was generated. Please try again.';
}
// Save final state
storage.updateLastAssistantMessage(
conversationId,
accumulatedContent,
toolCalls.length > 0 ? toolCalls : undefined,
usage,
);
// Update local messages with usage
if (usage) {
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === 'assistant') {
updated[updated.length - 1] = { ...last, usage };
}
return updated;
});
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setStreamState(INITIAL_STREAM_STATE);
abortRef.current = null;
}
},
[conversationId, model],
);
return {
messages,
streamState,
sendMessage,
stopStreaming,
reloadMessages,
};
}
-49
View File
@@ -1,49 +0,0 @@
import { useState, useCallback } from 'react';
import type { Conversation } from '../types';
import * as storage from '../storage/conversations';
export function useConversations() {
const [conversations, setConversations] = useState<Conversation[]>(
storage.getConversations,
);
const [activeId, setActiveIdState] = useState<string | null>(
storage.getActiveId,
);
const reload = useCallback(() => {
setConversations(storage.getConversations());
setActiveIdState(storage.getActiveId());
}, []);
const createConversation = useCallback((model: string) => {
const conv = storage.createConversation(model);
setConversations(storage.getConversations());
setActiveIdState(conv.id);
return conv;
}, []);
const selectConversation = useCallback((id: string) => {
storage.setActiveId(id);
setActiveIdState(id);
}, []);
const removeConversation = useCallback((id: string) => {
storage.deleteConversation(id);
setConversations(storage.getConversations());
setActiveIdState(storage.getActiveId());
}, []);
const activeConversation = activeId
? storage.getConversation(activeId)
: null;
return {
conversations,
activeId,
activeConversation,
createConversation,
selectConversation,
removeConversation,
reload,
};
}
-17
View File
@@ -1,17 +0,0 @@
import { useState, useEffect } from 'react';
import type { ModelInfo } from '../types';
import { fetchModels } from '../api/client';
export function useModels() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchModels()
.then(setModels)
.catch(() => setModels([]))
.finally(() => setLoading(false));
}, []);
return { models, loading };
}
-21
View File
@@ -1,21 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import type { SavingsData } from '../types';
import { fetchSavings } from '../api/client';
export function useSavings() {
const [savings, setSavings] = useState<SavingsData | null>(null);
const refresh = useCallback(() => {
fetchSavings()
.then(setSavings)
.catch(() => {});
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 30000);
return () => clearInterval(interval);
}, [refresh]);
return { savings, refresh };
}
-15
View File
@@ -1,15 +0,0 @@
import { useState, useEffect } from 'react';
import type { ServerInfo } from '../types';
import { fetchServerInfo } from '../api/client';
export function useServerInfo() {
const [info, setInfo] = useState<ServerInfo | null>(null);
useEffect(() => {
fetchServerInfo()
.then(setInfo)
.catch(() => {});
}, []);
return info;
}
+327
View File
@@ -0,0 +1,327 @@
@import "tailwindcss";
/*
* OpenJarvis design tokens.
* Zinc/slate neutrals, blue accent. Light default, dark via class or system pref.
*/
@layer base {
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f4f4f5;
--color-bg-tertiary: #e4e4e7;
--color-surface: #ffffff;
--color-sidebar: #fafafa;
--color-border: #e4e4e7;
--color-border-subtle: #f4f4f5;
--color-text: #09090b;
--color-text-secondary: #71717a;
--color-text-tertiary: #a1a1aa;
--color-text-inverse: #fafafa;
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-accent-subtle: #eff6ff;
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
--color-code-bg: #f4f4f5;
--color-input-bg: #ffffff;
--color-input-border: #d4d4d8;
--color-user-bubble: #2563eb;
--color-user-bubble-text: #ffffff;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
--sidebar-width: 260px;
--chat-max-width: 720px;
color-scheme: light;
}
.dark {
--color-bg: #09090b;
--color-bg-secondary: #18181b;
--color-bg-tertiary: #27272a;
--color-surface: #18181b;
--color-sidebar: #0f0f12;
--color-border: #27272a;
--color-border-subtle: #1e1e22;
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #71717a;
--color-text-inverse: #09090b;
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-subtle: #172554;
--color-success: #22c55e;
--color-warning: #eab308;
--color-error: #ef4444;
--color-code-bg: #1e1e22;
--color-input-bg: #18181b;
--color-input-border: #3f3f46;
--color-user-bubble: #3b82f6;
--color-user-bubble-text: #ffffff;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--color-bg: #09090b;
--color-bg-secondary: #18181b;
--color-bg-tertiary: #27272a;
--color-surface: #18181b;
--color-sidebar: #0f0f12;
--color-border: #27272a;
--color-border-subtle: #1e1e22;
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #71717a;
--color-text-inverse: #09090b;
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-subtle: #172554;
--color-success: #22c55e;
--color-warning: #eab308;
--color-error: #ef4444;
--color-code-bg: #1e1e22;
--color-input-bg: #18181b;
--color-input-border: #3f3f46;
--color-user-bubble: #3b82f6;
--color-user-bubble-text: #ffffff;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
color-scheme: dark;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: var(--color-accent);
color: white;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
}
/* Syntax highlighting theme (highlight.js) */
@layer components {
.hljs {
background: var(--color-code-bg) !important;
color: var(--color-text) !important;
border-radius: var(--radius-md);
padding: 1rem !important;
font-size: 0.8125rem;
line-height: 1.6;
overflow-x: auto;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-section,
.hljs-link { color: #7c3aed; }
.dark .hljs-keyword,
.dark .hljs-selector-tag,
.dark .hljs-literal,
.dark .hljs-section,
.dark .hljs-link { color: #a78bfa; }
.hljs-string,
.hljs-title,
.hljs-name,
.hljs-type,
.hljs-attribute,
.hljs-symbol,
.hljs-bullet,
.hljs-addition,
.hljs-variable,
.hljs-template-tag,
.hljs-template-variable { color: #059669; }
.dark .hljs-string,
.dark .hljs-title,
.dark .hljs-name,
.dark .hljs-type,
.dark .hljs-attribute,
.dark .hljs-symbol,
.dark .hljs-bullet,
.dark .hljs-addition,
.dark .hljs-variable,
.dark .hljs-template-tag,
.dark .hljs-template-variable { color: #34d399; }
.hljs-comment,
.hljs-quote,
.hljs-deletion,
.hljs-meta { color: #a1a1aa; }
.hljs-number,
.hljs-regexp,
.hljs-built_in,
.hljs-params { color: #d97706; }
.dark .hljs-number,
.dark .hljs-regexp,
.dark .hljs-built_in,
.dark .hljs-params { color: #fbbf24; }
.hljs-function { color: #2563eb; }
.dark .hljs-function { color: #60a5fa; }
/* Markdown prose styling */
.prose {
line-height: 1.65;
font-size: 0.9375rem;
}
.prose p {
margin-bottom: 0.75em;
}
.prose p:last-child {
margin-bottom: 0;
}
.prose code:not(pre code) {
background: var(--color-code-bg);
padding: 0.15em 0.35em;
border-radius: var(--radius-sm);
font-size: 0.85em;
font-family: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
}
.prose pre {
margin: 0.75em 0;
border-radius: var(--radius-md);
overflow: hidden;
position: relative;
}
.prose pre code {
font-family: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
}
.prose ul, .prose ol {
padding-left: 1.5em;
margin-bottom: 0.75em;
}
.prose li {
margin-bottom: 0.25em;
}
.prose blockquote {
border-left: 3px solid var(--color-accent);
padding-left: 1em;
margin: 0.75em 0;
color: var(--color-text-secondary);
}
.prose h1, .prose h2, .prose h3, .prose h4 {
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
.prose h1 { font-size: 1.375em; }
.prose h2 { font-size: 1.2em; }
.prose h3 { font-size: 1.075em; }
.prose table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.875em;
}
.prose th, .prose td {
border: 1px solid var(--color-border);
padding: 0.5em 0.75em;
text-align: left;
}
.prose th {
background: var(--color-bg-secondary);
font-weight: 600;
}
.prose a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose a:hover {
color: var(--color-accent-hover);
}
.prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1em 0;
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
}
const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
const getBase = () => import.meta.env.VITE_API_URL || '';
async function tauriInvoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
const { invoke } = await import('@tauri-apps/api/core');
const apiUrl = getBase() || 'http://localhost:8000';
return invoke<T>(command, { apiUrl, ...args });
}
export async function fetchModels(): Promise<ModelInfo[]> {
if (isTauri()) {
try {
const result = await tauriInvoke<{ data?: ModelInfo[] }>('fetch_models');
return result?.data || [];
} catch {
// Fall through to fetch
}
}
const res = await fetch(`${getBase()}/v1/models`);
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
const data = await res.json();
return data.data || [];
}
export async function fetchSavings(): Promise<SavingsData> {
const res = await fetch(`${getBase()}/v1/savings`);
if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
return res.json();
}
export async function fetchServerInfo(): Promise<ServerInfo> {
const res = await fetch(`${getBase()}/v1/info`);
if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
return res.json();
}
export async function checkHealth(): Promise<boolean> {
if (isTauri()) {
try {
const apiUrl = getBase() || 'http://localhost:8000';
await tauriInvoke('check_health', { apiUrl });
return true;
} catch {
return false;
}
}
try {
const res = await fetch(`${getBase()}/health`);
return res.ok;
} catch {
return false;
}
}
export async function fetchEnergy(): Promise<unknown> {
if (isTauri()) {
try {
const apiUrl = getBase() || 'http://localhost:8000';
return await tauriInvoke('fetch_energy', { apiUrl });
} catch {}
}
const res = await fetch(`${getBase()}/v1/telemetry/energy`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
export async function fetchTelemetry(): Promise<unknown> {
if (isTauri()) {
try {
const apiUrl = getBase() || 'http://localhost:8000';
return await tauriInvoke('fetch_telemetry', { apiUrl });
} catch {}
}
const res = await fetch(`${getBase()}/v1/telemetry/stats`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
export async function fetchTraces(limit: number = 50): Promise<unknown> {
if (isTauri()) {
try {
const apiUrl = getBase() || 'http://localhost:8000';
return await tauriInvoke('fetch_traces', { apiUrl, limit });
} catch {}
}
const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
@@ -10,7 +10,8 @@ export async function* streamChat(
request: ChatRequest,
signal?: AbortSignal,
): AsyncGenerator<SSEEvent> {
const response = await fetch('/v1/chat/completions', {
const base = import.meta.env.VITE_API_URL || '';
const response = await fetch(`${base}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
@@ -41,9 +42,7 @@ export async function* streamChat(
currentEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
return;
}
if (data === '[DONE]') return;
yield { event: currentEvent, data };
currentEvent = undefined;
} else if (line.trim() === '') {
+319
View File
@@ -0,0 +1,319 @@
import { create } from 'zustand';
import type {
Conversation,
ChatMessage,
ModelInfo,
SavingsData,
ServerInfo,
StreamState,
ToolCallInfo,
TokenUsage,
} from '../types';
// ── localStorage persistence ──────────────────────────────────────────
const CONVERSATIONS_KEY = 'openjarvis-conversations';
const SETTINGS_KEY = 'openjarvis-settings';
interface ConversationStore {
version: 1;
conversations: Record<string, Conversation>;
activeId: string | null;
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function loadConversations(): ConversationStore {
try {
const raw = localStorage.getItem(CONVERSATIONS_KEY);
if (!raw) return { version: 1, conversations: {}, activeId: null };
const parsed = JSON.parse(raw);
if (parsed.version === 1) return parsed;
return { version: 1, conversations: {}, activeId: null };
} catch {
return { version: 1, conversations: {}, activeId: null };
}
}
function saveConversations(store: ConversationStore): void {
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(store));
}
export type ThemeMode = 'light' | 'dark' | 'system';
interface Settings {
theme: ThemeMode;
apiUrl: string;
fontSize: 'small' | 'default' | 'large';
defaultModel: string;
defaultAgent: string;
temperature: number;
maxTokens: number;
}
function loadSettings(): Settings {
const defaults: Settings = {
theme: 'system',
apiUrl: '',
fontSize: 'default',
defaultModel: '',
defaultAgent: '',
temperature: 0.7,
maxTokens: 4096,
};
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return defaults;
return { ...defaults, ...JSON.parse(raw) };
} catch {
return defaults;
}
}
function saveSettings(settings: Settings): void {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
// ── Store ─────────────────────────────────────────────────────────────
const INITIAL_STREAM: StreamState = {
isStreaming: false,
phase: '',
elapsedMs: 0,
activeToolCalls: [],
content: '',
};
interface AppState {
// Conversations
conversations: Conversation[];
activeId: string | null;
messages: ChatMessage[];
streamState: StreamState;
// Models & server
models: ModelInfo[];
modelsLoading: boolean;
selectedModel: string;
serverInfo: ServerInfo | null;
savings: SavingsData | null;
// Settings
settings: Settings;
// Command palette
commandPaletteOpen: boolean;
// Sidebar
sidebarOpen: boolean;
// Actions: conversations
loadConversations: () => void;
createConversation: (model?: string) => string;
selectConversation: (id: string) => void;
deleteConversation: (id: string) => void;
loadMessages: (conversationId: string | null) => void;
addMessage: (conversationId: string, message: ChatMessage) => void;
updateLastAssistant: (
conversationId: string,
content: string,
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
) => void;
setStreamState: (state: Partial<StreamState>) => void;
resetStream: () => void;
// Actions: models & server
setModels: (models: ModelInfo[]) => void;
setModelsLoading: (loading: boolean) => void;
setSelectedModel: (model: string) => void;
setServerInfo: (info: ServerInfo | null) => void;
setSavings: (data: SavingsData | null) => void;
// Actions: settings
updateSettings: (partial: Partial<Settings>) => void;
// Actions: UI
setCommandPaletteOpen: (open: boolean) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
}
export const useAppStore = create<AppState>((set, get) => {
const initial = loadConversations();
const convList = Object.values(initial.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
);
return {
conversations: convList,
activeId: initial.activeId,
messages:
initial.activeId && initial.conversations[initial.activeId]
? initial.conversations[initial.activeId].messages
: [],
streamState: INITIAL_STREAM,
models: [],
modelsLoading: true,
selectedModel: '',
serverInfo: null,
savings: null,
settings: loadSettings(),
commandPaletteOpen: false,
sidebarOpen: true,
// ── Conversations ───────────────────────────────────────────────
loadConversations: () => {
const store = loadConversations();
set({
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
activeId: store.activeId,
});
},
createConversation: (model?: string) => {
const store = loadConversations();
const conv: Conversation = {
id: generateId(),
title: 'New chat',
createdAt: Date.now(),
updatedAt: Date.now(),
model: model || get().selectedModel || 'default',
messages: [],
};
store.conversations[conv.id] = conv;
store.activeId = conv.id;
saveConversations(store);
set({
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
activeId: conv.id,
messages: [],
});
return conv.id;
},
selectConversation: (id: string) => {
const store = loadConversations();
store.activeId = id;
saveConversations(store);
const conv = store.conversations[id];
set({
activeId: id,
messages: conv ? conv.messages : [],
});
},
deleteConversation: (id: string) => {
const store = loadConversations();
delete store.conversations[id];
if (store.activeId === id) {
const remaining = Object.keys(store.conversations);
store.activeId = remaining.length > 0 ? remaining[0] : null;
}
saveConversations(store);
const convList = Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
);
const activeConv = store.activeId
? store.conversations[store.activeId]
: null;
set({
conversations: convList,
activeId: store.activeId,
messages: activeConv ? activeConv.messages : [],
});
},
loadMessages: (conversationId: string | null) => {
if (!conversationId) {
set({ messages: [] });
return;
}
const store = loadConversations();
const conv = store.conversations[conversationId];
set({ messages: conv ? conv.messages : [] });
},
addMessage: (conversationId: string, message: ChatMessage) => {
const store = loadConversations();
const conv = store.conversations[conversationId];
if (!conv) return;
conv.messages.push(message);
conv.updatedAt = Date.now();
if (message.role === 'user' && conv.title === 'New chat') {
conv.title =
message.content.slice(0, 50) +
(message.content.length > 50 ? '...' : '');
}
saveConversations(store);
set({
messages: [...conv.messages],
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
});
},
updateLastAssistant: (
conversationId: string,
content: string,
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
) => {
const store = loadConversations();
const conv = store.conversations[conversationId];
if (!conv) return;
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.content = content;
if (toolCalls) lastMsg.toolCalls = toolCalls;
if (usage) lastMsg.usage = usage;
conv.updatedAt = Date.now();
saveConversations(store);
set({ messages: [...conv.messages] });
}
},
setStreamState: (partial: Partial<StreamState>) => {
set((s) => ({ streamState: { ...s.streamState, ...partial } }));
},
resetStream: () => {
set({ streamState: INITIAL_STREAM });
},
// ── Models & server ────────────────────────────────────────────
setModels: (models: ModelInfo[]) => set({ models }),
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
setSelectedModel: (model: string) => set({ selectedModel: model }),
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
setSavings: (data: SavingsData | null) => set({ savings: data }),
// ── Settings ───────────────────────────────────────────────────
updateSettings: (partial: Partial<Settings>) => {
const updated = { ...get().settings, ...partial };
saveSettings(updated);
set({ settings: updated });
},
// ── UI ──────────────────────────────────────────────────────────
setCommandPaletteOpen: (open: boolean) => set({ commandPaletteOpen: open }),
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
};
});
export { generateId };
+22 -6
View File
@@ -1,17 +1,33 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { ErrorBoundary } from './components/ErrorBoundary';
import App from './App';
import './styles/variables.css';
import './styles/base.css';
import './styles/sidebar.css';
import './styles/chat.css';
import './styles/input.css';
import './index.css';
function applyTheme() {
try {
const raw = localStorage.getItem('openjarvis-settings');
const settings = raw ? JSON.parse(raw) : {};
const theme = settings.theme || 'system';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
} else if (theme === 'light') {
document.documentElement.classList.add('light');
document.documentElement.classList.remove('dark');
}
} catch { /* use system default */ }
}
applyTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
</StrictMode>,
);
+5
View File
@@ -0,0 +1,5 @@
import { ChatArea } from '../components/Chat/ChatArea';
export function ChatPage() {
return <ChatArea />;
}
+26
View File
@@ -0,0 +1,26 @@
import { BarChart3 } from 'lucide-react';
import { EnergyDashboard } from '../components/Dashboard/EnergyDashboard';
import { CostComparison } from '../components/Dashboard/CostComparison';
import { TraceDebugger } from '../components/Dashboard/TraceDebugger';
export function DashboardPage() {
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-5xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<BarChart3 size={24} style={{ color: 'var(--color-accent)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--color-text)' }}>
Dashboard
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<EnergyDashboard />
<CostComparison />
</div>
<TraceDebugger />
</div>
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { useState, useEffect } from 'react';
import {
Settings,
Palette,
Globe,
Cpu,
Database,
Info,
Check,
Sun,
Moon,
Monitor,
Download,
Upload,
Trash2,
} from 'lucide-react';
import { useAppStore, type ThemeMode } from '../lib/store';
import { checkHealth } from '../lib/api';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div
className="rounded-xl p-5"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text)' }}>
{title}
</h3>
{children}
</div>
);
}
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-3" style={{ borderBottom: '1px solid var(--color-border-subtle)' }}>
<div>
<div className="text-sm" style={{ color: 'var(--color-text)' }}>{label}</div>
{description && (
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{description}</div>
)}
</div>
<div>{children}</div>
</div>
);
}
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
];
export function SettingsPage() {
const settings = useAppStore((s) => s.settings);
const updateSettings = useAppStore((s) => s.updateSettings);
const conversations = useAppStore((s) => s.conversations);
const serverInfo = useAppStore((s) => s.serverInfo);
const [healthy, setHealthy] = useState<boolean | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
checkHealth().then(setHealthy);
}, []);
const showSaved = () => {
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
const handleExport = () => {
const data = localStorage.getItem('openjarvis-conversations') || '{}';
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `openjarvis-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target?.result as string);
if (data.version === 1) {
localStorage.setItem('openjarvis-conversations', JSON.stringify(data));
useAppStore.getState().loadConversations();
showSaved();
}
} catch {}
};
reader.readAsText(file);
};
input.click();
};
const handleClear = () => {
if (confirm('Delete all conversations? This cannot be undone.')) {
localStorage.removeItem('openjarvis-conversations');
useAppStore.getState().loadConversations();
}
};
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Settings size={24} style={{ color: 'var(--color-accent)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--color-text)' }}>
Settings
</h1>
{saved && (
<span className="flex items-center gap-1 text-xs px-2 py-1 rounded-full" style={{
background: 'var(--color-accent-subtle)',
color: 'var(--color-success)',
}}>
<Check size={12} /> Saved
</span>
)}
</div>
<div className="flex flex-col gap-4">
{/* Appearance */}
<Section title="Appearance">
<SettingRow label="Theme" description="Choose how OpenJarvis looks">
<div className="flex gap-1 p-0.5 rounded-lg" style={{ background: 'var(--color-bg-secondary)' }}>
{themeOptions.map((opt) => {
const isActive = settings.theme === opt.value;
return (
<button
key={opt.value}
onClick={() => { updateSettings({ theme: opt.value }); showSaved(); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer"
style={{
background: isActive ? 'var(--color-surface)' : 'transparent',
color: isActive ? 'var(--color-text)' : 'var(--color-text-tertiary)',
boxShadow: isActive ? 'var(--shadow-sm)' : 'none',
}}
>
<opt.icon size={14} />
{opt.label}
</button>
);
})}
</div>
</SettingRow>
<SettingRow label="Font size">
<select
value={settings.fontSize}
onChange={(e) => { updateSettings({ fontSize: e.target.value as any }); showSaved(); }}
className="text-sm px-3 py-1.5 rounded-lg outline-none cursor-pointer"
style={{
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
<option value="small">Small</option>
<option value="default">Default</option>
<option value="large">Large</option>
</select>
</SettingRow>
</Section>
{/* Connection */}
<Section title="Connection">
<SettingRow label="Server status" description={serverInfo ? `${serverInfo.engine} / ${serverInfo.model}` : 'Not connected'}>
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ background: healthy === true ? 'var(--color-success)' : healthy === false ? 'var(--color-error)' : 'var(--color-text-tertiary)' }}
/>
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{healthy === true ? 'Connected' : healthy === false ? 'Disconnected' : 'Checking...'}
</span>
</div>
</SettingRow>
<SettingRow label="API URL" description="Leave empty for same-origin">
<input
type="text"
value={settings.apiUrl}
onChange={(e) => { updateSettings({ apiUrl: e.target.value }); showSaved(); }}
placeholder="http://localhost:8000"
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
style={{
background: 'var(--color-bg-secondary)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
/>
</SettingRow>
</Section>
{/* Model defaults */}
<Section title="Model Defaults">
<SettingRow label="Temperature" description={`${settings.temperature}`}>
<input
type="range"
min="0"
max="2"
step="0.1"
value={settings.temperature}
onChange={(e) => { updateSettings({ temperature: parseFloat(e.target.value) }); showSaved(); }}
className="w-32 cursor-pointer accent-[var(--color-accent)]"
/>
</SettingRow>
<SettingRow label="Max tokens" description={`${settings.maxTokens}`}>
<input
type="range"
min="256"
max="32768"
step="256"
value={settings.maxTokens}
onChange={(e) => { updateSettings({ maxTokens: parseInt(e.target.value) }); showSaved(); }}
className="w-32 cursor-pointer accent-[var(--color-accent)]"
/>
</SettingRow>
</Section>
{/* Data */}
<Section title="Data">
<SettingRow label="Conversations" description={`${conversations.length} stored locally`}>
<div className="flex gap-2">
<button
onClick={handleExport}
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-bg-secondary)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
>
<Download size={12} /> Export
</button>
<button
onClick={handleImport}
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-bg-secondary)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
>
<Upload size={12} /> Import
</button>
</div>
</SettingRow>
<SettingRow label="Clear all data" description="Permanently delete all conversations">
<button
onClick={handleClear}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(220,38,38,0.1)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Trash2 size={12} /> Clear
</button>
</SettingRow>
</Section>
{/* About */}
<Section title="About">
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
<p className="mb-2">
<span className="font-semibold" style={{ color: 'var(--color-text)' }}>OpenJarvis</span> Programming abstractions for on-device AI.
</p>
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Part of Intelligence Per Watt, a research initiative at Stanford SAIL.
</p>
<div className="flex gap-3 mt-3 text-xs">
<a
href="https://www.intelligence-per-watt.ai/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-accent)' }}
>
Project site
</a>
<a
href="https://hazyresearch.stanford.edu/OpenJarvis/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-accent)' }}
>
Documentation
</a>
</div>
</div>
</Section>
</div>
</div>
</div>
);
}
-110
View File
@@ -1,110 +0,0 @@
import type { Conversation, ConversationStore, ChatMessage } from '../types';
const STORAGE_KEY = 'openjarvis-conversations';
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function loadStore(): ConversationStore {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { version: 1, conversations: {}, activeId: null };
const parsed = JSON.parse(raw);
if (parsed.version === 1) return parsed;
return { version: 1, conversations: {}, activeId: null };
} catch {
return { version: 1, conversations: {}, activeId: null };
}
}
function saveStore(store: ConversationStore): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
export function getConversations(): Conversation[] {
const store = loadStore();
return Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
);
}
export function getActiveId(): string | null {
return loadStore().activeId;
}
export function setActiveId(id: string | null): void {
const store = loadStore();
store.activeId = id;
saveStore(store);
}
export function getConversation(id: string): Conversation | null {
const store = loadStore();
return store.conversations[id] || null;
}
export function createConversation(model: string): Conversation {
const store = loadStore();
const conv: Conversation = {
id: generateId(),
title: 'New chat',
createdAt: Date.now(),
updatedAt: Date.now(),
model,
messages: [],
};
store.conversations[conv.id] = conv;
store.activeId = conv.id;
saveStore(store);
return conv;
}
export function addMessage(
conversationId: string,
message: ChatMessage,
): void {
const store = loadStore();
const conv = store.conversations[conversationId];
if (!conv) return;
conv.messages.push(message);
conv.updatedAt = Date.now();
// Update title from first user message
if (message.role === 'user' && conv.title === 'New chat') {
conv.title = message.content.slice(0, 50) + (message.content.length > 50 ? '...' : '');
}
saveStore(store);
}
export function updateLastAssistantMessage(
conversationId: string,
content: string,
toolCalls?: ChatMessage['toolCalls'],
usage?: ChatMessage['usage'],
): void {
const store = loadStore();
const conv = store.conversations[conversationId];
if (!conv) return;
const lastMsg = conv.messages[conv.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.content = content;
if (toolCalls) lastMsg.toolCalls = toolCalls;
if (usage) lastMsg.usage = usage;
conv.updatedAt = Date.now();
saveStore(store);
}
}
export function deleteConversation(id: string): void {
const store = loadStore();
delete store.conversations[id];
if (store.activeId === id) {
const remaining = Object.keys(store.conversations);
store.activeId = remaining.length > 0 ? remaining[0] : null;
}
saveStore(store);
}
export function generateMessageId(): string {
return generateId();
}
-64
View File
@@ -1,64 +0,0 @@
/* Reset & layout */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--color-bg);
color: var(--color-text);
height: 100vh;
overflow: hidden;
}
#root {
height: 100vh;
}
.app {
display: flex;
height: 100vh;
position: relative;
}
.sidebar-toggle {
display: none;
position: fixed;
top: 12px;
left: 12px;
z-index: 1000;
background: var(--color-primary);
color: var(--ctp-crust);
border: none;
border-radius: 8px;
width: 36px;
height: 36px;
font-size: 18px;
cursor: pointer;
align-items: center;
justify-content: center;
}
@media (max-width: 768px) {
.sidebar-toggle {
display: flex;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 999;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.message-bubble {
max-width: 90%;
}
}
-291
View File
@@ -1,291 +0,0 @@
/* Chat Area */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
min-width: 0;
}
.chat-header {
padding: 12px 24px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.chat-header-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-header-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.chat-header-info {
display: flex;
align-items: center;
gap: 6px;
}
.header-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-family: monospace;
}
.model-badge {
background: rgba(137, 180, 250, 0.12);
color: var(--ctp-blue);
border: 1px solid rgba(137, 180, 250, 0.25);
}
.agent-badge {
background: rgba(166, 227, 161, 0.12);
color: var(--ctp-green);
border: 1px solid rgba(166, 227, 161, 0.25);
}
.chat-header-tokens {
font-size: 11px;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Message List */
.message-list {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message-list-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-size: 15px;
}
/* Message Bubble */
.message-bubble {
max-width: 75%;
position: relative;
}
.message-bubble.user {
align-self: flex-end;
}
.message-bubble.assistant {
align-self: flex-start;
}
.message-content {
padding: 12px 16px;
border-radius: 16px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-bubble.user .message-content {
background: var(--color-user-bubble);
color: var(--ctp-crust);
border-bottom-right-radius: 4px;
}
.message-bubble.assistant .message-content {
background: var(--color-assistant-bubble);
color: var(--color-text);
border: 1px solid var(--ctp-surface1);
border-bottom-left-radius: 4px;
}
.message-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 0 4px;
}
.message-time {
font-size: 11px;
color: var(--color-text-secondary);
}
.message-tokens {
font-size: 10px;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.message-bubble.user .message-meta {
justify-content: flex-end;
}
/* Copy Button */
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: var(--color-text-secondary);
transition: opacity 0.15s;
}
.message-bubble:hover .copy-btn {
opacity: 1;
}
.copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.message-bubble.user .copy-btn {
color: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.1);
}
.message-bubble.user .copy-btn:hover {
background: rgba(0, 0, 0, 0.2);
}
/* Tool Call Indicator */
.tool-calls {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-call {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-tool-bg);
border: 1px solid var(--color-tool-border);
border-radius: 8px;
font-size: 13px;
}
.tool-call .tool-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tool-call .tool-status.running {
background: var(--color-tool-running);
animation: pulse 1.5s infinite;
}
.tool-call .tool-status.success {
background: var(--color-tool-success);
}
.tool-call .tool-status.error {
background: var(--color-tool-error);
}
.tool-call .tool-name {
font-weight: 600;
color: var(--color-text);
}
.tool-call .tool-latency {
margin-left: auto;
font-size: 11px;
color: var(--color-text-secondary);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Streaming Indicator */
.streaming-indicator {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
margin: 0 24px;
}
.streaming-tool-calls {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.streaming-progress-row {
display: flex;
align-items: center;
gap: 12px;
}
.streaming-bar-container {
flex: 1;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.streaming-bar {
height: 100%;
background: var(--color-primary);
border-radius: 2px;
animation: stream-progress 2s ease-in-out infinite;
}
@keyframes stream-progress {
0% { width: 0%; margin-left: 0%; }
50% { width: 40%; margin-left: 30%; }
100% { width: 0%; margin-left: 100%; }
}
.streaming-phase {
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
}
.streaming-elapsed {
font-size: 12px;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
-131
View File
@@ -1,131 +0,0 @@
/* Input Area */
.input-area {
padding: 16px 24px 24px;
border-top: 1px solid var(--color-border);
background: var(--color-bg);
}
.input-attachment-row {
margin-bottom: 8px;
}
.input-container {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-container textarea {
flex: 1;
resize: none;
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 12px 16px;
font-size: 14px;
font-family: inherit;
line-height: 1.5;
min-height: 72px;
max-height: 200px;
outline: none;
background: var(--ctp-surface0);
color: var(--color-text);
transition: border-color 0.15s;
}
.input-container textarea:focus {
border-color: var(--color-primary);
}
.input-container textarea::placeholder {
color: var(--ctp-overlay0);
}
.send-btn, .stop-btn {
padding: 10px 20px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.send-btn {
background: var(--color-primary);
color: var(--ctp-crust);
}
.send-btn:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stop-btn {
background: var(--color-tool-error);
color: var(--ctp-crust);
}
.stop-btn:hover {
background: var(--ctp-maroon);
}
/* Pasted text pill */
.pasted-pill {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--ctp-surface0);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px 14px;
color: var(--color-text-secondary);
font-size: 13px;
max-width: 100%;
}
.pasted-pill svg {
flex-shrink: 0;
opacity: 0.6;
}
.pasted-pill-text {
font-weight: 500;
color: var(--color-text);
}
.pasted-pill-size {
color: var(--color-text-secondary);
font-size: 12px;
}
.pasted-pill-action {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pasted-pill-action:hover {
background: var(--ctp-surface1);
color: var(--color-text);
}
.pasted-pill-remove {
font-size: 16px;
line-height: 1;
padding: 2px 6px;
}
.pasted-pill-remove:hover {
color: var(--color-tool-error);
border-color: var(--color-tool-error);
}
-217
View File
@@ -1,217 +0,0 @@
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--color-bg-sidebar);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
height: 100vh;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--color-border);
}
.sidebar-header h1 {
font-size: 18px;
font-weight: 700;
color: var(--color-primary);
margin-bottom: 12px;
}
.new-chat-btn {
width: 100%;
padding: 10px 16px;
background: var(--color-primary);
color: var(--ctp-crust);
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.new-chat-btn:hover {
background: var(--color-primary-dark);
}
/* Model Selector */
.model-selector {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.model-selector label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.model-selector select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--ctp-surface0);
font-size: 13px;
color: var(--color-text);
cursor: pointer;
}
/* Conversation List */
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conversation-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--color-text);
transition: background 0.15s;
margin-bottom: 2px;
}
.conversation-item:hover {
background: var(--ctp-surface0);
}
.conversation-item.active {
background: var(--color-primary);
color: var(--ctp-crust);
}
.conversation-item .conv-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item .conv-delete {
opacity: 0;
border: none;
background: none;
color: inherit;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 16px;
line-height: 1;
}
.conversation-item:hover .conv-delete {
opacity: 0.6;
}
.conversation-item .conv-delete:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.08);
}
.conversation-item.active .conv-delete:hover {
background: rgba(0, 0, 0, 0.2);
}
/* Savings Panel */
.savings-panel {
padding: 16px;
border-top: 1px solid var(--color-primary);
background: var(--ctp-crust);
}
.savings-panel h3 {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-primary);
margin-bottom: 8px;
}
.savings-models {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.savings-model-card {
background: var(--ctp-surface0);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px 10px;
border-left: 3px solid var(--color-border);
}
.savings-model-card.local {
border-left-color: var(--ctp-green);
}
.savings-model-card.cloud {
border-left-color: var(--ctp-yellow);
}
.savings-model-card-label {
display: block;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
margin-bottom: 2px;
}
.savings-model-card-name {
display: block;
font-family: monospace;
font-size: 11px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.savings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.savings-item {
background: var(--ctp-surface0);
border-radius: 8px;
padding: 10px;
border: 1px solid var(--color-border);
}
.savings-item .savings-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.savings-item .savings-value {
font-size: 18px;
font-weight: 800;
color: var(--color-savings);
font-variant-numeric: tabular-nums;
}
.savings-item.calls .savings-value {
color: var(--color-primary);
}
-50
View File
@@ -1,50 +0,0 @@
/* Catppuccin Mocha theme tokens */
:root {
/* Base palette */
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
/* Semantic tokens */
--color-primary: var(--ctp-blue);
--color-primary-light: var(--ctp-sapphire);
--color-primary-dark: var(--ctp-lavender);
--color-bg: var(--ctp-base);
--color-bg-sidebar: var(--ctp-mantle);
--color-bg-surface: var(--ctp-surface0);
--color-text: var(--ctp-text);
--color-text-secondary: var(--ctp-subtext0);
--color-border: var(--ctp-surface0);
--color-user-bubble: var(--ctp-blue);
--color-assistant-bubble: var(--ctp-surface0);
--color-tool-bg: rgba(137, 180, 250, 0.08);
--color-tool-border: var(--ctp-surface1);
--color-tool-success: var(--ctp-green);
--color-tool-running: var(--ctp-yellow);
--color-tool-error: var(--ctp-red);
--color-savings: var(--ctp-green);
--sidebar-width: 280px;
}
+16 -2
View File
@@ -1,18 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'OpenJarvis',
short_name: 'Jarvis',
description: 'On-device AI assistant',
theme_color: '#1a1a1e',
background_color: '#1a1a1e',
theme_color: '#09090b',
background_color: '#09090b',
display: 'standalone',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
@@ -22,12 +24,24 @@ export default defineConfig({
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
navigateFallbackDenylist: [/^\/v1\//, /^\/health/, /^\/dashboard/],
mode: 'development',
},
}),
],
build: {
outDir: '../src/openjarvis/server/static',
emptyOutDir: true,
minify: 'esbuild',
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
markdown: ['react-markdown', 'rehype-highlight', 'remark-gfm'],
charts: ['recharts'],
router: ['react-router'],
},
},
},
},
server: {
port: 5173,