Merge pull request #4 from HazyResearch/gabebo-ui

webapp + desktop app UI setup w/ working backend + pods
This commit is contained in:
Jon Saad-Falcon
2026-03-03 22:08:43 -08:00
committed by GitHub
104 changed files with 15226 additions and 15603 deletions
+20 -5
View File
@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- 'desktop/**'
- 'frontend/**'
- '.github/workflows/desktop.yml'
tags:
- 'desktop-v*'
@@ -12,6 +13,7 @@ on:
branches: [main]
paths:
- 'desktop/**'
- 'frontend/**'
- '.github/workflows/desktop.yml'
workflow_dispatch:
@@ -46,17 +48,17 @@ jobs:
node-version: '22'
- name: Install frontend dependencies
working-directory: frontend
run: npm install
- name: Install desktop dependencies
working-directory: desktop
run: npm install
- name: TypeScript type-check
working-directory: desktop
working-directory: frontend
run: npx tsc --noEmit
- name: Vite build
working-directory: desktop
run: npx vite build
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -65,6 +67,9 @@ jobs:
with:
workspaces: 'desktop/src-tauri -> target'
- name: Create frontend dist stub
run: mkdir -p frontend/dist && echo '<html><body></body></html>' > frontend/dist/index.html
- name: Cargo check
working-directory: desktop/src-tauri
run: cargo check
@@ -117,9 +122,18 @@ jobs:
node-version: '22'
- name: Install frontend dependencies
working-directory: frontend
run: npm install
- name: Install desktop dependencies
working-directory: desktop
run: npm install
- name: Download Ollama sidecar
shell: bash
run: |
cd desktop/scripts && chmod +x download-ollama.sh && ./download-ollama.sh
- name: Determine release info
id: release-info
shell: bash
@@ -163,6 +177,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
TAURI_CONFIG: '{"bundle":{"externalBin":["binaries/ollama"]}}'
with:
projectPath: desktop
tauriScript: npx tauri
+6
View File
@@ -60,3 +60,9 @@ desktop/src-tauri/target/
# Worktrees
.worktrees/
# Claude plan artifacts
docs/plans/
# Tauri auto-generated schemas
**/src-tauri/gen/schemas/
+14
View File
@@ -1,12 +1,26 @@
# Stage 1: Build frontend SPA
FROM node:22-slim AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ src/
# Copy built frontend into the server static directory
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
# Stage 3: Runtime
FROM python:3.12-slim
COPY --from=builder /usr/local /usr/local
+13
View File
@@ -1,3 +1,13 @@
# Stage 1: Build frontend SPA
FROM node:22-slim AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
RUN apt-get update && \
@@ -8,9 +18,12 @@ WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ src/
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
# Stage 3: Runtime
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
RUN apt-get update && \
+13
View File
@@ -1,3 +1,13 @@
# Stage 1: Build frontend SPA
FROM node:22-slim AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package (AMD ROCm 6.2)
FROM rocm/dev-ubuntu-22.04:6.2 AS builder
RUN apt-get update && \
@@ -8,9 +18,12 @@ WORKDIR /app
COPY pyproject.toml README.md ./
COPY src/ src/
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
# Stage 3: Runtime
FROM rocm/dev-ubuntu-22.04:6.2
RUN apt-get update && \
+3 -3
View File
@@ -4,9 +4,9 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"dev": "npm --prefix ../frontend run dev",
"build": "npm --prefix ../frontend run build:tauri",
"preview": "npm --prefix ../frontend run preview",
"tauri": "tauri"
},
"dependencies": {
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# Download the Ollama binary for the target platform and place it
# in the Tauri binaries/ directory so it can be bundled as an
# externalBin sidecar.
#
# Usage:
# ./download-ollama.sh # auto-detect current platform
# ./download-ollama.sh aarch64-apple-darwin
# ./download-ollama.sh x86_64-unknown-linux-gnu
#
# Ollama distributes platform binaries as archives (.tgz / .tar.zst).
# This script downloads, extracts the `ollama` CLI binary, renames it
# to the Tauri target-triple convention, and places it under
# desktop/src-tauri/binaries/.
set -euo pipefail
BINARIES_DIR="$(cd "$(dirname "$0")/../src-tauri/binaries" 2>/dev/null && pwd || echo "$(dirname "$0")/../src-tauri/binaries")"
mkdir -p "$BINARIES_DIR"
# Determine target triple
if [ "${1:-}" != "" ]; then
TARGET="$1"
else
ARCH="$(uname -m)"
OS="$(uname -s)"
case "$OS" in
Darwin)
case "$ARCH" in
arm64) TARGET="aarch64-apple-darwin" ;;
x86_64) TARGET="x86_64-apple-darwin" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
;;
Linux)
case "$ARCH" in
x86_64) TARGET="x86_64-unknown-linux-gnu" ;;
aarch64) TARGET="aarch64-unknown-linux-gnu" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
;;
*)
echo "Unsupported OS: $OS"; exit 1 ;;
esac
fi
echo "Target triple: $TARGET"
# Tauri externalBin naming: <name>-<target-triple>[.exe]
SUFFIX=""
case "$TARGET" in
*windows*) SUFFIX=".exe" ;;
esac
OUT_FILE="$BINARIES_DIR/ollama-${TARGET}${SUFFIX}"
if [ -f "$OUT_FILE" ]; then
echo "Already exists: $OUT_FILE"
echo "Delete it first to re-download."
exit 0
fi
# Map target triple to Ollama release asset
RELEASE_URL="https://github.com/ollama/ollama/releases/latest/download"
case "$TARGET" in
*apple-darwin)
ASSET_URL="${RELEASE_URL}/ollama-darwin.tgz"
ARCHIVE_TYPE="tgz"
;;
x86_64-unknown-linux-gnu)
ASSET_URL="${RELEASE_URL}/ollama-linux-amd64.tar.zst"
ARCHIVE_TYPE="zst"
;;
aarch64-unknown-linux-gnu)
ASSET_URL="${RELEASE_URL}/ollama-linux-arm64.tar.zst"
ARCHIVE_TYPE="zst"
;;
x86_64-pc-windows-msvc)
ASSET_URL="${RELEASE_URL}/ollama-windows-amd64.zip"
ARCHIVE_TYPE="zip"
;;
*)
echo "No Ollama binary mapping for target: $TARGET"
exit 1
;;
esac
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
echo "Downloading: $ASSET_URL"
ARCHIVE_FILE="$TMPDIR/ollama-archive"
curl -fSL --progress-bar "$ASSET_URL" -o "$ARCHIVE_FILE"
echo "Extracting..."
case "$ARCHIVE_TYPE" in
tgz)
tar xzf "$ARCHIVE_FILE" -C "$TMPDIR"
;;
zst)
if command -v zstd &>/dev/null; then
zstd -d "$ARCHIVE_FILE" -o "$TMPDIR/ollama.tar" --quiet
tar xf "$TMPDIR/ollama.tar" -C "$TMPDIR"
else
echo "zstd not found. Install with: brew install zstd (macOS) or apt install zstd (Linux)"
exit 1
fi
;;
zip)
unzip -q "$ARCHIVE_FILE" -d "$TMPDIR"
;;
esac
# Find the ollama binary in the extracted contents
OLLAMA_BIN=""
for candidate in "$TMPDIR/bin/ollama" "$TMPDIR/ollama" "$TMPDIR/ollama.exe"; do
if [ -f "$candidate" ]; then
OLLAMA_BIN="$candidate"
break
fi
done
if [ -z "$OLLAMA_BIN" ]; then
echo "Could not find ollama binary in archive. Contents:"
find "$TMPDIR" -type f | head -20
exit 1
fi
cp "$OLLAMA_BIN" "$OUT_FILE"
chmod +x "$OUT_FILE"
echo "Saved to: $OUT_FILE"
ls -lh "$OUT_FILE"
echo "Done."
+3
View File
@@ -0,0 +1,3 @@
# Downloaded sidecar binaries — platform-specific, not committed
ollama-*
!.gitignore
@@ -0,0 +1,28 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for OpenJarvis Desktop",
"windows": ["main"],
"permissions": [
"core:default",
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"updater:default",
"process:default",
"shell:allow-execute",
"shell:allow-spawn",
"shell:allow-stdin-write",
"shell:allow-kill",
"shell:allow-open",
{
"identifier": "shell:allow-execute",
"allow": [
{ "name": "binaries/ollama", "sidecar": true, "args": true }
]
}
]
}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+349 -84
View File
@@ -1,157 +1,395 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::Manager;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri_plugin_autostart::MacosLauncher;
use tokio::sync::Mutex;
const OLLAMA_PORT: u16 = 11434;
const JARVIS_PORT: u16 = 8222;
const DEFAULT_MODEL: &str = "qwen3:0.6b";
// ---------------------------------------------------------------------------
// BackendManager — owns the Ollama + Jarvis server child processes
// ---------------------------------------------------------------------------
struct ChildHandle {
child: tokio::process::Child,
}
impl ChildHandle {
async fn kill(&mut self) {
let _ = self.child.kill().await;
}
}
#[derive(Default)]
struct BackendManager {
ollama: Option<ChildHandle>,
jarvis: Option<ChildHandle>,
}
impl BackendManager {
async fn stop_all(&mut self) {
if let Some(ref mut h) = self.jarvis {
h.kill().await;
}
self.jarvis = None;
if let Some(ref mut h) = self.ollama {
h.kill().await;
}
self.ollama = None;
}
}
type SharedBackend = Arc<Mutex<BackendManager>>;
// ---------------------------------------------------------------------------
// Setup status (reported to frontend)
// ---------------------------------------------------------------------------
#[derive(serde::Serialize, Clone)]
struct SetupStatus {
phase: String,
detail: String,
ollama_ready: bool,
server_ready: bool,
model_ready: bool,
error: Option<String>,
}
impl Default for SetupStatus {
fn default() -> Self {
Self {
phase: "starting".into(),
detail: "Initializing...".into(),
ollama_ready: false,
server_ready: false,
model_ready: false,
error: None,
}
}
}
type SharedStatus = Arc<Mutex<SetupStatus>>;
// ---------------------------------------------------------------------------
// Health-check helpers
// ---------------------------------------------------------------------------
async fn wait_for_url(url: &str, timeout: Duration) -> bool {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap();
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if let Ok(resp) = client.get(url).send().await {
if resp.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
false
}
async fn ollama_has_model(model: &str) -> bool {
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
if let Ok(resp) = client.get(&url).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
return models.iter().any(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|n| n.starts_with(model.split(':').next().unwrap_or(model)))
.unwrap_or(false)
});
}
}
}
false
}
async fn pull_model(model: &str) -> Result<(), String> {
let url = format!("http://127.0.0.1:{}/api/pull", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.post(&url)
.json(&serde_json::json!({"name": model, "stream": false}))
.send()
.await
.map_err(|e| format!("Pull request failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Pull returned status {}", resp.status()));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Backend boot sequence (runs in background after app launch)
// ---------------------------------------------------------------------------
async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
// Phase 1: Start Ollama
{
let mut s = status.lock().await;
s.phase = "ollama".into();
s.detail = "Starting inference engine...".into();
}
// Try the bundled sidecar first, fall back to system ollama
let ollama_child = {
let sidecar = tokio::process::Command::new("ollama")
.arg("serve")
.env("OLLAMA_HOST", format!("127.0.0.1:{}", OLLAMA_PORT))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match sidecar {
Ok(child) => Some(child),
Err(_) => None,
}
};
if let Some(child) = ollama_child {
backend.lock().await.ollama = Some(ChildHandle { child });
}
let ollama_url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
let ollama_ok = wait_for_url(&ollama_url, Duration::from_secs(30)).await;
if !ollama_ok {
let mut s = status.lock().await;
s.error = Some("Could not start Ollama. Install it from https://ollama.com".into());
return;
}
{
let mut s = status.lock().await;
s.ollama_ready = true;
s.detail = "Inference engine ready.".into();
}
// Phase 2: Ensure a default model exists
{
let mut s = status.lock().await;
s.phase = "model".into();
s.detail = format!("Checking for model {}...", DEFAULT_MODEL);
}
if !ollama_has_model(DEFAULT_MODEL).await {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}... (this may take a minute)", DEFAULT_MODEL);
}
if let Err(e) = pull_model(DEFAULT_MODEL).await {
let mut s = status.lock().await;
s.error = Some(format!("Failed to download model: {}", e));
return;
}
}
{
let mut s = status.lock().await;
s.model_ready = true;
s.detail = "Model ready.".into();
}
// Phase 3: Start jarvis serve
{
let mut s = status.lock().await;
s.phase = "server".into();
s.detail = "Starting API server...".into();
}
let jarvis_child = tokio::process::Command::new("jarvis")
.args(["serve", "--port", &JARVIS_PORT.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match jarvis_child {
Ok(child) => {
backend.lock().await.jarvis = Some(ChildHandle { child });
}
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Could not start jarvis server: {}. \
Install with: pip install 'openjarvis[server]'",
e
));
return;
}
}
let server_url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
let server_ok = wait_for_url(&server_url, Duration::from_secs(30)).await;
if !server_ok {
let mut s = status.lock().await;
s.error = Some("Jarvis server did not become healthy in time.".into());
return;
}
{
let mut s = status.lock().await;
s.server_ready = true;
s.phase = "ready".into();
s.detail = "All systems ready.".into();
}
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
fn api_base() -> String {
format!("http://127.0.0.1:{}", JARVIS_PORT)
}
#[tauri::command]
async fn get_setup_status(
state: tauri::State<'_, SharedStatus>,
) -> Result<SetupStatus, String> {
Ok(state.lock().await.clone())
}
#[tauri::command]
async fn start_backend(
backend: tauri::State<'_, SharedBackend>,
status: tauri::State<'_, SharedStatus>,
) -> Result<(), String> {
let b = backend.inner().clone();
let s = status.inner().clone();
tauri::async_runtime::spawn(boot_backend(b, s));
Ok(())
}
#[tauri::command]
async fn stop_backend(
backend: tauri::State<'_, SharedBackend>,
) -> Result<(), String> {
backend.lock().await.stop_all().await;
Ok(())
}
/// 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 url = format!("{}/health", if api_url.is_empty() { api_base() } else { 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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/telemetry/energy", base))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/telemetry/stats", base))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/traces?limit={}", base, limit))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/traces/{}", base, trace_id))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/learning/stats", base))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/learning/policy", base))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/memory/stats", base))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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 base = if api_url.is_empty() { api_base() } else { api_url };
let client = reqwest::Client::new();
let resp = client
.post(&url)
.post(format!("{}/v1/memory/search", base))
.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)
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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)
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/agents", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() { api_base() } else { api_url };
let resp = reqwest::get(format!("{}/v1/models", base))
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
}
/// 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")
@@ -211,9 +449,21 @@ async fn speech_health(api_url: String) -> Result<serde_json::Value, String> {
Ok(body)
}
// ---------------------------------------------------------------------------
// App entry point
// ---------------------------------------------------------------------------
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let backend: SharedBackend = Arc::new(Mutex::new(BackendManager::default()));
let status: SharedStatus = Arc::new(Mutex::new(SetupStatus::default()));
let boot_backend_ref = backend.clone();
let boot_status_ref = status.clone();
tauri::Builder::default()
.manage(backend.clone())
.manage(status.clone())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
@@ -224,15 +474,15 @@ pub fn run() {
.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| {
.setup(move |app| {
// System tray
let show = MenuItemBuilder::with_id("show", "Show / Hide")
.build(app)?;
let health = MenuItemBuilder::with_id("health", "Health: checking...")
let health = MenuItemBuilder::with_id("health", "Health: starting...")
.enabled(false)
.build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit OpenJarvis")
@@ -270,9 +520,15 @@ pub fn run() {
})
.build(app)?;
// Auto-start backend services on launch
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_setup_status,
start_backend,
stop_backend,
check_health,
fetch_energy,
fetch_telemetry,
@@ -283,10 +539,19 @@ pub fn run() {
fetch_memory_stats,
search_memory,
fetch_agents,
fetch_models,
run_jarvis_command,
transcribe_audio,
speech_health,
])
.run(tauri::generate_context!())
.expect("error while running OpenJarvis Desktop");
.build(tauri::generate_context!())
.expect("error while building OpenJarvis Desktop")
.run(move |_app, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
let b = backend.clone();
tauri::async_runtime::spawn(async move {
b.lock().await.stop_all().await;
});
}
});
}
+2 -5
View File
@@ -4,7 +4,7 @@
"version": "1.0.0",
"identifier": "com.openjarvis.desktop",
"build": {
"frontendDist": "../dist",
"frontendDist": "../../frontend/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
@@ -24,7 +24,7 @@
}
],
"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:"
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; img-src 'self' data: blob:"
}
},
"bundle": {
@@ -60,9 +60,6 @@
"endpoints": [
"https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/latest.json"
]
},
"notification": {
"permissionState": "prompt"
}
}
}
+238
View File
@@ -0,0 +1,238 @@
---
title: Downloads
description: Download the OpenJarvis desktop app, browser app, CLI, or Python SDK
---
# Downloads
OpenJarvis runs entirely on your hardware. Choose the interface that fits your workflow.
---
## Desktop App
The native desktop app bundles Ollama (the inference engine) and the OpenJarvis Python backend
into a single installer. Download, open, and start chatting — no terminal required.
### Download
| Platform | Download | Notes |
|----------|----------|-------|
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg) | M1/M2/M3/M4 Macs |
| macOS (Intel) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg) | Intel Macs (2020 and earlier) |
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe) | Windows 10+ |
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb) | Ubuntu, Debian |
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm) | Fedora, RHEL |
!!! tip "All releases"
Browse all versions on the [GitHub Releases](https://github.com/HazyResearch/OpenJarvis/releases) page.
### What's included
The desktop app ships with:
- **Ollama** sidecar — inference engine runs automatically in the background
- **OpenJarvis backend** — Python API server managed by the app
- **Full chat UI** — same interface as the browser app
- **Energy monitoring** — real-time power consumption tracking
- **Telemetry dashboard** — token throughput, latency, and cost comparison
### Build from source
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis/desktop
npm install
npm run tauri build
```
The built installer will be in `desktop/src-tauri/target/release/bundle/`.
---
## Browser App
Run the full chat UI in your browser. Everything stays local — the backend runs on
your machine and the frontend connects via `localhost`.
### One-command setup
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
The script handles everything:
1. Checks for Python 3.10+ and Node.js 22+
2. Installs Ollama if not present and pulls a starter model
3. Installs Python and frontend dependencies
4. Starts the backend API server and frontend dev server
5. Opens `http://localhost:5173` in your browser
### Manual setup
If you prefer to run each step yourself:
=== "Step 1: Clone and install"
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
uv sync --extra server
cd frontend && npm install && cd ..
```
=== "Step 2: Start Ollama"
```bash
# Install from https://ollama.com if not already installed
ollama serve &
ollama pull qwen3:0.6b
```
=== "Step 3: Start backend"
```bash
uv run jarvis serve --port 8000
```
=== "Step 4: Start frontend"
```bash
cd frontend
npm run dev
```
Then open [http://localhost:5173](http://localhost:5173).
### What you get
- **Chat interface** — markdown rendering, streaming responses, conversation history
- **Tool use** — calculator, web search, code interpreter, file I/O
- **System panel** — live telemetry, energy monitoring, cost comparison vs. cloud models
- **Dashboard** — energy graphs, trace debugging, cost breakdown
- **Settings** — model selection, agent configuration, theme toggle
---
## CLI
The command-line interface is the fastest way to interact with OpenJarvis
programmatically. Every feature is accessible from the terminal.
### Install
=== "uv (recommended)"
```bash
uv pip install openjarvis
```
=== "pip"
```bash
pip install openjarvis
```
=== "From source"
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
uv sync
```
### Verify
```bash
jarvis --version
# jarvis, version 1.0.0
```
### First commands
```bash
# Ask a question
jarvis ask "What is the capital of France?"
# Use an agent with tools
jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?"
# Start the API server
jarvis serve --port 8000
# Run diagnostics
jarvis doctor
# List available models
jarvis model list
# Interactive chat
jarvis chat
```
!!! info "Inference backend required"
The CLI requires a running inference backend (e.g., Ollama). See the
[Installation guide](getting-started/installation.md#setting-up-an-inference-backend)
for setup instructions.
---
## Python SDK
For programmatic access, the `Jarvis` class provides a high-level sync API.
### Install
```bash
pip install openjarvis
```
### Quick example
```python
from openjarvis import Jarvis
j = Jarvis()
print(j.ask("Explain quicksort in two sentences."))
j.close()
```
### With agents and tools
```python
result = j.ask_full(
"What is the square root of 144?",
agent="orchestrator",
tools=["calculator", "think"],
)
print(result["content"]) # "12"
print(result["tool_results"]) # tool invocations
print(result["turns"]) # number of agent turns
```
### Composition layer
For full control, use the `SystemBuilder`:
```python
from openjarvis import SystemBuilder
system = (
SystemBuilder()
.engine("ollama")
.model("qwen3:8b")
.agent("orchestrator")
.tools(["calculator", "web_search", "file_read"])
.enable_telemetry()
.enable_traces()
.build()
)
result = system.ask("Summarize the latest AI news.")
system.close()
```
See the [Python SDK guide](user-guide/python-sdk.md) for the full API reference.
+28 -1
View File
@@ -7,16 +7,43 @@ description: Install OpenJarvis and set up an inference backend
This guide covers installing OpenJarvis, its optional extras, and setting up an inference backend.
## Quickstart (Recommended)
The fastest way to get everything running — browser UI, backend, and inference engine — with a single command:
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
This script checks for Python 3.10+, Node.js, and Ollama (installing what's missing), pulls a starter model, installs all dependencies, starts the backend and frontend servers, and opens the chat UI in your browser.
!!! tip "Desktop app"
Prefer a native app? Download the [Desktop App](../downloads.md#desktop-app) instead — it bundles everything into a single installer.
---
## Requirements
| Requirement | Version | Notes |
|-------------|---------|-------|
| Python | 3.10+ | Required |
| Inference backend | Any | At least one of Ollama, vLLM, llama.cpp, SGLang, or a cloud API |
| Node.js | 22+ | Only required for OpenClaw agent infrastructure |
| Node.js | 18+ | Required for the browser UI; 22+ for OpenClaw agent |
## Installing OpenJarvis
=== "Quickstart script"
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
Handles everything: deps, Ollama, model pull, backend, frontend, browser open.
=== "uv (recommended)"
```bash
+19 -1
View File
@@ -5,11 +5,29 @@ description: Get up and running with OpenJarvis in minutes
# Quick Start
This guide walks through the core workflows of OpenJarvis: asking questions, using agents with tools, managing memory, running benchmarks, and starting the API server.
This guide walks through the core workflows of OpenJarvis: the browser app, CLI, Python SDK, agents with tools, memory, benchmarks, and the API server.
!!! info "Prerequisites"
Make sure you have [installed OpenJarvis](installation.md) and have at least one inference backend running (e.g., `ollama serve`).
## Browser App
The quickest way to experience OpenJarvis is the full chat UI running in your browser:
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
To stop all services, press ++ctrl+c++ in the terminal.
!!! tip "Environment variable"
Set `OPENJARVIS_MODEL` to change the default model: `OPENJARVIS_MODEL=deepseek-r1:14b ./scripts/quickstart.sh`
## Initialize Configuration
Start by detecting your hardware and generating a configuration file:
+89 -106
View File
@@ -5,19 +5,86 @@ hide:
- navigation
---
# OpenJarvis
**Programming abstractions for on-device AI.**
OpenJarvis is a modular framework for building, running, and learning from local AI systems. It provides composable abstractions across **five pillars** with a cross-cutting trace-driven learning system:
1. **Intelligence** -- The LM itself: Llama, Qwen, Claude, GPT, etc. Model catalog, generation defaults, quantization, and preferred engine configuration.
2. **Agents** -- The agentic harness for running it: system prompt (including objective, available tools, available models), context from past turns, retry logic, looping logic, exit logic. Seven agent types from simple single-turn to recursive decomposition.
3. **Tools** -- In an MCP interface, the available tools and LMs that can be called: web search, calculator, file read, code interpreter, retrieval systems, SQLite, sub-model calls, and any external MCP server.
4. **Engine** -- The inference runtime: Ollama, SGLang, vLLM, llama.cpp, cloud APIs (OpenAI, Anthropic, Google). All implement the same `InferenceEngine` ABC.
5. **Learning** -- Methodologies for improving Intelligence (weight updates via SFT) or Agents (changes to system prompt, tools available, models available, retry/looping/exit logic via agent advisor and ICL updater). Trace-driven feedback loop.
# _Programming abstractions_ for on-device AI
<p class="hero-tagline">
OpenJarvis is a modular framework for building, running, and learning from local AI systems.
Five composable pillars — each with a clear ABC interface and decorator-based registry.
Everything runs on your hardware. Cloud APIs are optional.
</p>
<div class="install-cmd">> pip install openjarvis</div>
---
## Get Started
=== "Browser App"
Run the full chat UI locally with one script:
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
This installs dependencies, starts Ollama + a local model, launches the backend
and frontend, and opens `http://localhost:5173` in your browser.
=== "Desktop App"
Download the native desktop app — it bundles Ollama and the Python backend
so everything works out of the box.
[Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg){ .md-button .md-button--primary }
Also available for [macOS (Intel)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg), [Windows](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm). See the [Downloads](downloads.md) page for details.
=== "Python SDK"
```python
from openjarvis import Jarvis
j = Jarvis() # auto-detect engine
response = j.ask("Explain quicksort.")
print(response)
```
For more control, use `ask_full()` to get usage stats, model info, and tool results:
```python
result = j.ask_full(
"What is 2 + 2?",
agent="orchestrator",
tools=["calculator"],
)
print(result["content"]) # "4"
print(result["tool_results"]) # [{tool_name: "calculator", ...}]
```
=== "CLI"
```bash
jarvis ask "What is the capital of France?"
jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?"
jarvis serve --port 8000
jarvis memory index ./docs/
jarvis memory search "configuration options"
```
---
## Five Pillars
1. **Intelligence** — The LM: model catalog, generation defaults, quantization, preferred engine.
2. **Agents** — The agentic harness: system prompt, tools, context, retry and exit logic. Seven agent types.
3. **Tools** — MCP interface: web search, calculator, file I/O, code interpreter, retrieval, and any external MCP server.
4. **Engine** — The inference runtime: Ollama, vLLM, SGLang, llama.cpp, cloud APIs. Same `InferenceEngine` ABC.
5. **Learning** — Improvement loop: SFT weight updates, agent advisor, ICL updater. Trace-driven feedback.
---
@@ -29,126 +96,42 @@ Everything runs on your hardware. Cloud APIs are optional.
---
Intelligence (the model), Agents (agentic harness), Tools (MCP-based tool system with storage), Engine (inference runtime), and Learning (trace-driven improvement) — each with a clear ABC interface and decorator-based registry.
Intelligence, Agents, Tools, Engine, and Learning — each with a clear ABC interface and decorator-based registry.
- **5 Engine Backends**
---
Ollama, vLLM, SGLang, llama.cpp, and cloud (OpenAI/Anthropic/Google). All implement the same `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, and `health()`.
- **5 Memory Backends**
---
SQLite/FTS5 (default, zero-dependency), FAISS, ColBERTv2, BM25, and Hybrid (reciprocal rank fusion). Document chunking, indexing, and context injection built in.
Ollama, vLLM, SGLang, llama.cpp, and cloud (OpenAI/Anthropic/Google). Same `InferenceEngine` ABC.
- **Hardware-Aware**
---
Auto-detects GPU vendor, model, and VRAM via `nvidia-smi`, `rocm-smi`, and `system_profiler`. Recommends the optimal engine for your hardware automatically.
Auto-detects GPU vendor, model, and VRAM. Recommends the optimal engine for your hardware.
- **Offline-First**
---
All core functionality works without a network connection. Cloud API backends are optional extras for when you need them.
All core functionality works without a network connection. Cloud APIs are optional extras.
- **OpenAI-Compatible API**
---
`jarvis serve` starts a FastAPI server with `POST /v1/chat/completions`, `GET /v1/models`, and SSE streaming. Drop-in replacement for OpenAI-compatible clients.
`jarvis serve` starts a FastAPI server with SSE streaming. Drop-in replacement for OpenAI clients.
- **Trace-Driven Learning**
---
Every agent interaction is recorded as a trace. The learning system improves Intelligence (SFT weight updates) and Agents (system prompt, tool selection, retry logic). Pluggable policies: heuristic, trace-driven, SFT, agent advisor, ICL updater, GRPO.
- **Python SDK**
---
The `Jarvis` class provides a high-level sync API. Three lines of code to ask a question. Full access to agents, tools, memory, and model routing.
- **CLI-First**
---
`jarvis ask`, `jarvis serve`, `jarvis memory`, `jarvis bench`, `jarvis telemetry` — every capability is accessible from the command line with rich terminal output.
Every interaction is traced. The learning system improves models (SFT) and agents (prompt, tools, logic).
</div>
---
## Quick Start
### Python SDK
```python
from openjarvis import Jarvis
j = Jarvis()
response = j.ask("Explain quicksort in two sentences.")
print(response)
j.close()
```
For more control, use `ask_full()` to get usage stats, model info, and tool results:
```python
result = j.ask_full(
"What is 2 + 2?",
agent="orchestrator",
tools=["calculator"],
)
print(result["content"]) # "4"
print(result["tool_results"]) # [{tool_name: "calculator", ...}]
```
### CLI
```bash
# Ask a question
jarvis ask "What is the capital of France?"
# Use an agent with tools
jarvis ask --agent orchestrator --tools calculator,think "What is 137 * 42?"
# Start the API server
jarvis serve --port 8000
# Index documents and search memory
jarvis memory index ./docs/
jarvis memory search "configuration options"
# Run inference benchmarks
jarvis bench run --json
```
---
## Project Status
OpenJarvis v1.5 (Phase 10) is complete. The framework includes the full five-pillar architecture, seven agent types, Python SDK, CLI, OpenAI-compatible API server, benchmarking framework, and Docker deployment. The test suite contains over 1,800 tests.
| Component | Status |
|-----------|--------|
| Intelligence (model catalog + config) | Stable |
| Agents (7 types: Simple, Orchestrator, NativeReAct, NativeOpenHands, RLM, OpenHands SDK, OpenClaw) | Stable |
| Tools (MCP interface + 5 storage backends) | Stable |
| Engine (5 backends) | Stable |
| Learning (routing, SFT, agent advisor, ICL updater) | Stable |
| Python SDK | Stable |
| CLI | Stable |
| API Server | Stable |
| Trace System | Stable |
| Docker Deployment | Stable |
---
## Documentation
<div class="grid cards" markdown>
@@ -157,31 +140,31 @@ OpenJarvis v1.5 (Phase 10) is complete. The framework includes the full five-pil
---
Install OpenJarvis, configure your first engine, and run your first query in minutes.
Install OpenJarvis, configure your first engine, and run your first query.
- **[User Guide](user-guide/cli.md)**
---
Comprehensive guides for the CLI, Python SDK, agents, memory, tools, telemetry, and benchmarks.
CLI, Python SDK, agents, memory, tools, telemetry, and benchmarks.
- **[Architecture](architecture/overview.md)**
---
Deep dive into the five-pillar design, registry pattern, query flow, and cross-cutting learning system.
Five-pillar design, registry pattern, query flow, and cross-cutting learning.
- **[API Reference](api/index.md)**
---
Auto-generated reference for every module: SDK, core, engine, agents, memory, tools, intelligence, learning, traces, telemetry, and server.
Auto-generated reference for every module.
- **[Deployment](deployment/docker.md)**
---
Deploy OpenJarvis with Docker, systemd, or launchd. Includes GPU-accelerated container images.
Docker, systemd, launchd. GPU-accelerated container images.
- **[Development](development/contributing.md)**
@@ -1,177 +0,0 @@
# Differentiated Functionalities Design
**Date:** 2026-02-28
**Approach:** Learning Flywheel (Approach A)
**Status:** Approved
## Vision
Make OpenJarvis's functionalities genuinely differentiated through four pillars of uniqueness:
1. **Fully open-source on-device stack** — every component runs locally, user controls data
2. **Native on-device intelligence** — make local LMs punch above their weight via trace-driven learning
3. **Latency, privacy, energy as first-class** — alongside accuracy, not afterthoughts
4. **Composable, programmable abstractions** — Intelligence, Agent, Tools, Engine, Learning as optimized components
**Priority:** On-device depth first. Trace-driven learning is the core differentiator.
## Design Decisions
- **Approach A selected:** Build the trace-to-learn-to-eval loop as the backbone, hang user-facing features off it
- **Learning targets:** Full stack — routing, agent logic, AND model weights (LoRA/QLoRA)
- **Eval purpose:** Research platform — rigorous before/after measurement with reproducible configs
- **Composition layers:** Python SDK + TOML configs + pre-built recipes
- **Operators:** 3 recipes (researcher, correspondent, sentinel) — not a new abstraction, just recipe + schedule + channel
- **Interleave:** Wire up real learning AND build new user-facing features simultaneously
## Section 1: Trace-Driven Learning Pipeline
The core differentiator. Makes the learning pipeline real, not just infrastructure.
### Current State
`TraceStore` captures traces, `TraceAnalyzer` computes stats, GRPO/Bandit policies update internal routing state, `SFTRouterPolicy` and `AgentAdvisorPolicy` exist but don't produce real training jobs, `ICLUpdaterPolicy` updates in-context examples with versioning/rollback.
### New Components
**Trace-to-Training Data pipeline** (`learning/training/data.py`): Mines the `TraceStore` for supervised pairs. For successful traces (user rated positively or task completed), extracts `(input, preferred_output)` pairs. For routing, extracts `(query_class, best_model)` pairs. For agent logic, extracts `(query_type, best_agent_config)` tuples. Filters: minimum trace quality score, deduplication, privacy scanning (strip PII before training).
**LoRA/QLoRA fine-tuning** (`learning/training/lora.py`): Wraps `transformers` + `peft` + `trl` for actual weight updates on local models. Takes training data from the pipeline, runs LoRA fine-tuning with configurable rank/alpha/dropout. Produces adapter weights saved alongside the base model. Energy-aware via existing `EnergyMonitor`. Guarded by hardware detection — only runs if sufficient VRAM/RAM.
**Agent config evolution** (`learning/agent_learning.py`): Makes `AgentAdvisorPolicy` actually rewrite agent TOML configs. Analyzes traces to identify: which tools were useful vs. never called, which system prompt patterns led to success, optimal `max_turns` per query class. Writes updated configs with version control (git-tracked, rollback via `ICLUpdaterPolicy` pattern).
**Learning orchestrator** (`learning/orchestrator.py`): Coordinates all learning. Runs on a schedule (e.g., nightly) or on-demand. Steps: collect new traces, mine training data, run evals (baseline), fine-tune model / update agent configs, run evals (after), accept/reject based on improvement threshold. Atomic: if evals don't improve, rollback automatically.
### Data Flow
```
User queries -> Traces recorded -> TraceStore (SQLite)
|
LearningOrchestrator (scheduled/on-demand)
| | |
TrainingDataMiner AgentConfigEvolver RoutingPolicyUpdater
| | |
LoRA fine-tune TOML config update GRPO/Bandit update
| | |
EvalHarness (before/after comparison)
|
Accept (deploy) or Reject (rollback)
```
## Section 2: Eval Framework
Proves the learning flywheel works and serves as the research platform.
### Five Workload-Type Eval Suites
- **Chat:** Multi-turn conversation quality (MT-Bench style, coherence, helpfulness)
- **Reasoning:** Logic/math/science (GSM8K, ARC, MMLU subsets that run locally)
- **RAG:** Retrieval-augmented accuracy (measures whether memory retrieval improves answers vs. without, uses OpenJarvis's own memory backends)
- **Agentic:** Task completion rate for multi-step tool-use workflows (custom scenarios: research a topic, triage inbox, etc.)
- **Coding:** Code generation correctness (HumanEval, MBPP subsets)
### On-Device Metrics as First-Class
Every eval records: accuracy, latency (TTFT + total), energy consumption (via `EnergyMonitor`), peak memory, tokens/second. Results are multi-dimensional, not a single number. Configurable weighting (e.g., 40% accuracy, 30% latency, 20% energy, 10% memory).
### Before/After Measurement
`LearningOrchestrator` calls the eval harness before learning (baseline) and after (candidate). Improvement measured per-dimension. Configurable acceptance threshold (e.g., accept if accuracy improves >2% without latency regressing >10%).
### Reproducible Configs
Every eval run fully specified by TOML (model, agent, tools, dataset, metrics, hardware). Results are JSONL with full provenance. CLI: `jarvis eval run`, `jarvis eval compare`, `jarvis eval report`.
### Ablation Support
Run the same eval with one variable changed (with vs. without LoRA, with vs. without a tool, with vs. without memory). Built-in diffing and statistical significance testing.
## Section 3: Composable Abstractions and Recipes
The user-facing layer for interacting with the pillars.
### Recipe System (`recipes/`)
A recipe is a curated composition of all 5 pillars in a single TOML file: model selection, engine preference, agent type + system prompt, tool set, learning policy, and eval config. Recipes are opinionated defaults that work well together.
Users load recipes via `jarvis ask --recipe coding_assistant "Fix this bug"` or `Jarvis(recipe="coding_assistant")` in Python.
### Agent Templates (15-20)
Pre-configured agent TOML manifests with system prompts, tool sets, and behavioral parameters.
Categories:
- **Coding:** code-reviewer, debugger, architect
- **Research:** deep-researcher, fact-checker, summarizer
- **Productivity:** inbox-triager, meeting-prep, note-taker
- **General:** assistant, tutor, translator, writer
### Bundled Skills (20-30)
Focused on on-device use cases where local execution matters.
Categories:
- **File management:** organize, deduplicate, backup
- **Personal knowledge:** extract, summarize, index
- **Development:** lint, test, review
- **Productivity:** calendar prep, email draft, todo management
### Three Composition Layers
- **CLI flags:** `--recipe`, `--agent`, `--tools`, `--model` for quick use
- **TOML configs:** Full pillar configuration for persistent setups
- **Python SDK:** `SystemBuilder` for programmatic composition, research scripts, custom pipelines
## Section 4: Operator Recipes
OpenJarvis's answer to autonomous task agents. Operators are NOT a new abstraction — they are recipe + schedule + channel output, composed via TOML.
### Deep Researcher (`recipes/operators/researcher.toml`)
Autonomous research agent. Given a topic, searches the web, cross-references sources, evaluates credibility, builds a knowledge graph entry, produces a cited report. Runs on-demand or scheduled.
Tools: `web_search`, `http_request`, `memory_store`, `memory_search`, `kg_add_entity`, `kg_add_relation`, `file_write`.
Learning: traces which search strategies yield useful results, evolves search query patterns over time.
### Correspondent (`recipes/operators/correspondent.toml`)
Messaging triage across Slack/Gmail/Discord. Monitors incoming messages, classifies urgency (urgent/normal/low/ignore), drafts responses for high-priority items, summarizes the rest into a daily digest.
Tools: `memory_store`, `memory_search`, `think`, `llm_call`.
Channels: Slack, email, Discord.
Learning: adapts to user's triage preferences over time.
### Sentinel (`recipes/operators/sentinel.toml`)
Monitors Twitter, Reddit, Mastodon, Google Trends, RSS feeds, and specified URLs for changes relevant to user-defined topics. Produces alerts when something significant surfaces — trending discussions, sentiment shifts, breaking news, competitor activity.
Tools: `web_search`, `http_request`, `memory_store`, `memory_search`, `kg_add_entity`.
Channels: Twitter, Reddit, Mastodon (read via APIs), Google Trends.
Learning: refines what "significant" means based on which alerts the user acts on vs. dismisses.
## Section 5: Testing and Validation
### Learning Pipeline Tests
Unit tests for `TrainingDataMiner` (mock traces, verify correct pairs), `LoRATrainer` (mock training, verify adapter saved), `AgentConfigEvolver` (mock traces, verify TOML rewritten), `LearningOrchestrator` (mock all steps, verify accept/reject logic). Integration test: synthetic traces, actual LoRA training on tiny model, verify loss decreases.
### Eval Framework Tests
Each of the 5 eval suites has a smoke mode (5-10 examples, <30 seconds). Verify multi-dimensional scoring. Verify before/after comparison and acceptance thresholds. Verify ablation diffing.
### Recipe and Template Tests
Each recipe loads without error. Each agent template produces a valid agent. Each skill executes its steps. Composition tests: `--recipe` flag wires all pillars correctly.
### Operator Tests
Each operator recipe loads and schedules correctly. Mock tool execution to verify agent loop completes. Verify learning feedback loop (trace, config update, re-eval).
### Regression
All ~2940 existing tests continue to pass. Target ~200-300 additional tests for new functionality.
File diff suppressed because it is too large Load Diff
@@ -1,319 +0,0 @@
# Experience Polish Design: Eval, CLI, Install, Dashboard
**Date**: 2026-02-28
**Status**: Approved
**Approach**: Vertical slices (3 mini-phases, independently shippable)
## Context
OpenJarvis has strong infrastructure (multi-vendor energy monitoring, eval framework, Tauri desktop, PWA) but the end-to-end user experience has gaps. This design addresses: eval output quality, CLI polish, installation flow, and dashboard aesthetics.
### Current State (from audits)
| Area | Score | Key Gaps |
|------|-------|----------|
| Energy Monitors | 8.5/10 | IPJ/IPW only in evals, not core telemetry |
| Eval Framework | 9/10 | Token stats null, no grouped tables, no trace aggregation |
| CLI Formatting | 9/10 | No logging, no verbose/quiet, bench lacks full stats |
| Installation | 8.5/10 | No quickstart, no auto-suggestions in errors |
| Desktop App | 8/10 | No settings panel, placeholder icon, stub tray |
| Browser/PWA | 6/10 | No CI/CD, no versioning, no error boundary |
### Key Decisions
- **Benchmark focus**: GAIA with OpenHands (TerminalBench deferred)
- **IPW** = task_accuracy / avg_power_watts (task-level, NOT per-inference)
- **IPJ** = task_accuracy / avg_energy_joules (task-level, NOT per-inference)
- **tokens_per_joule** = per-inference efficiency metric in core telemetry
- **Table layout**: Grouped panels by default, `--compact` for single dense table
- **Trace detail**: Summary + step-type breakdown by default, `--trace-detail` for full listing
- **Installation**: Non-interactive `jarvis quickstart` (zero prompts)
- **Dashboard style**: Clean, minimalistic (ChatGPT/Claude aesthetic) with unique side panels
---
## Phase 23a: Perfect Eval Run
Everything needed so `python -m evals run -c config.toml` produces beautiful, complete results.
### 1. Core Telemetry: tokens_per_joule
**Files**:
- `src/openjarvis/core/types.py` — Add `tokens_per_joule` to `TelemetryRecord`
- `src/openjarvis/telemetry/instrumented_engine.py` — Compute `tokens_per_joule = completion_tokens / energy_joules`
- `src/openjarvis/telemetry/store.py` — Schema migration for new column
- `src/openjarvis/telemetry/aggregator.py` — Add `avg_tokens_per_joule` to `ModelStats`/`EngineStats`
**Not** adding IPJ/IPW to core telemetry — they require task accuracy and are inherently eval-level.
### 2. Eval Metrics: Fix Token Stats + Strengthen IPJ/IPW
**Files**:
- `evals/core/runner.py`:
- Wire `prompt_tokens` and `completion_tokens` from engine response into `EvalResult`
- Verify IPW = `accuracy / avg(power_watts)` across all scored samples
- Verify IPJ = `accuracy / avg(energy_joules)` across all scored samples
- Ensure `energy_joules` and `power_watts` populated from telemetry for every sample
- `evals/core/types.py`:
- Add `total_energy_joules` (sum over all samples) to `RunSummary`
- Add `avg_power_watts` (mean over all samples) to `RunSummary`
- Add `trace_steps: int`, `trace_energy_joules: float` to `EvalResult`
- Add `trace_step_type_stats: Dict[str, StepTypeStats]` to `RunSummary`
### 3. Eval Display: Grouped Tables
Rewrite `evals/core/display.py` with these functions:
#### `print_accuracy_panel(summary)`
```
╭─ Accuracy ──────────────────────────────────────────────────╮
│ Overall Accuracy 42.0% (84/200) │
│ Level 1 58.8% (40/68) │
│ Level 2 35.0% (35/100) │
│ Level 3 28.1% (9/32) │
╰─────────────────────────────────────────────────────────────╯
```
#### `print_latency_table(summary)`
```
╭─ Latency & Throughput ──────────────────────────────────────╮
│ Metric Avg Median Min Max Std │
│ Latency (s) 15.41 12.30 2.10 89.50 11.20 │
│ TTFT (ms) 145.2 120.0 45.0 890.0 85.3 │
│ Throughput (tok/s) 41.9 38.5 12.0 95.0 15.2 │
│ Avg Input Tokens 1024 890 128 4096 520 │
│ Avg Output Tokens 256 210 32 2048 180 │
╰─────────────────────────────────────────────────────────────╯
```
#### `print_energy_table(summary)`
```
╭─ Energy & Efficiency ───────────────────────────────────────╮
│ Metric Avg Median Min Max Std │
│ Energy (J) 46502 38500 4145 410522 89390 │
│ Power (W) 883.8 870.2 650.0 1050.0 85.0 │
│ GPU Util (%) 46.4 48.0 12.0 92.0 18.5 │
│ Energy/Token (mJ) 12.5 10.8 3.2 45.0 8.1 │
│ Tokens/Joule 80.0 92.6 22.2 312.5 65.0 │
│ ────────────────────────────────────────────────────────── │
│ IPW (acc/W) 0.00048 │
│ IPJ (acc/J) 9.03e-06 │
│ Total Energy (kJ) 9300.4 │
╰─────────────────────────────────────────────────────────────╯
```
#### `print_trace_summary(summary)`
```
╭─ Agentic Trace Summary ────────────────────────────────────╮
│ Total Steps: 1240 │ Avg Steps/Sample: 6.2 │
│ │
│ Step Type Count Avg Duration Avg Energy Avg In Tok Avg Out Tok │
│ Median/Min/Max/Std for each column │
│ generate 580 8.2s 38200 J 890 256 │
│ tool_call 420 3.1s — — — │
│ retrieve 120 0.8s — — — │
│ route 120 0.01s — — — │
╰─────────────────────────────────────────────────────────────╯
```
All metrics in trace summary show avg/median/min/max/std where applicable.
#### `print_compact_table(summary)` — `--compact` flag
Single dense table with all 17 metrics as rows, columns: avg/median/min/max/std.
#### CLI flags
- `--compact`: Dense single table
- `--trace-detail`: Full per-step listing for each sample
### 4. Per-Step Trace Aggregation
**Files**:
- `src/openjarvis/traces/analyzer.py`:
- Add `TraceSummary.total_energy_joules` — sum of `step.metadata['energy_joules']`
- Add `TraceSummary.total_generate_energy_joules` — sum for GENERATE steps
- Add `TraceSummary.step_type_stats` — dict mapping step type to `{count, avg_duration, median_duration, min_duration, max_duration, std_duration, total_energy, avg_input_tokens, median_input_tokens, min_input_tokens, max_input_tokens, std_input_tokens, avg_output_tokens, median_output_tokens, min_output_tokens, max_output_tokens, std_output_tokens}`
- `evals/core/runner.py` — After each agent eval sample, extract `TraceSummary` and store in `EvalResult.metadata`
---
## Phase 23b: Perfect First Experience
Everything needed so a new user goes from zero to first eval in one sitting.
### 5. `jarvis quickstart` Command
**New file**: `src/openjarvis/cli/quickstart_cmd.py`
Non-interactive flow (5 numbered steps):
1. Detect hardware (platform, CPU, RAM, GPU vendor/model/VRAM)
2. Write config to `~/.openjarvis/config.toml` (skip if exists, unless `--force`)
3. Check engine health (try auto-detected engine)
4. Verify model availability (list models from engine)
5. Test query ("What is 2+2?") with latency + energy measurement
Each step prints a status line. If a step fails, print a helpful suggestion and exit gracefully.
Flags: `--force` (redo everything)
**Register** in `src/openjarvis/cli/__init__.py`.
### 6. Error Message Auto-Suggestions
**New file**: `src/openjarvis/cli/hints.py`
Centralized hint functions:
- `hint_no_config()` → "Config not found. Run: `jarvis quickstart`"
- `hint_no_engine()` → "No engine responding. Run: `jarvis doctor`"
- `hint_no_model()` → "No model available. Try: `ollama pull qwen3:8b`"
**Wire into**: `ask.py`, `serve.py`, `bench_cmd.py`, `chat_cmd.py` at failure points.
### 7. Global Logging & Verbose/Quiet Flags
**Changes**:
- `src/openjarvis/cli/__init__.py` — Add `--verbose` / `--quiet` to root `cli` group
- **New file**: `src/openjarvis/cli/log_config.py` — Centralized logging setup
- `RichHandler` for console (respects quiet flag)
- `RotatingFileHandler` for `~/.openjarvis/cli.log` (5 MB max, 3 backups)
- Default level: WARNING; `--verbose`: DEBUG; `--quiet`: ERROR
**Add progress indicators**:
- `ask.py` — Spinner during generation
- `memory_cmd.py` — Progress bar for indexing
### 8. Bench CLI Full Stats Tables
**Changes**:
- `src/openjarvis/cli/bench_cmd.py` — Show full stats table (avg/median/min/max/std) matching eval style
- `src/openjarvis/bench/latency.py` — Export all percentile stats (already computes internally)
- `src/openjarvis/bench/throughput.py` — Add stats computation
- `src/openjarvis/bench/energy.py` — Add stats computation
---
## Phase 23c: Perfect Dashboard
Desktop and browser apps polished for demos and daily use.
### Design Aesthetic
**Clean, minimalistic** — inspired by ChatGPT and Claude web interfaces:
- Generous whitespace, muted color palette, subtle borders
- Sans-serif typography, comfortable line heights
- Smooth transitions, no visual clutter
**Differentiated** via unique side panels:
- Energy dashboard with real-time power charts
- Trace debugger with color-coded timeline
- Learning curve visualization
- Memory browser with relevance scoring
The side panels are what make OpenJarvis unique vs generic chat UIs. Keep them accessible but not overwhelming — collapsible, clean data density.
### 9. Desktop: Settings Panel
**New file**: `desktop/src/components/SettingsPanel.tsx`
- API URL configuration (default: `localhost:8000`)
- Auto-update interval setting
- Theme toggle (dark/light) — currently hardcoded dark
- Persist to `localStorage`
### 10. Desktop: Windows Icon
Replace 108-byte placeholder `desktop/src-tauri/icons/icon.ico` with proper multi-resolution icon generated from existing `icon.png`.
### 11. Desktop: System Tray
Implement stubbed tray menu in `desktop/src-tauri/src/lib.rs`:
- Show/Hide window toggle
- Health status indicator (green/red dot)
- Quit action
### 12. Browser/PWA: CI/CD
**New file**: `.github/workflows/frontend.yml`
- Trigger on changes to `frontend/`
- `npm ci && npm run build`
- Commit built output to `src/openjarvis/server/static/`
- Add `version` field to `manifest.webmanifest` (auto-bumped)
### 13. Browser/PWA: Error Boundary + Config
- Wrap `<App />` in React error boundary (graceful crash recovery)
- Support `VITE_API_URL` environment variable (default `localhost:8000`)
### 14. Browser/PWA: Style Refresh
Refactor the 12,971-line `App.css`:
- Modularize into component-scoped CSS files
- Clean minimalist aesthetic (ChatGPT/Claude-inspired)
- Catppuccin-based dark theme (keep existing) + light theme option
- Collapsible side panels with smooth transitions
---
## Implementation Order
### Phase 23a (highest priority — enables GAIA research)
1. Core telemetry: `tokens_per_joule`
2. Eval metrics: fix token stats, strengthen IPJ/IPW
3. Per-step trace aggregation in `TraceAnalyzer`
4. Eval display: grouped tables, `--compact`, `--trace-detail`
### Phase 23b (high priority — onboarding & CLI)
5. `jarvis quickstart` command
6. Error message auto-suggestions
7. Global logging + verbose/quiet flags
8. Bench CLI full stats tables
### Phase 23c (lower priority — dashboard polish)
9. Desktop settings panel
10. Windows icon fix
11. System tray implementation
12. PWA CI/CD
13. PWA error boundary + config
14. PWA style refresh
---
## Files Modified (Summary)
### Phase 23a
| File | Change |
|------|--------|
| `src/openjarvis/core/types.py` | Add `tokens_per_joule` to `TelemetryRecord` |
| `src/openjarvis/telemetry/instrumented_engine.py` | Compute `tokens_per_joule` |
| `src/openjarvis/telemetry/store.py` | Schema migration |
| `src/openjarvis/telemetry/aggregator.py` | Add `avg_tokens_per_joule` |
| `src/openjarvis/traces/analyzer.py` | Add energy aggregation, step-type stats |
| `evals/core/types.py` | Add trace fields, total_energy, avg_power |
| `evals/core/runner.py` | Fix token stats, wire trace data, verify IPJ/IPW |
| `evals/core/display.py` | Rewrite: grouped panels, compact mode |
| `evals/cli.py` | Add `--compact`, `--trace-detail` flags |
### Phase 23b
| File | Change |
|------|--------|
| `src/openjarvis/cli/quickstart_cmd.py` | **New**: quickstart command |
| `src/openjarvis/cli/hints.py` | **New**: centralized hint system |
| `src/openjarvis/cli/log_config.py` | **New**: logging setup |
| `src/openjarvis/cli/__init__.py` | Register quickstart, add verbose/quiet |
| `src/openjarvis/cli/ask.py` | Add hints, progress spinner |
| `src/openjarvis/cli/serve.py` | Add hints |
| `src/openjarvis/cli/bench_cmd.py` | Full stats tables |
| `src/openjarvis/cli/chat_cmd.py` | Add hints |
| `src/openjarvis/cli/memory_cmd.py` | Progress bar for indexing |
| `src/openjarvis/bench/latency.py` | Export full stats |
| `src/openjarvis/bench/throughput.py` | Add stats computation |
| `src/openjarvis/bench/energy.py` | Add stats computation |
### Phase 23c
| File | Change |
|------|--------|
| `desktop/src/components/SettingsPanel.tsx` | **New**: settings UI |
| `desktop/src-tauri/icons/icon.ico` | Replace placeholder |
| `desktop/src-tauri/src/lib.rs` | Implement system tray |
| `.github/workflows/frontend.yml` | **New**: PWA CI/CD |
| `frontend/src/App.tsx` | Error boundary wrapper |
| `frontend/vite.config.ts` | VITE_API_URL support |
| `frontend/src/App.css` | Modularize, style refresh |
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
# Codebase Simplification Design
**Date:** 2026-03-02
**Goal:** Simplify repository structure and clean up code while preserving all functionality.
**Approach:** Incremental commits, tests verified after each step.
## 1. Target Root Structure
Reduce root from 32 items to 21 items.
### Items removed from git tracking
- `get-pip.py` (2.2MB, regenerable)
- `site/` (9.6MB, mkdocs build output)
- `results/` (5.7MB, eval output)
### Items moved
| Source | Destination |
|--------|------------|
| `Dockerfile`, `Dockerfile.gpu`, `Dockerfile.gpu.rocm`, `Dockerfile.sandbox` | `deploy/docker/` |
| `docker-compose.yml`, `docker-compose.gpu.rocm.yml` | `deploy/docker/` |
| `recipes/*.toml` | `src/openjarvis/recipes/data/` |
| `templates/agents/*.toml` | `src/openjarvis/templates/data/` |
| `skills/builtin/*.toml` | `src/openjarvis/skills/data/` |
| `operators/*.toml` | `src/openjarvis/operators/data/` |
| `evals/` (entire directory) | `src/openjarvis/evals/` |
### Items kept at root
- `CLAUDE.md`, `README.md`, `VISION.md`, `ROADMAP.md`, `NOTES.md`
- `mkdocs.yml`, `pyproject.toml`, `uv.lock`, `LICENSE`
- `assets/`, `configs/`, `deploy/`, `desktop/`, `docs/`, `frontend/`, `scripts/`, `src/`, `tests/`
## 2. Data Directory Migration
Each module's `discover_*()` function defaults to `Path(__file__).parent / "data"` for built-in TOML files. `pyproject.toml` declares package data via `[tool.hatch.build.targets.wheel]`.
### evals/ migration
The entire evals directory (Python modules + configs + dataset loaders) moves into `src/openjarvis/evals/` as a subpackage. CLI `jarvis eval` and `python -m openjarvis.evals` entry points updated.
## 3. Code Cleanups
### 3a. Remove memory/ shim package
Delete `src/openjarvis/memory/` (11 files, 108 lines of pure re-exports). Update all imports to use `openjarvis.tools.storage.*` directly.
### 3b. Consolidate engine wrappers
Replace 5 near-identical 17-line files (vllm.py, sglang.py, llamacpp.py, mlx.py, lmstudio.py) with a single data-driven registration in `openai_compat_engines.py`.
### 3c. Refactor repetitive __init__.py imports
Replace 26 identical try-except blocks in channels/__init__.py (and similar in learning/, engine/) with loop-based auto-imports using `importlib.import_module()`.
## 4. Test & CI Impact
- Update tests importing from `openjarvis.memory.*` to `openjarvis.tools.storage.*`
- Delete `tests/memory/` (duplicates `tests/tools/` storage tests)
- Update engine tests for consolidated module
- Update CI workflows referencing moved paths
- Update `pyproject.toml` entry points
- All ~3240 tests must pass after each commit
## 5. Commit Sequence
1. Clean git artifacts (get-pip.py, site/, results/) + update .gitignore
2. Move Docker files to deploy/docker/
3. Move recipes/ TOML data into src/openjarvis/recipes/data/
4. Move templates/ TOML data into src/openjarvis/templates/data/
5. Move skills/ TOML data into src/openjarvis/skills/data/
6. Move operators/ TOML data into src/openjarvis/operators/data/
7. Move evals/ into src/openjarvis/evals/
8. Remove memory/ shim package + update imports
9. Consolidate engine wrappers into single file
10. Refactor repetitive __init__.py imports
11. Update CLAUDE.md to reflect new structure
@@ -1,782 +0,0 @@
# Codebase Simplification Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Simplify the OpenJarvis repository structure by reducing root sprawl, moving data files into the package, removing backward-compat shims, and consolidating repetitive code patterns.
**Architecture:** Incremental commits, each independently testable. Data TOML files move into `src/openjarvis/*/data/` as package data. The `evals/` framework becomes `src/openjarvis/evals/` with all internal imports rewritten from `evals.` to `openjarvis.evals.`. The `memory/` shim package is removed. Engine wrappers are consolidated.
**Tech Stack:** Python, TOML, hatch/hatchling build system, pytest, ruff
---
### Task 1: Remove get-pip.py from git tracking
**Files:**
- Delete: `get-pip.py`
- Modify: `.gitignore`
**Step 1: Add get-pip.py to .gitignore**
In `.gitignore`, add after the `# Project` section (after line 44):
```
get-pip.py
```
**Step 2: Remove get-pip.py from git tracking and delete**
Run:
```bash
git rm get-pip.py
```
**Step 3: Commit**
```bash
git add .gitignore
git commit -m "chore: remove get-pip.py from repository (2.2MB regenerable artifact)"
```
---
### Task 2: Move Docker files to deploy/docker/
**Files:**
- Move: `Dockerfile` -> `deploy/docker/Dockerfile`
- Move: `Dockerfile.gpu` -> `deploy/docker/Dockerfile.gpu`
- Move: `Dockerfile.gpu.rocm` -> `deploy/docker/Dockerfile.gpu.rocm`
- Move: `Dockerfile.sandbox` -> `deploy/docker/Dockerfile.sandbox`
- Move: `docker-compose.yml` -> `deploy/docker/docker-compose.yml`
- Move: `docker-compose.gpu.rocm.yml` -> `deploy/docker/docker-compose.gpu.rocm.yml`
- Modify: `deploy/docker/docker-compose.yml` (update dockerfile paths)
- Modify: `deploy/docker/docker-compose.gpu.rocm.yml` (update dockerfile path)
**Step 1: Move all Docker files**
```bash
mv Dockerfile deploy/docker/Dockerfile
mv Dockerfile.gpu deploy/docker/Dockerfile.gpu
mv Dockerfile.gpu.rocm deploy/docker/Dockerfile.gpu.rocm
mv Dockerfile.sandbox deploy/docker/Dockerfile.sandbox
mv docker-compose.yml deploy/docker/docker-compose.yml
mv docker-compose.gpu.rocm.yml deploy/docker/docker-compose.gpu.rocm.yml
```
**Step 2: Update docker-compose.yml build contexts**
In `deploy/docker/docker-compose.yml`, change the `build` section from:
```yaml
build:
context: .
dockerfile: Dockerfile
```
to:
```yaml
build:
context: ../..
dockerfile: deploy/docker/Dockerfile
```
**Step 3: Update docker-compose.gpu.rocm.yml**
In `deploy/docker/docker-compose.gpu.rocm.yml`, change:
```yaml
dockerfile: Dockerfile.gpu.rocm
```
to:
```yaml
context: ../..
dockerfile: deploy/docker/Dockerfile.gpu.rocm
```
**Step 4: Commit**
```bash
git add -A deploy/docker/
git add -A Dockerfile* docker-compose*
git commit -m "refactor: move Docker files to deploy/docker/"
```
---
### Task 3: Move recipes/ TOML data into package
**Files:**
- Move: `recipes/*.toml` -> `src/openjarvis/recipes/data/*.toml`
- Move: `recipes/operators/*.toml` -> `src/openjarvis/recipes/data/operators/*.toml`
- Move: `recipes/operators/*.md` -> `src/openjarvis/recipes/data/operators/*.md`
- Modify: `src/openjarvis/recipes/loader.py` (lines 15-16, update `_PROJECT_RECIPES_DIR`)
- Modify: `pyproject.toml` (add package data include)
- Test: `tests/recipes/` (run existing tests)
**Step 1: Create data directory and move files**
```bash
mkdir -p src/openjarvis/recipes/data/operators
mv recipes/*.toml src/openjarvis/recipes/data/
mv recipes/operators/*.toml src/openjarvis/recipes/data/operators/
mv recipes/operators/*.md src/openjarvis/recipes/data/operators/
rmdir recipes/operators
rmdir recipes
```
**Step 2: Update loader.py**
In `src/openjarvis/recipes/loader.py`, change line 16 from:
```python
_PROJECT_RECIPES_DIR = Path(__file__).resolve().parents[3] / "recipes"
```
to:
```python
_PROJECT_RECIPES_DIR = Path(__file__).resolve().parent / "data"
```
**Step 3: Run tests**
```bash
uv run pytest tests/recipes/ -v
```
Expected: All recipe tests pass.
**Step 4: Commit**
```bash
git add src/openjarvis/recipes/data/ src/openjarvis/recipes/loader.py
git add recipes/
git commit -m "refactor: move recipes/ TOML data into src/openjarvis/recipes/data/"
```
---
### Task 4: Move templates/ agent TOML data into package
**Files:**
- Move: `templates/agents/*.toml` -> `src/openjarvis/templates/data/*.toml`
- Modify: `src/openjarvis/templates/agent_templates.py` (lines 68-71, update `_builtin_templates_dir`)
- Test: `tests/templates/` (run existing tests)
**Step 1: Create data directory and move files**
```bash
mkdir -p src/openjarvis/templates/data
mv templates/agents/*.toml src/openjarvis/templates/data/
rmdir templates/agents
rmdir templates
```
**Step 2: Update agent_templates.py**
In `src/openjarvis/templates/agent_templates.py`, change lines 68-71 from:
```python
def _builtin_templates_dir() -> Path:
"""Return the path to the built-in templates shipped with the package."""
# templates/agents/ at project root, 3 levels above this file
return Path(__file__).resolve().parents[3] / "templates" / "agents"
```
to:
```python
def _builtin_templates_dir() -> Path:
"""Return the path to the built-in templates shipped with the package."""
return Path(__file__).resolve().parent / "data"
```
**Step 3: Run tests**
```bash
uv run pytest tests/templates/ -v
```
Expected: All template tests pass.
**Step 4: Commit**
```bash
git add src/openjarvis/templates/data/ src/openjarvis/templates/agent_templates.py
git add templates/
git commit -m "refactor: move templates/agents/ TOML data into src/openjarvis/templates/data/"
```
---
### Task 5: Move skills/builtin/ TOML data into package
**Files:**
- Move: `skills/builtin/*.toml` -> `src/openjarvis/skills/data/*.toml`
- Modify: `tests/skills/test_bundled_skills.py` (line 12, update `BUILTIN_DIR` path)
- Test: `tests/skills/` (run existing tests)
**Step 1: Create data directory and move files**
```bash
mkdir -p src/openjarvis/skills/data
mv skills/builtin/*.toml src/openjarvis/skills/data/
rmdir skills/builtin
rmdir skills
```
**Step 2: Update test_bundled_skills.py**
In `tests/skills/test_bundled_skills.py`, change line 12 from:
```python
BUILTIN_DIR = Path(__file__).resolve().parents[2] / "skills" / "builtin"
```
to:
```python
BUILTIN_DIR = Path(__file__).resolve().parents[2] / "src" / "openjarvis" / "skills" / "data"
```
**Step 3: Run tests**
```bash
uv run pytest tests/skills/ -v
```
Expected: All skill tests pass (20 bundled skills found).
**Step 4: Commit**
```bash
git add src/openjarvis/skills/data/ tests/skills/test_bundled_skills.py
git add skills/
git commit -m "refactor: move skills/builtin/ TOML data into src/openjarvis/skills/data/"
```
---
### Task 6: Move operators/ TOML data into package
**Files:**
- Move: `operators/*.toml` -> `src/openjarvis/operators/data/*.toml`
- Modify: `src/openjarvis/cli/operators_cmd.py` (lines 35, 247, 274 — replace `Path("operators")` with package data path)
- Test: `tests/operators/` (run existing tests)
**Step 1: Create data directory and move files**
```bash
mkdir -p src/openjarvis/operators/data
mv operators/*.toml src/openjarvis/operators/data/
rmdir operators
```
**Step 2: Update operators_cmd.py**
The CLI currently checks `Path("operators")` (cwd-relative). We need to replace this with the package data directory. In `src/openjarvis/cli/operators_cmd.py`:
Add a helper at the top of the file (after imports):
```python
def _builtin_operators_dir() -> Path:
"""Return the path to built-in operator manifests shipped with the package."""
return Path(__file__).resolve().parents[1] / "operators" / "data"
```
Then update the three places that reference `Path("operators")`:
Line 35: Change `local_ops = Path("operators")` to `local_ops = _builtin_operators_dir()`
Line 247: Change `dirs = [DEFAULT_CONFIG_DIR / "operators", Path("operators")]` to `dirs = [DEFAULT_CONFIG_DIR / "operators", _builtin_operators_dir()]`
Line 274: Change `for d in [DEFAULT_CONFIG_DIR / "operators", Path("operators")]:` to `for d in [DEFAULT_CONFIG_DIR / "operators", _builtin_operators_dir()]:`
**Step 3: Run tests**
```bash
uv run pytest tests/operators/ -v
```
Expected: All operator tests pass.
**Step 4: Commit**
```bash
git add src/openjarvis/operators/data/ src/openjarvis/cli/operators_cmd.py
git add operators/
git commit -m "refactor: move operators/ TOML data into src/openjarvis/operators/data/"
```
---
### Task 7: Move evals/ into src/openjarvis/evals/
This is the most complex migration. The `evals/` directory is a standalone Python package with internal imports like `from evals.core.config import ...`. All these must be rewritten to `from openjarvis.evals.core.config import ...`.
**Files:**
- Move: `evals/` -> `src/openjarvis/evals/` (entire directory tree)
- Modify: All `*.py` files in `src/openjarvis/evals/` (rewrite `from evals.` to `from openjarvis.evals.`)
- Modify: `src/openjarvis/cli/eval_cmd.py` (rewrite `from evals.` imports)
- Modify: `tests/evals/*.py` (rewrite `from evals.` imports)
- Modify: `pyproject.toml` (update ruff per-file-ignores paths)
**Step 1: Move the directory**
```bash
# Move evals/ into src/openjarvis/evals/
# First remove any existing __pycache__
find evals/ -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null
mv evals/ src/openjarvis/evals/
```
**Step 2: Rewrite all internal imports in evals/**
Run a find-and-replace across all Python files in `src/openjarvis/evals/`:
```bash
find src/openjarvis/evals/ -name "*.py" -exec sed -i 's/from evals\./from openjarvis.evals./g; s/import evals\./import openjarvis.evals./g' {} +
```
**Step 3: Rewrite imports in eval_cmd.py**
In `src/openjarvis/cli/eval_cmd.py`, replace all occurrences of:
- `from evals.core.config import` -> `from openjarvis.evals.core.config import`
- `from evals.cli import` -> `from openjarvis.evals.cli import`
- `from evals.core.types import` -> `from openjarvis.evals.core.types import`
**Step 4: Rewrite imports in tests/evals/**
Run find-and-replace across test files:
```bash
find tests/evals/ -name "*.py" -exec sed -i 's/from evals\./from openjarvis.evals./g; s/import evals\./import openjarvis.evals./g' {} +
```
**Step 5: Update pyproject.toml**
Change lines 134-136 from:
```toml
[tool.ruff.lint.per-file-ignores]
"evals/datasets/*.py" = ["E501"]
"evals/scorers/*.py" = ["E501"]
```
to:
```toml
[tool.ruff.lint.per-file-ignores]
"src/openjarvis/evals/datasets/*.py" = ["E501"]
"src/openjarvis/evals/scorers/*.py" = ["E501"]
```
**Step 6: Update __main__.py**
In `src/openjarvis/evals/__main__.py`, the import was rewritten in step 2 but verify it now reads:
```python
"""Allow running as ``python -m openjarvis.evals``."""
from openjarvis.evals.cli import main
if __name__ == "__main__":
main()
```
**Step 7: Run tests**
```bash
uv run pytest tests/evals/ -v
```
Expected: All eval tests pass.
**Step 8: Lint check**
```bash
uv run ruff check src/openjarvis/evals/ tests/evals/ src/openjarvis/cli/eval_cmd.py
```
Expected: No import errors.
**Step 9: Commit**
```bash
git add src/openjarvis/evals/ tests/evals/ src/openjarvis/cli/eval_cmd.py pyproject.toml
git commit -m "refactor: move evals/ into src/openjarvis/evals/ as proper subpackage"
```
---
### Task 8: Remove memory/ backward-compat shim package
**Files:**
- Delete: `src/openjarvis/memory/` (entire directory, 11 files)
- Modify: `src/openjarvis/cli/memory_cmd.py` (lines 15-16)
- Modify: `src/openjarvis/cli/ask.py` (lines 130, 304)
- Modify: `src/openjarvis/sdk.py` (lines 36, 59-60, 391, 431)
- Modify: `tests/tools/test_storage_stubs.py` (lines 68-69, 80)
- Modify: `tests/tools/test_retrieval.py` (line 7)
- Modify: `tests/cli/test_memory_cmd.py` (line 12)
- Modify: `tests/cli/test_ask_context.py` (lines 34, 55)
- Modify: `tests/test_integration_extended.py` (lines 402, 419)
- Delete: `tests/memory/` (entire directory — these test the shim, not the real backends)
**Step 1: Update src/ imports**
In `src/openjarvis/cli/memory_cmd.py`, change:
```python
from openjarvis.memory.chunking import ChunkConfig
from openjarvis.memory.ingest import ingest_path
```
to:
```python
from openjarvis.tools.storage.chunking import ChunkConfig
from openjarvis.tools.storage.ingest import ingest_path
```
In `src/openjarvis/cli/ask.py`, change all occurrences of:
- `from openjarvis.memory.context import` -> `from openjarvis.tools.storage.context import`
- `from openjarvis.memory.sqlite import` -> `from openjarvis.tools.storage.sqlite import`
In `src/openjarvis/sdk.py`, change:
- `from openjarvis.memory.sqlite import SqliteMemory` -> `from openjarvis.tools.storage.sqlite import SQLiteMemory as SqliteMemory`
- `from openjarvis.memory.chunking import ChunkConfig` -> `from openjarvis.tools.storage.chunking import ChunkConfig`
- `from openjarvis.memory.ingest import ingest_path` -> `from openjarvis.tools.storage.ingest import ingest_path`
- `from openjarvis.memory.context import ContextConfig, inject_context` -> `from openjarvis.tools.storage.context import ContextConfig, inject_context`
**Step 2: Update test imports**
In `tests/tools/test_storage_stubs.py`, change:
- `from openjarvis.memory._stubs import MemoryBackend as MB` -> `from openjarvis.tools.storage._stubs import MemoryBackend as MB`
- `from openjarvis.memory._stubs import RetrievalResult as RR` -> `from openjarvis.tools.storage._stubs import RetrievalResult as RR`
- `from openjarvis.memory.sqlite import SQLiteMemory as S2` -> `from openjarvis.tools.storage.sqlite import SQLiteMemory as S2`
In `tests/tools/test_retrieval.py`, change:
- `from openjarvis.memory._stubs import MemoryBackend, RetrievalResult` -> `from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult`
In `tests/cli/test_memory_cmd.py`, change:
- `from openjarvis.memory.sqlite import SQLiteMemory` -> `from openjarvis.tools.storage.sqlite import SQLiteMemory`
In `tests/cli/test_ask_context.py`, change:
- `from openjarvis.memory.sqlite import SQLiteMemory` -> `from openjarvis.tools.storage.sqlite import SQLiteMemory`
In `tests/test_integration_extended.py`, change:
- `from openjarvis.memory.sqlite import SQLiteMemory` -> `from openjarvis.tools.storage.sqlite import SQLiteMemory`
- `from openjarvis.memory.bm25 import BM25Memory` -> `from openjarvis.tools.storage.bm25 import BM25Memory`
**Step 3: Update tests/memory/ imports and move to tests/tools/**
The `tests/memory/` files test the same backends as `tests/tools/test_storage_*.py`. Since they import from the shim, we need to rewrite their imports and merge them. However, since `tests/tools/` already has storage tests, the simplest approach is:
Rewrite all `tests/memory/*.py` imports from `openjarvis.memory.*` to `openjarvis.tools.storage.*`:
```bash
find tests/memory/ -name "*.py" -exec sed -i 's/from openjarvis\.memory\./from openjarvis.tools.storage./g' {} +
```
Then move the directory to be a subdirectory of tests/tools:
```bash
mv tests/memory tests/tools/memory_tests
```
Actually, the cleanest approach: just rewrite imports in-place and keep `tests/memory/` as-is. The tests are valuable and test different aspects than `tests/tools/`.
**Step 4: Delete the shim package**
```bash
rm -rf src/openjarvis/memory/
```
**Step 5: Run full test suite**
```bash
uv run pytest tests/memory/ tests/tools/ tests/cli/ tests/sdk/ -v
```
Expected: All tests pass.
**Step 6: Commit**
```bash
git add -A src/openjarvis/memory/ src/openjarvis/cli/memory_cmd.py src/openjarvis/cli/ask.py src/openjarvis/sdk.py
git add tests/memory/ tests/tools/ tests/cli/ tests/test_integration_extended.py
git commit -m "refactor: remove memory/ backward-compat shims, use tools.storage directly"
```
---
### Task 9: Consolidate engine wrappers into single file
**Files:**
- Delete: `src/openjarvis/engine/vllm.py`
- Delete: `src/openjarvis/engine/sglang.py`
- Delete: `src/openjarvis/engine/llamacpp.py`
- Delete: `src/openjarvis/engine/mlx.py`
- Delete: `src/openjarvis/engine/lmstudio.py`
- Create: `src/openjarvis/engine/openai_compat_engines.py`
- Modify: `src/openjarvis/engine/__init__.py` (update imports)
- Test: Run `tests/engine/` tests
**Step 1: Write the consolidated engine file**
Create `src/openjarvis/engine/openai_compat_engines.py`:
```python
"""Data-driven registration of OpenAI-compatible inference engines."""
from openjarvis.core.registry import EngineRegistry
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
_ENGINES = {
"vllm": ("VLLMEngine", "http://localhost:8000"),
"sglang": ("SGLangEngine", "http://localhost:30000"),
"llamacpp": ("LlamaCppEngine", "http://localhost:8080"),
"mlx": ("MLXEngine", "http://localhost:8080"),
"lmstudio": ("LMStudioEngine", "http://localhost:1234"),
}
for _key, (_cls_name, _default_host) in _ENGINES.items():
_cls = type(
_cls_name,
(_OpenAICompatibleEngine,),
{"engine_id": _key, "_default_host": _default_host},
)
EngineRegistry.register(_key)(_cls)
globals()[_cls_name] = _cls
__all__ = [name for name, _ in _ENGINES.values()]
```
**Step 2: Update engine/__init__.py**
Replace lines 5-11 (the 5 individual imports) with a single import:
Change from:
```python
import openjarvis.engine.llamacpp # noqa: F401
import openjarvis.engine.mlx # noqa: F401
# Import engine modules to trigger @EngineRegistry.register() decorators
import openjarvis.engine.ollama # noqa: F401
import openjarvis.engine.sglang # noqa: F401
import openjarvis.engine.vllm # noqa: F401
```
To:
```python
# Import engine modules to trigger @EngineRegistry.register() decorators
import openjarvis.engine.ollama # noqa: F401
import openjarvis.engine.openai_compat_engines # noqa: F401
```
**Step 3: Delete the 5 individual wrapper files**
```bash
rm src/openjarvis/engine/vllm.py
rm src/openjarvis/engine/sglang.py
rm src/openjarvis/engine/llamacpp.py
rm src/openjarvis/engine/mlx.py
rm src/openjarvis/engine/lmstudio.py
```
**Step 4: Run tests**
```bash
uv run pytest tests/engine/ -v
```
Expected: All engine tests pass.
**Step 5: Commit**
```bash
git add src/openjarvis/engine/
git commit -m "refactor: consolidate 5 OpenAI-compat engine wrappers into single data-driven file"
```
---
### Task 10: Refactor repetitive __init__.py imports
**Files:**
- Modify: `src/openjarvis/channels/__init__.py`
- Modify: `src/openjarvis/engine/__init__.py` (optional engines section)
- Test: Run `tests/channels/` and `tests/engine/` tests
**Step 1: Refactor channels/__init__.py**
Replace the entire file content with:
```python
"""Channel abstraction for multi-platform messaging."""
import importlib
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelMessage,
ChannelStatus,
)
# Trigger registration of built-in channels.
# Each module uses @ChannelRegistry.register() — importing is sufficient.
_CHANNEL_MODULES = [
"telegram",
"discord_channel",
"slack",
"webhook",
"email_channel",
"whatsapp",
"signal_channel",
"google_chat",
"irc_channel",
"webchat",
"teams",
"matrix_channel",
"mattermost",
"feishu",
"bluebubbles",
"whatsapp_baileys",
"line_channel",
"viber_channel",
"messenger_channel",
"reddit_channel",
"mastodon_channel",
"xmpp_channel",
"rocketchat_channel",
"zulip_channel",
"twitch_channel",
"nostr_channel",
]
for _mod in _CHANNEL_MODULES:
try:
importlib.import_module(f".{_mod}", __name__)
except ImportError:
pass
__all__ = [
"BaseChannel",
"ChannelHandler",
"ChannelMessage",
"ChannelStatus",
]
```
**Step 2: Refactor optional engine imports in engine/__init__.py**
Replace lines 19-29 with:
```python
# Optional engines — only register if their SDK deps are present
for _optional in ("cloud", "litellm"):
try:
importlib.import_module(f".{_optional}", __name__)
except ImportError:
pass
```
And add `import importlib` at the top.
**Step 3: Run tests**
```bash
uv run pytest tests/channels/ tests/engine/ -v
```
Expected: All tests pass.
**Step 4: Commit**
```bash
git add src/openjarvis/channels/__init__.py src/openjarvis/engine/__init__.py
git commit -m "refactor: replace repetitive try/except imports with loop-based auto-registration"
```
---
### Task 11: Update pyproject.toml package data includes
**Files:**
- Modify: `pyproject.toml`
**Step 1: Add package data includes**
The TOML data files that moved into `src/openjarvis/` need to be included in wheel builds. In `pyproject.toml`, after the existing `[tool.hatch.build.targets.wheel.force-include]` section, add the data directories.
Actually, since all the data directories are under `src/openjarvis/` and `packages = ["src/openjarvis"]` is already set, hatch will include them automatically as long as they are inside the package tree. But TOML files are non-Python files, so we need to tell hatch to include them.
Add to pyproject.toml after line 114:
```toml
[tool.hatch.build.targets.wheel.shared-data]
[tool.hatch.build]
include = [
"src/openjarvis/**/*.py",
"src/openjarvis/**/*.toml",
"src/openjarvis/**/*.md",
"src/openjarvis/agents/claude_code_runner/**",
"src/openjarvis/channels/whatsapp_baileys_bridge/**",
]
```
Wait — hatchling by default includes all files under `packages`. Non-Python files inside the package are included by default. No change needed here. Verify with:
```bash
uv run python -c "from pathlib import Path; d = Path('src/openjarvis/recipes/data'); print(list(d.glob('*.toml')))"
```
**Step 2: Commit if any changes were needed**
If no pyproject.toml changes needed, skip this task.
---
### Task 12: Update CLAUDE.md to reflect new structure
**Files:**
- Modify: `CLAUDE.md`
**Step 1: Update all references to moved paths**
In CLAUDE.md, make the following updates:
1. Update the `python -m evals` command references to `python -m openjarvis.evals`
2. Remove references to top-level `recipes/`, `templates/`, `skills/`, `operators/` directories
3. Update Docker command references to use `deploy/docker/` paths
4. Update the architecture section to reflect that data files are now package data
5. Remove mentions of `memory/` backward-compat shims
6. Remove the 5 individual engine wrapper files from architecture description
**Step 2: Run linting to verify no broken references**
```bash
uv run ruff check src/ tests/
```
**Step 3: Run full test suite**
```bash
uv run pytest tests/ -v --tb=short 2>&1 | tail -20
```
Expected: ~3240 tests pass.
**Step 4: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md to reflect simplified repository structure"
```
---
### Task 13: Final verification
**Step 1: Run full test suite**
```bash
uv run pytest tests/ -v --tb=short 2>&1 | tail -30
```
Expected: ~3240 tests pass, ~44 skipped.
**Step 2: Run linter**
```bash
uv run ruff check src/ tests/
```
Expected: Clean.
**Step 3: Verify root structure**
```bash
ls -la | grep -v __pycache__ | grep -v '^\.'
```
Expected: 21 or fewer items at root (down from 32).
**Step 4: Verify package data is accessible**
```bash
uv run python -c "
from openjarvis.recipes.loader import discover_recipes
from openjarvis.templates.agent_templates import discover_templates
print(f'Recipes: {len(discover_recipes())}')
print(f'Templates: {len(discover_templates())}')
print('All data accessible.')
"
```
Expected: Recipes: 3, Templates: 15, All data accessible.
@@ -1,280 +0,0 @@
# Speech-to-Text Design
**Date:** 2026-03-03
**Status:** Approved
**Scope:** Add voice input (speech-to-text) to OpenJarvis — desktop app and browser
## Overview
Add a new Speech subsystem to OpenJarvis that lets users speak commands instead of typing them. The system transcribes audio to text using local open-source models (Faster-Whisper) by default, with cloud backends (OpenAI, Deepgram) as alternatives. Transcribed text is inserted into the input box for user review before sending.
## Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Scope | STT only (no TTS) | Ship voice input first; TTS follows later |
| Runtime | Separate process | Avoid VRAM conflicts with the LLM engine |
| Surfaces | Desktop app + browser | Both use the same React frontend |
| UX mode | Record-then-transcribe | Simpler to implement, works with all backends |
| Architecture | New Speech subsystem | Fits OpenJarvis patterns (ABC + registry + decorator) |
## Architecture
### New Module: `src/openjarvis/speech/`
```
src/openjarvis/speech/
├── __init__.py # Imports, ensure_registered()
├── _stubs.py # SpeechBackend ABC, TranscriptionResult dataclass
├── faster_whisper.py # FasterWhisperBackend (local, default)
├── whisper_cpp.py # WhisperCppBackend (local, llama.cpp ecosystem)
├── openai_whisper.py # OpenAIWhisperBackend (cloud)
├── deepgram.py # DeepgramBackend (cloud)
└── _discovery.py # Auto-discover available backend (local preferred)
```
### Core Types (`_stubs.py`)
```python
@dataclass
class Segment:
text: str
start: float # Start time in seconds
end: float # End time in seconds
confidence: float | None
@dataclass
class TranscriptionResult:
text: str # The transcribed text
language: str | None # Detected language code (e.g., "en")
confidence: float | None # Overall confidence [0, 1]
duration_seconds: float # Audio duration
segments: list[Segment] # Word/phrase-level timing (optional)
class SpeechBackend(ABC):
backend_id: str
@abstractmethod
def transcribe(self, audio: bytes, *, format: str = "wav",
language: str | None = None) -> TranscriptionResult: ...
@abstractmethod
def health(self) -> bool: ...
@abstractmethod
def supported_formats(self) -> list[str]: ...
```
### Registry
New `SpeechRegistry` added to `core/registry.py` using `RegistryBase[T]`. Backends register via `@SpeechRegistry.register("faster-whisper")`.
### Discovery (`_discovery.py`)
Priority order (local-first):
1. Faster-Whisper (if `faster-whisper` package installed)
2. WhisperCpp (if `whisper-cpp-python` package installed)
3. OpenAI Whisper API (if `OPENAI_API_KEY` set)
4. Deepgram (if `DEEPGRAM_API_KEY` set)
Function: `get_speech_backend(config) -> SpeechBackend | None`
### Config
New `[speech]` section in `JarvisConfig`:
```toml
[speech]
backend = "auto" # "auto", "faster-whisper", "whisper-cpp", "openai", "deepgram"
model = "base" # Whisper model size: tiny, base, small, medium, large-v3
language = "" # Empty = auto-detect
device = "auto" # "auto", "cpu", "cuda"
compute_type = "float16" # "float16", "int8", "float32"
```
New `SpeechConfig` dataclass in `core/config.py`.
## API Layer
### New Endpoints
```
POST /v1/speech/transcribe
Content-Type: multipart/form-data
Body: audio file (field name: "file")
Optional form fields: language, model
Response 200: {
"text": "Hello, what's the weather like?",
"language": "en",
"confidence": 0.94,
"duration_seconds": 2.3
}
GET /v1/speech/backends
Response 200: {
"backends": ["faster-whisper"],
"active": "faster-whisper",
"model": "base"
}
GET /v1/speech/health
Response 200: {"available": true, "backend": "faster-whisper", "model": "base"}
Response 200: {"available": false, "reason": "No speech backend installed"}
```
### Server Wiring
- `SystemBuilder.with_speech(backend=..., model=...)` — configure speech
- `JarvisSystem.speech` — active `SpeechBackend` instance or `None`
- Speech backend initializes lazily on first `transcribe()` call
- Routes added to `server/api_routes.py`
## Frontend Integration
### Web Frontend (`frontend/src/`)
**New component: `MicButton.tsx`**
- Sits next to the Send button in `InputArea.tsx`
- States: idle (mic icon), recording (pulsing red), transcribing (spinner)
- Uses `MediaRecorder` API to capture audio
- Sends audio blob as multipart to `/v1/speech/transcribe`
- Appends transcribed text to input textarea
- Hidden if `/v1/speech/health` returns `available: false`
**New hook: `useSpeech.ts`**
- Manages microphone permissions, `MediaRecorder` lifecycle
- `startRecording()`, `stopRecording()`, `isRecording`, `isTranscribing`, `error`
- Checks `navigator.mediaDevices` support
**Changes to existing components:**
- `InputArea.tsx` — add `MicButton` next to send button
- `App.tsx` — check speech availability on load
### Desktop App (`desktop/`)
Same React UI works in the Tauri WebView.
**New Tauri command: `transcribe_audio`** in `lib.rs` — proxies multipart POST to backend for cases where WebView fetch has CORS issues.
### Audio Format
Record as WebM/Opus (native browser format). Faster-Whisper handles WebM directly. Server-side conversion to WAV as fallback if a backend requires it.
### User Flow
```
User clicks mic button
→ Browser requests microphone permission (first time)
→ Recording starts (button pulses red)
User clicks mic button again (or releases)
→ Recording stops
→ Button shows spinner
→ Audio blob sent to POST /v1/speech/transcribe
→ Response text inserted into input textarea
→ User reviews/edits text
→ User clicks Send (normal flow)
```
## Backend Implementations
### Faster-Whisper (Default Local)
```python
@SpeechRegistry.register("faster-whisper")
class FasterWhisperBackend(SpeechBackend):
backend_id = "faster-whisper"
# Uses CTranslate2-based Faster-Whisper (4x faster than original Whisper)
# Lazy model loading on first transcribe() call
# Model sizes: tiny (39M), base (74M), small (244M), medium (769M), large-v3 (1.5B)
# GPU: device="cuda", compute_type="float16" or "int8"
# CPU: device="cpu", compute_type="int8"
```
Optional dep: `openjarvis[speech]``faster-whisper>=1.0`
### OpenAI Whisper API (Cloud)
```python
@SpeechRegistry.register("openai")
class OpenAIWhisperBackend(SpeechBackend):
backend_id = "openai"
# Uses openai.audio.transcriptions.create()
# Requires OPENAI_API_KEY
# Uses existing openai dependency
```
### Deepgram (Cloud)
```python
@SpeechRegistry.register("deepgram")
class DeepgramBackend(SpeechBackend):
backend_id = "deepgram"
# Uses deepgram-sdk
# Requires DEEPGRAM_API_KEY
```
Optional dep: `openjarvis[speech-deepgram]``deepgram-sdk>=3.0`
## Error Handling
| Scenario | Behavior |
|----------|----------|
| Mic permission denied | Toast error in frontend, no crash |
| No speech backend available | Mic button hidden, health endpoint returns `available: false` |
| Audio too short / silence | Return empty text with low confidence |
| Model loading failure | Health returns false, error logged |
| Network failure (cloud) | Error response, frontend shows retry option |
| Unsupported audio format | Server converts to WAV, or returns 400 with message |
## Testing
### Backend Tests
- `tests/speech/test_faster_whisper.py` — mock `faster_whisper` import, test transcribe with synthetic WAV
- `tests/speech/test_openai_whisper.py` — mock `openai` client, test transcribe
- `tests/speech/test_deepgram.py` — mock `deepgram` client, test transcribe
- `tests/speech/test_discovery.py` — test auto-discovery priority order
### API Tests
- `tests/server/test_speech_routes.py` — test endpoints with mocked backend
### Config Tests
- Test `SpeechConfig` defaults, TOML parsing, `[speech]` section
All optional-dep backends behind `pytest.importorskip()`.
## Optional Dependencies
```toml
[project.optional-dependencies]
speech = ["faster-whisper>=1.0"]
speech-deepgram = ["deepgram-sdk>=3.0"]
```
## Files Changed (Estimated)
### New Files (~12)
- `src/openjarvis/speech/__init__.py`
- `src/openjarvis/speech/_stubs.py`
- `src/openjarvis/speech/faster_whisper.py`
- `src/openjarvis/speech/openai_whisper.py`
- `src/openjarvis/speech/deepgram.py`
- `src/openjarvis/speech/_discovery.py`
- `frontend/src/components/MicButton.tsx`
- `frontend/src/hooks/useSpeech.ts`
- `tests/speech/test_faster_whisper.py`
- `tests/speech/test_openai_whisper.py`
- `tests/speech/test_deepgram.py`
- `tests/speech/test_discovery.py`
- `tests/server/test_speech_routes.py`
### Modified Files (~8)
- `src/openjarvis/core/registry.py` — add `SpeechRegistry`
- `src/openjarvis/core/config.py` — add `SpeechConfig`, `[speech]` section
- `src/openjarvis/system.py` — add `with_speech()`, wire speech backend
- `src/openjarvis/server/api_routes.py` — add speech endpoints
- `frontend/src/components/InputArea.tsx` — add MicButton
- `frontend/src/App.tsx` — check speech availability
- `desktop/src-tauri/src/lib.rs` — add `transcribe_audio` command
- `pyproject.toml` — add `[speech]` and `[speech-deepgram]` extras
- `tests/conftest.py` — add SpeechRegistry to `_clean_registries`
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
/* ── Fonts ─────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&family=Inter:wght@400;500;600;700&display=swap');
:root {
--md-text-font: "Merriweather", Georgia, "Times New Roman", serif;
--md-code-font: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
}
/* ── Color Overrides (Light) ──────────────────────────────────────── */
[data-md-color-scheme="default"] {
--md-primary-fg-color: #2b2b2b;
--md-primary-fg-color--light: #444444;
--md-primary-fg-color--dark: #1a1a1a;
--md-accent-fg-color: #3d5a80;
--md-accent-fg-color--transparent: rgba(61, 90, 128, 0.07);
--md-default-bg-color: #f8f7f4;
--md-default-fg-color: #2b2b2b;
--md-default-fg-color--light: #666666;
--md-typeset-a-color: #3d5a80;
--md-code-bg-color: #f0efeb;
}
/* ── Color Overrides (Dark) ───────────────────────────────────────── */
[data-md-color-scheme="slate"] {
--md-primary-fg-color: #1a1a1a;
--md-primary-fg-color--light: #2b2b2b;
--md-primary-fg-color--dark: #0f0f0f;
--md-accent-fg-color: #8da9c4;
--md-accent-fg-color--transparent: rgba(141, 169, 196, 0.1);
--md-default-bg-color: #1a1a1a;
--md-default-fg-color: #e8e6e1;
--md-default-fg-color--light: #999999;
--md-typeset-a-color: #8da9c4;
--md-code-bg-color: #222222;
}
/* ── Global Typography ───────────────────────────────────────────── */
.md-typeset {
font-size: 0.92rem;
line-height: 1.85;
font-weight: 300;
}
.md-typeset strong {
font-weight: 700;
}
/* Nav and UI chrome use a clean sans-serif */
.md-header,
.md-tabs,
.md-nav,
.md-footer,
.md-search__input,
.md-source {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.md-nav__link,
.md-tabs__link {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 500;
font-size: 0.8rem;
letter-spacing: 0.01em;
}
/* ── Hero Section (DSPy-style: clean, italic emphasis) ───────────── */
.md-typeset h1 {
font-size: 2.4rem;
font-weight: 900;
letter-spacing: -0.025em;
line-height: 1.2;
margin-bottom: 1.5rem;
color: var(--md-default-fg-color);
}
.md-typeset h1 em {
font-style: italic;
color: var(--md-accent-fg-color);
}
.hero-tagline {
font-size: 1.05rem;
color: var(--md-default-fg-color--light);
max-width: 600px;
line-height: 1.8;
font-weight: 300;
margin-bottom: 2rem;
}
/* Prominent install command (like DSPy's pip install) */
.install-cmd {
display: inline-block;
background: var(--md-code-bg-color);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
padding: 0.6rem 1.2rem;
font-family: var(--md-code-font);
font-size: 0.85rem;
letter-spacing: 0;
color: var(--md-default-fg-color);
margin: 1rem 0 2rem 0;
}
[data-md-color-scheme="slate"] .install-cmd {
border-color: rgba(255, 255, 255, 0.08);
}
/* ── Section Headers ──────────────────────────────────────────────── */
.md-typeset h2 {
font-weight: 700;
font-size: 1.4rem;
letter-spacing: -0.015em;
margin-top: 3.5rem;
margin-bottom: 1.5rem;
padding-top: 2rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.md-typeset h2:first-child,
.md-typeset hr + h2 {
border-top: none;
padding-top: 0;
}
[data-md-color-scheme="slate"] .md-typeset h2 {
border-top-color: rgba(255, 255, 255, 0.08);
}
.md-typeset h3 {
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
margin-top: 2rem;
}
.md-typeset h4 {
font-weight: 600;
font-size: 0.92rem;
}
/* ── Feature Cards (clean, minimal like OA/DSPy) ─────────────────── */
.md-typeset .grid.cards > ol,
.md-typeset .grid.cards > ul {
gap: 0.85rem;
}
.md-typeset .grid.cards > ol > li,
.md-typeset .grid.cards > ul > li {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 1.5rem 1.75rem;
transition: border-color 0.2s ease, background 0.2s ease;
background: var(--md-default-bg-color);
}
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ol > li,
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ul > li {
border-color: rgba(255, 255, 255, 0.1);
background: #222222;
}
.md-typeset .grid.cards > ol > li:hover,
.md-typeset .grid.cards > ul > li:hover {
border-color: var(--md-accent-fg-color);
background: var(--md-accent-fg-color--transparent);
}
.md-typeset .grid.cards > ol > li > :first-child,
.md-typeset .grid.cards > ul > li > :first-child {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 0.92rem;
letter-spacing: -0.01em;
}
/* ── Code Blocks ──────────────────────────────────────────────────── */
.md-typeset code {
border-radius: 4px;
font-size: 0.82em;
}
.md-typeset pre {
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
[data-md-color-scheme="slate"] .md-typeset pre {
border-color: rgba(255, 255, 255, 0.08);
}
.md-typeset pre > code {
font-size: 0.82rem;
line-height: 1.7;
}
/* ── Tabs ─────────────────────────────────────────────────────────── */
.md-typeset .tabbed-labels > label {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 500;
font-size: 0.82rem;
letter-spacing: 0.01em;
}
/* ── Buttons (clean, bordered like OA) ───────────────────────────── */
.md-typeset .md-button {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 500;
font-size: 0.85rem;
border-radius: 6px;
padding: 0.55rem 1.5rem;
letter-spacing: 0.01em;
transition: all 0.2s ease;
}
.md-typeset .md-button--primary {
background: var(--md-default-fg-color);
border-color: var(--md-default-fg-color);
color: var(--md-default-bg-color);
}
.md-typeset .md-button--primary:hover {
background: var(--md-accent-fg-color);
border-color: var(--md-accent-fg-color);
}
/* ── Navigation ───────────────────────────────────────────────────── */
.md-tabs {
background: var(--md-primary-fg-color);
}
.md-header {
box-shadow: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
[data-md-color-scheme="slate"] .md-header {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
/* ── Admonitions ──────────────────────────────────────────────────── */
.md-typeset .admonition,
.md-typeset details {
border-radius: 6px;
border-width: 1px;
border-left-width: 4px;
box-shadow: none;
font-size: 0.88rem;
}
/* ── Footer ───────────────────────────────────────────────────────── */
.md-footer {
background: var(--md-primary-fg-color--dark);
}
/* ── Pill Badges ─────────────────────────────────────────────────── */
.pill {
display: inline-block;
padding: 0.2rem 0.7rem;
border-radius: 100px;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--md-accent-fg-color--transparent);
color: var(--md-accent-fg-color);
margin-bottom: 1rem;
}
/* ── Divider ──────────────────────────────────────────────────────── */
.md-typeset hr {
border-color: rgba(0, 0, 0, 0.08);
margin: 3rem 0;
}
[data-md-color-scheme="slate"] .md-typeset hr {
border-color: rgba(255, 255, 255, 0.08);
}
/* ── Inline Code ─────────────────────────────────────────────────── */
.md-typeset :not(pre) > code {
transition: background 0.15s ease;
font-size: 0.8em;
padding: 0.15em 0.4em;
}
/* ── Links ───────────────────────────────────────────────────────── */
.md-typeset a {
transition: color 0.15s ease;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.md-typeset a:hover {
text-decoration: underline;
}
/* ── Content Width ───────────────────────────────────────────────── */
.md-content__inner {
max-width: 48rem;
padding-top: 2rem;
}
/* ── Tables ──────────────────────────────────────────────────────── */
.md-typeset table:not([class]) {
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.1);
font-size: 0.83rem;
}
[data-md-color-scheme="slate"] .md-typeset table:not([class]) {
border-color: rgba(255, 255, 255, 0.1);
}
.md-typeset table:not([class]) th {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Quick Start Tabs ─────────────────────────────────────────────── */
.md-typeset .tabbed-set {
margin-top: 1.5rem;
}
.md-typeset .tabbed-content {
padding-top: 0.75rem;
}
/* ── Lists (more breathing room) ──────────────────────────────────── */
.md-typeset ol,
.md-typeset ul {
line-height: 1.85;
}
.md-typeset li + li {
margin-top: 0.35rem;
}
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.md-typeset h1 {
font-size: 1.7rem;
}
.hero-tagline {
font-size: 0.95rem;
}
.install-cmd {
font-size: 0.78rem;
}
}
+4 -1
View File
@@ -3,10 +3,13 @@
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap" rel="stylesheet" />
<title>OpenJarvis</title>
</head>
<body>
+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();
}
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"
}
}
}
+81 -91
View File
@@ -1,103 +1,93 @@
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, useState, useCallback } 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 { GetStartedPage } from './pages/GetStartedPage';
import { CommandPalette } from './components/CommandPalette';
import { SetupScreen } from './components/SetupScreen';
import { useAppStore } from './lib/store';
import { fetchModels, fetchServerInfo, fetchSavings, isTauri } 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 [setupDone, setSetupDone] = useState(!isTauri());
const handleSetupReady = useCallback(() => setSetupDone(true), []);
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
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
// Global keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setCommandPaletteOpen(!commandPaletteOpen);
}
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
e.preventDefault();
toggleSystemPanel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [commandPaletteOpen, setCommandPaletteOpen, toggleSystemPanel]);
if (!setupDone) {
return <SetupScreen onReady={handleSetupReady} />;
}
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 path="get-started" element={<GetStartedPage />} />
</Route>
</Routes>
{commandPaletteOpen && <CommandPalette />}
</>
);
}
-52
View File
@@ -1,52 +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();
}
export interface TranscriptionResult {
text: string;
language: string | null;
confidence: number | null;
duration_seconds: number;
}
export interface SpeechHealth {
available: boolean;
backend?: string;
reason?: string;
}
export async function transcribeAudio(audioBlob: Blob, filename = 'recording.webm'): Promise<TranscriptionResult> {
const formData = new FormData();
formData.append('file', audioBlob, filename);
const res = await fetch(`${BASE}/v1/speech/transcribe`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
return res.json();
}
export async function fetchSpeechHealth(): Promise<SpeechHealth> {
const res = await fetch(`${BASE}/v1/speech/health`);
if (!res.ok) return { available: false };
return res.json();
}
+79 -66
View File
@@ -1,75 +1,88 @@
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, PanelRightOpen, PanelRightClose } 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 systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
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;
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
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>
)}
{totalTokens > 0 && (
<div className="chat-header-tokens">
{totalPrompt.toLocaleString()} in / {totalCompletion.toLocaleString()} out
</div>
)}
</div>
<div className="flex flex-col h-full">
{/* Toggle bar */}
<div className="flex items-center justify-end px-3 py-1.5 shrink-0">
<button
onClick={toggleSystemPanel}
className="p-1.5 rounded-md transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title={`${systemPanelOpen ? 'Hide' : 'Show'} system panel (${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+I)`}
>
<PanelIcon size={16} />
</button>
</div>
<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>
<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>
<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>
<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>
);
}
+254 -143
View File
@@ -1,186 +1,297 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Send, Square, Paperclip } from 'lucide-react';
import { useAppStore, generateId } from '../../lib/store';
import { streamChat } from '../../lib/sse';
import { fetchSavings } from '../../lib/api';
import { MicButton } from './MicButton';
import { useSpeech } from '../../hooks/useSpeech';
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 speechEnabled = useAppStore((s) => s.settings.speechEnabled);
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 { state: speechState, available: speechAvailable, startRecording, stopRecording, error: speechError } = useSpeech();
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
const micDisabled = !speechEnabled || !speechAvailable || streamState.isStreaming;
const micReason: 'not-enabled' | 'no-backend' | 'streaming' | undefined =
!speechEnabled ? 'not-enabled'
: !speechAvailable ? 'no-backend'
: streamState.isStreaming ? 'streaming'
: undefined;
const handleMicClick = useCallback(async () => {
if (speechState === 'recording') {
try {
const text = await stopRecording();
if (text) {
setTyped((prev) => (prev ? prev + ' ' + text : text));
setInput((prev) => (prev ? prev + ' ' + text : text));
}
} catch {
// Error is captured in speechError
// Error is captured in useSpeech
}
} else {
await startRecording();
}
}, [speechState, startRecording, stopRecording]);
const handleSend = useCallback(() => {
if (!fullMessage.trim() || isStreaming) return;
onSend(fullMessage);
setAttachment('');
setTyped('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [fullMessage, isStreaming, onSend]);
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, [input]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const stopStreaming = useCallback(() => {
abortRef.current?.abort();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
resetStream();
}, [resetStream]);
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[] = [];
let lastFlush = 0;
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, phase: '' });
const now = Date.now();
if (now - lastFlush >= 80) {
updateLastAssistant(
convId,
accumulatedContent,
toolCalls.length > 0 ? [...toolCalls] : undefined,
);
lastFlush = now;
}
}
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;
fetchSavings()
.then((data) => useAppStore.getState().setSavings(data))
.catch(() => {});
}
}, [
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>
) : (
<div style={{ display: 'flex', gap: '4px' }}>
{speechAvailable && (
<MicButton
state={speechState}
onClick={handleMicClick}
/>
)}
<div className="flex items-center gap-1">
<MicButton
state={speechState}
onClick={handleMicClick}
disabled={micDisabled}
reason={micReason}
/>
<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>
<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>
);
}
+129 -25
View File
@@ -1,41 +1,145 @@
import { useState, useMemo } 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 {
function stripThinkTags(text: string): string {
let cleaned = text.replace(/<think>[\s\S]*?<\/think>\s*/gi, '');
cleaned = cleaned.replace(/^[\s\S]*?<\/think>\s*/i, '');
return cleaned.trim();
}
interface Props {
message: ChatMessage;
}
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>
);
}
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
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 */}
{cleanContent && (
<div className="prose max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code: CodeBlock,
}}
>
{cleanContent}
</ReactMarkdown>
</div>
)}
{/* Footer: usage + copy */}
<div className="flex items-center gap-2 mt-1.5 min-h-[24px]">
<CopyMessageButton content={cleanContent} />
{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>
);
}
+66 -39
View File
@@ -1,53 +1,80 @@
import { useState } from 'react';
import type { SpeechState } from '../../hooks/useSpeech';
interface MicButtonProps {
state: SpeechState;
onClick: () => void;
disabled?: boolean;
reason?: 'not-enabled' | 'no-backend' | 'streaming';
}
export function MicButton({ state, onClick, disabled }: MicButtonProps) {
const title =
state === 'recording'
? 'Stop recording'
: state === 'transcribing'
? 'Transcribing...'
: 'Voice input';
export function MicButton({ state, onClick, disabled, reason }: MicButtonProps) {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipText =
reason === 'not-enabled'
? 'Enable in Settings'
: reason === 'no-backend'
? 'Speech backend not configured'
: reason === 'streaming'
? 'Wait for response'
: state === 'recording'
? 'Stop recording'
: state === 'transcribing'
? 'Transcribing...'
: 'Voice input';
const isInactive = disabled || state === 'transcribing';
return (
<button
className={`mic-btn ${state !== 'idle' ? `mic-${state}` : ''}`}
onClick={onClick}
disabled={disabled || state === 'transcribing'}
title={title}
style={{
background: state === 'recording' ? '#e74c3c' : 'transparent',
border: '1px solid var(--border, #555)',
borderRadius: '8px',
padding: '8px',
cursor: disabled || state === 'transcribing' ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '36px',
height: '36px',
color: state === 'recording' ? '#fff' : 'var(--text, #cdd6f4)',
opacity: disabled || state === 'transcribing' ? 0.5 : 1,
animation: state === 'recording' ? 'pulse 1.5s ease-in-out infinite' : 'none',
}}
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{state === 'transcribing' ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="28" strokeDashoffset="10">
<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="1s" repeatCount="indefinite" />
</circle>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z" />
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" />
</svg>
<button
onClick={onClick}
disabled={isInactive}
className="p-2 rounded-xl transition-all shrink-0"
style={{
background: state === 'recording'
? 'var(--color-error)'
: 'transparent',
color: state === 'recording'
? 'white'
: isInactive
? 'var(--color-text-tertiary)'
: 'var(--color-text-secondary)',
cursor: isInactive ? 'default' : 'pointer',
opacity: isInactive ? 0.35 : 1,
animation: state === 'recording' ? 'pulse 1.5s ease-in-out infinite' : 'none',
}}
>
{state === 'transcribing' ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="28" strokeDashoffset="10">
<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="1s" repeatCount="indefinite" />
</circle>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z" />
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" />
</svg>
)}
</button>
{showTooltip && isInactive && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1.5 rounded-lg text-xs whitespace-nowrap pointer-events-none"
style={{
background: 'var(--color-text)',
color: 'var(--color-bg)',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
>
{tooltipText}
</div>
)}
</button>
</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,273 @@
import { useState, useEffect, useCallback } from 'react';
import {
Zap,
Activity,
Thermometer,
DollarSign,
TrendingDown,
Cloud,
HardDrive,
Hash,
X,
} from 'lucide-react';
import { useAppStore } from '../../lib/store';
interface EnergyData {
total_energy_j?: number;
energy_per_token_j?: number;
avg_power_w?: number;
}
interface TelemetryStats {
total_requests?: number;
total_tokens?: number;
}
const CLOUD_PRICING = [
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
];
export function SystemPanel() {
const savings = useAppStore((s) => s.savings);
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
const [energy, setEnergy] = useState<EnergyData | null>(null);
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
const fetchData = useCallback(async () => {
try {
const base = import.meta.env.VITE_API_URL || '';
const [energyRes, telRes] = await Promise.allSettled([
fetch(`${base}/v1/telemetry/energy`).then((r) => (r.ok ? r.json() : null)),
fetch(`${base}/v1/telemetry/stats`).then((r) => (r.ok ? r.json() : null)),
]);
if (energyRes.status === 'fulfilled' && energyRes.value) {
setEnergy(energyRes.value as EnergyData);
}
if (telRes.status === 'fulfilled' && telRes.value) {
setTelemetry(telRes.value as TelemetryStats);
}
} catch {
// best-effort
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 3000);
return () => clearInterval(interval);
}, [fetchData]);
// Re-fetch energy/telemetry when savings updates (after a chat message)
useEffect(() => {
if (savings) fetchData();
}, [savings, fetchData]);
const thermalStatus =
(energy?.avg_power_w ?? 0) < 50
? { label: 'Cool', color: 'var(--color-success)' }
: (energy?.avg_power_w ?? 0) < 150
? { label: 'Warm', color: 'var(--color-warning)' }
: { label: 'Hot', color: 'var(--color-error)' };
const promptK = (savings?.total_prompt_tokens ?? 0) / 1000;
const completionK = (savings?.total_completion_tokens ?? 0) / 1000;
return (
<div
className="flex flex-col h-full overflow-y-auto"
style={{
width: 280,
minWidth: 280,
background: 'var(--color-bg)',
borderLeft: '1px solid var(--color-border)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<span className="text-xs font-semibold tracking-wide uppercase" style={{ color: 'var(--color-text-secondary)' }}>
System
</span>
<button
onClick={toggleSystemPanel}
className="p-1 rounded-md transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title="Close panel"
>
<X size={14} />
</button>
</div>
<div className="flex flex-col gap-4 p-4">
{/* Session Stats */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Session
</h4>
<div className="grid grid-cols-2 gap-2">
<MiniStat icon={Hash} label="Requests" value={String(telemetry?.total_requests ?? savings?.total_calls ?? 0)} />
<MiniStat icon={Activity} label="Tokens" value={formatNumber(telemetry?.total_tokens ?? savings?.total_tokens ?? 0)} />
</div>
</section>
{/* Energy */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Energy
</h4>
<div className="grid grid-cols-2 gap-2">
<MiniStat
icon={Zap}
label="Total"
value={((energy?.total_energy_j ?? 0) / 1000).toFixed(1)}
unit="kJ"
/>
<MiniStat
icon={Activity}
label="Per Token"
value={(energy?.energy_per_token_j ?? 0).toFixed(3)}
unit="J"
/>
<MiniStat
icon={Thermometer}
label="Avg Power"
value={(energy?.avg_power_w ?? 0).toFixed(1)}
unit="W"
/>
<div
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: thermalStatus.color }} />
<span className="text-[11px]" style={{ color: 'var(--color-text-secondary)' }}>
{thermalStatus.label}
</span>
</div>
</div>
</section>
{/* Cost Comparison */}
<section>
<h4 className="text-[11px] font-medium uppercase tracking-wide mb-2" style={{ color: 'var(--color-text-tertiary)' }}>
Cost Comparison
</h4>
{/* Local */}
<div
className="flex items-center gap-2 rounded-lg px-3 py-2 mb-2"
style={{ background: 'var(--color-accent-subtle)', border: '1px solid var(--color-accent)' }}
>
<HardDrive size={14} style={{ color: 'var(--color-accent)' }} />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate" style={{ color: 'var(--color-text)' }}>Local</div>
</div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-success)' }}>
${(savings?.local_cost ?? 0).toFixed(4)}
</div>
</div>
{/* Cloud providers */}
<div className="flex flex-col gap-1.5">
{CLOUD_PRICING.map((provider) => {
const cost = (promptK * provider.input) / 1000 + (completionK * provider.output) / 1000;
const saved = cost - (savings?.local_cost ?? 0);
return (
<div
key={provider.name}
className="flex items-center gap-2 rounded-lg px-3 py-2"
style={{
background: provider.primary ? 'var(--color-bg-secondary)' : 'var(--color-bg-secondary)',
border: provider.primary ? '1px solid var(--color-border-accent, var(--color-accent))' : '1px solid transparent',
}}
>
<Cloud size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<div className="flex-1 min-w-0">
<div
className="text-xs truncate"
style={{
color: provider.primary ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontWeight: provider.primary ? 500 : 400,
}}
>
{provider.name}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-mono" style={{ color: 'var(--color-text)' }}>
${cost.toFixed(4)}
</div>
{saved > 0.0001 && (
<div className="text-[9px] flex items-center gap-0.5 justify-end" style={{ color: 'var(--color-success)' }}>
<TrendingDown size={8} />
${saved.toFixed(4)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Server-reported savings */}
{savings && savings.per_provider.length > 0 && (
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--color-border)' }}>
<div className="text-[10px] mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Server-reported
</div>
{savings.per_provider.map((p) => (
<div key={p.provider} className="flex justify-between text-[11px] py-0.5">
<span style={{ color: 'var(--color-text-secondary)' }}>{p.label}</span>
<span className="font-mono" style={{ color: 'var(--color-success)' }}>${p.total_cost.toFixed(4)}</span>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
function MiniStat({
icon: Icon,
label,
value,
unit,
}: {
icon: typeof Zap;
label: string;
value: string;
unit?: string;
}) {
return (
<div
className="rounded-lg px-2.5 py-2"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-1 mb-0.5">
<Icon size={10} style={{ color: 'var(--color-accent)' }} />
<span className="text-[10px]" style={{ color: 'var(--color-text-tertiary)' }}>
{label}
</span>
</div>
<div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{value}
{unit && (
<span className="text-[10px] font-normal ml-0.5" style={{ color: 'var(--color-text-tertiary)' }}>
{unit}
</span>
)}
</div>
</div>
);
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
@@ -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-5.3', input: 2.00, output: 10.00 },
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
];
export function CostComparison() {
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>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect, useCallback } from 'react';
import { Loader2, CheckCircle2, XCircle, Cpu, Server, Database } from 'lucide-react';
import { getSetupStatus, type SetupStatus } from '../lib/api';
const STEPS = [
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
{ key: 'model_ready', label: 'AI Model', icon: Database, detail: 'Loading model...' },
{ key: 'server_ready', label: 'API Server', icon: Server, detail: 'Starting server...' },
] as const;
type StepKey = (typeof STEPS)[number]['key'];
function StepRow({
icon: Icon,
label,
done,
active,
detail,
}: {
icon: typeof Cpu;
label: string;
done: boolean;
active: boolean;
detail: string;
}) {
return (
<div
className="flex items-center gap-4 px-5 py-4 rounded-xl transition-all"
style={{
background: done
? 'var(--color-accent-subtle)'
: active
? 'var(--color-surface)'
: 'transparent',
border: active ? '1px solid var(--color-border)' : '1px solid transparent',
}}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
style={{
background: done ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
color: done ? 'white' : 'var(--color-text-tertiary)',
}}
>
<Icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{label}
</div>
<div className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{done ? 'Ready' : active ? detail : 'Waiting...'}
</div>
</div>
<div className="shrink-0">
{done ? (
<CheckCircle2 size={18} style={{ color: 'var(--color-accent)' }} />
) : active ? (
<Loader2 size={18} className="animate-spin" style={{ color: 'var(--color-accent)' }} />
) : (
<div
className="w-4 h-4 rounded-full"
style={{ border: '2px solid var(--color-border)' }}
/>
)}
</div>
</div>
);
}
export function SetupScreen({ onReady }: { onReady: () => void }) {
const [status, setStatus] = useState<SetupStatus | null>(null);
const poll = useCallback(async () => {
const s = await getSetupStatus();
if (s) setStatus(s);
if (s?.phase === 'ready') {
setTimeout(onReady, 600);
}
}, [onReady]);
useEffect(() => {
poll();
const interval = setInterval(poll, 800);
return () => clearInterval(interval);
}, [poll]);
const activeStep: StepKey | null =
status && !status.ollama_ready
? 'ollama_ready'
: status && !status.model_ready
? 'model_ready'
: status && !status.server_ready
? 'server_ready'
: null;
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ background: 'var(--color-bg)' }}
>
<div className="w-full max-w-md px-6">
{/* Logo */}
<div className="text-center mb-10">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Cpu size={32} />
</div>
<h1 className="text-2xl font-bold mb-1" style={{ color: 'var(--color-text)' }}>
OpenJarvis
</h1>
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Setting up your local AI...
</p>
</div>
{/* Steps */}
<div className="flex flex-col gap-2 mb-8">
{STEPS.map((step) => (
<StepRow
key={step.key}
icon={step.icon}
label={step.label}
done={status?.[step.key] ?? false}
active={activeStep === step.key}
detail={
activeStep === step.key && status?.detail
? status.detail
: step.detail
}
/>
))}
</div>
{/* Error */}
{status?.error && (
<div
className="flex items-start gap-3 px-4 py-3 rounded-xl text-sm"
style={{
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#ef4444',
}}
>
<XCircle size={16} className="shrink-0 mt-0.5" />
<span>{status.error}</span>
</div>
)}
{/* Progress bar */}
{!status?.error && (
<div
className="h-1 rounded-full overflow-hidden"
style={{ background: 'var(--color-bg-tertiary)' }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
background: 'var(--color-accent)',
width: `${
((status?.ollama_ready ? 1 : 0) +
(status?.model_ready ? 1 : 0) +
(status?.server_ready ? 1 : 0)) *
33.33
}%`,
}}
/>
</div>
)}
</div>
</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>
);
}
+159 -48
View File
@@ -1,55 +1,166 @@
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,
Rocket,
} 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' },
{ path: '/get-started', icon: Rocket, label: 'Get Started' },
];
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;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { transcribeAudio, fetchSpeechHealth } from '../api/client';
import { transcribeAudio, fetchSpeechHealth } from '../lib/api';
export type SpeechState = 'idle' | 'recording' | 'transcribing';
+329
View File
@@ -0,0 +1,329 @@
@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: "Merriweather", Georgia, "Times New Roman", serif;
font-weight: 400;
line-height: 1.7;
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.75;
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;
}
}
+179
View File
@@ -0,0 +1,179 @@
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
}
export const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
const DESKTOP_API = 'http://127.0.0.1:8222';
const getBase = () => {
if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL;
if (isTauri()) return DESKTOP_API;
return '';
};
async function tauriInvoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
const { invoke } = await import('@tauri-apps/api/core');
const apiUrl = getBase();
return invoke<T>(command, { apiUrl, ...args });
}
// ---------------------------------------------------------------------------
// Setup status (desktop only)
// ---------------------------------------------------------------------------
export interface SetupStatus {
phase: string;
detail: string;
ollama_ready: boolean;
server_ready: boolean;
model_ready: boolean;
error: string | null;
}
export async function getSetupStatus(): Promise<SetupStatus | null> {
if (!isTauri()) return null;
try {
const { invoke } = await import('@tauri-apps/api/core');
return await invoke<SetupStatus>('get_setup_status');
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
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 {
await tauriInvoke('check_health', { apiUrl: getBase() });
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 {
return await tauriInvoke('fetch_energy', { apiUrl: getBase() });
} 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 {
return await tauriInvoke('fetch_telemetry', { apiUrl: getBase() });
} 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 {
return await tauriInvoke('fetch_traces', { apiUrl: getBase(), limit });
} catch {}
}
const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Speech
// ---------------------------------------------------------------------------
export interface TranscriptionResult {
text: string;
language: string | null;
confidence: number | null;
duration_seconds: number;
}
export interface SpeechHealth {
available: boolean;
backend?: string;
reason?: string;
}
export async function transcribeAudio(audioBlob: Blob, filename = 'recording.webm'): Promise<TranscriptionResult> {
if (isTauri()) {
try {
const buffer = await audioBlob.arrayBuffer();
return await tauriInvoke<TranscriptionResult>('transcribe_audio', {
audioData: Array.from(new Uint8Array(buffer)),
filename,
});
} catch {
// Fall through to fetch
}
}
const formData = new FormData();
formData.append('file', audioBlob, filename);
const res = await fetch(`${getBase()}/v1/speech/transcribe`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
return res.json();
}
export async function fetchSpeechHealth(): Promise<SpeechHealth> {
if (isTauri()) {
try {
return await tauriInvoke<SpeechHealth>('speech_health');
} catch {
return { available: false };
}
}
const res = await fetch(`${getBase()}/v1/speech/health`);
if (!res.ok) return { available: false };
return res.json();
}
@@ -1,4 +1,5 @@
import type { SSEEvent } from '../types';
import { isTauri } from './api';
export interface ChatRequest {
model: string;
@@ -6,11 +7,14 @@ export interface ChatRequest {
stream: true;
}
const DESKTOP_API = 'http://127.0.0.1:8222';
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 || (isTauri() ? DESKTOP_API : '');
const response = await fetch(`${base}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
@@ -41,9 +45,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() === '') {
+329
View File
@@ -0,0 +1,329 @@
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;
speechEnabled: boolean;
}
function loadSettings(): Settings {
const defaults: Settings = {
theme: 'system',
apiUrl: '',
fontSize: 'default',
defaultModel: '',
defaultAgent: '',
temperature: 0.7,
maxTokens: 4096,
speechEnabled: false,
};
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;
// System panel
systemPanelOpen: 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;
toggleSystemPanel: () => void;
setSystemPanelOpen: (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,
systemPanelOpen: 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 }),
toggleSystemPanel: () => set((s) => ({ systemPanelOpen: !s.systemPanelOpen })),
setSystemPanelOpen: (open: boolean) => set({ systemPanelOpen: 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>,
);
+16
View File
@@ -0,0 +1,16 @@
import { ChatArea } from '../components/Chat/ChatArea';
import { SystemPanel } from '../components/Chat/SystemPanel';
import { useAppStore } from '../lib/store';
export function ChatPage() {
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
return (
<div className="flex h-full overflow-hidden">
<div className="flex-1 min-w-0">
<ChatArea />
</div>
{systemPanelOpen && <SystemPanel />}
</div>
);
}
+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>
);
}
+489
View File
@@ -0,0 +1,489 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router';
import {
Sparkles,
Download,
Terminal,
Globe,
Monitor,
Apple,
ChevronDown,
ChevronRight,
Copy,
Check,
Cpu,
CheckCircle2,
MessageSquare,
ArrowRight,
} from 'lucide-react';
import { isTauri, checkHealth } from '../lib/api';
const GITHUB_BASE =
'https://github.com/hazy/OpenJarvis/releases/latest/download';
interface Platform {
id: string;
label: string;
shortLabel: string;
file: string;
icon: typeof Apple;
}
const PLATFORMS: Platform[] = [
{
id: 'mac-arm',
label: 'macOS (Apple Silicon)',
shortLabel: 'macOS (Apple Silicon)',
file: 'OpenJarvis_aarch64.dmg',
icon: Apple,
},
{
id: 'mac-intel',
label: 'macOS (Intel)',
shortLabel: 'macOS (Intel)',
file: 'OpenJarvis_x64.dmg',
icon: Apple,
},
{
id: 'windows',
label: 'Windows (64-bit)',
shortLabel: 'Windows (64-bit)',
file: 'OpenJarvis_x64-setup.msi',
icon: Monitor,
},
{
id: 'linux-deb',
label: 'Linux (DEB)',
shortLabel: 'Linux (DEB)',
file: 'OpenJarvis_amd64.deb',
icon: Terminal,
},
{
id: 'linux-rpm',
label: 'Linux (RPM)',
shortLabel: 'Linux (RPM)',
file: 'OpenJarvis_x86_64.rpm',
icon: Terminal,
},
];
type DeployContext = 'hosted' | 'desktop' | 'selfhosted';
function detectContext(): DeployContext {
if (isTauri()) return 'desktop';
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') {
return 'selfhosted';
}
return 'hosted';
}
function detectPlatform(): string {
const ua = navigator.userAgent.toLowerCase();
const platform = navigator.platform?.toLowerCase() || '';
if (platform.includes('mac') || ua.includes('macintosh')) {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
if (gl) {
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (ext) {
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
if (renderer && /apple m/i.test(renderer)) return 'mac-arm';
}
}
} catch {}
return 'mac-arm';
}
if (platform.includes('win') || ua.includes('windows')) return 'windows';
if (ua.includes('ubuntu') || ua.includes('debian')) return 'linux-deb';
if (ua.includes('linux')) return 'linux-deb';
return 'mac-arm';
}
function CodeBlock({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className="relative group rounded-lg px-4 py-3 text-sm font-mono overflow-x-auto"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text)' }}
>
<pre className="whitespace-pre-wrap break-all">{code}</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text-tertiary)' }}
title="Copy"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
);
}
function Section({
icon: Icon,
title,
children,
defaultOpen = false,
}: {
icon: typeof Terminal;
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div
className="rounded-xl overflow-hidden"
style={{ border: '1px solid var(--color-border)' }}
>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-3 w-full px-5 py-4 text-left cursor-pointer transition-colors"
style={{ background: open ? 'var(--color-bg-secondary)' : 'var(--color-surface)' }}
onMouseEnter={(e) => {
if (!open) e.currentTarget.style.background = 'var(--color-bg-secondary)';
}}
onMouseLeave={(e) => {
if (!open) e.currentTarget.style.background = 'var(--color-surface)';
}}
>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Icon size={16} />
</div>
<span className="text-sm font-medium flex-1" style={{ color: 'var(--color-text)' }}>
{title}
</span>
<Chevron size={16} style={{ color: 'var(--color-text-tertiary)' }} />
</button>
{open && (
<div className="px-5 pb-5 pt-3 flex flex-col gap-3" style={{ background: 'var(--color-surface)' }}>
{children}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Hosted view: visitor on a deployed website
// ---------------------------------------------------------------------------
function HostedView() {
const navigate = useNavigate();
const [healthy, setHealthy] = useState<boolean | null>(null);
useEffect(() => {
checkHealth().then(setHealthy);
}, []);
return (
<div className="text-center mb-14">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-5"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
OpenJarvis
</h1>
<p
className="text-sm mb-6 leading-relaxed max-w-md mx-auto"
style={{ color: 'var(--color-text-secondary)' }}
>
Private AI that runs on your hardware. Chat, tools, agents, and
energy profiling &mdash; no cloud required.
</p>
{healthy === true && (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 size={16} />
<span>Server is running</span>
</div>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-xl text-sm font-medium transition-opacity cursor-pointer"
style={{ background: 'var(--color-accent)', color: 'white' }}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
>
<MessageSquare size={18} />
Start Chatting
<ArrowRight size={16} />
</button>
</div>
)}
{healthy === false && (
<div
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm"
style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444' }}
>
Server is not responding. The backend may be starting up.
</div>
)}
{healthy === null && (
<div className="text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
Checking server...
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Desktop view: running in the Tauri app
// ---------------------------------------------------------------------------
function DesktopView() {
const navigate = useNavigate();
return (
<>
<div className="text-center mb-14">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-5"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
OpenJarvis Desktop
</h1>
<p
className="text-sm mb-4 leading-relaxed max-w-md mx-auto"
style={{ color: 'var(--color-text-secondary)' }}
>
Your local AI is ready. Everything runs on your device &mdash; no
data leaves your machine.
</p>
<span
className="inline-block text-[11px] font-mono px-2.5 py-1 rounded-full"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
>
v2.8
</span>
</div>
<div
className="rounded-xl p-6 mb-8 text-center"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center justify-center gap-2 mb-2" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 size={18} />
<span className="text-sm font-medium">All systems running</span>
</div>
<p className="text-xs mb-5" style={{ color: 'var(--color-text-tertiary)' }}>
Ollama inference engine, API server, and AI model are active.
</p>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-xl text-sm font-medium transition-opacity cursor-pointer"
style={{ background: 'var(--color-accent)', color: 'white' }}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
>
<MessageSquare size={18} />
Start Chatting
<ArrowRight size={16} />
</button>
</div>
<div className="flex flex-col gap-3 mb-8">
<Section icon={Cpu} title="Keyboard Shortcuts" defaultOpen>
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<div><kbd className="font-mono px-1.5 py-0.5 rounded" style={{ background: 'var(--color-bg-tertiary)' }}>Cmd+K</kbd> Model picker</div>
<div><kbd className="font-mono px-1.5 py-0.5 rounded" style={{ background: 'var(--color-bg-tertiary)' }}>Cmd+I</kbd> System panel</div>
<div><kbd className="font-mono px-1.5 py-0.5 rounded" style={{ background: 'var(--color-bg-tertiary)' }}>Cmd+N</kbd> New chat</div>
</div>
</Section>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Self-hosted view: running on localhost (manual setup)
// ---------------------------------------------------------------------------
function SelfHostedView() {
const detectedId = useMemo(() => detectPlatform(), []);
const primary = PLATFORMS.find((p) => p.id === detectedId) || PLATFORMS[0];
const others = PLATFORMS.filter((p) => p.id !== primary.id);
return (
<>
{/* Hero */}
<div className="text-center mb-14">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-5"
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
>
<Sparkles size={32} />
</div>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
OpenJarvis
</h1>
<p
className="text-sm mb-4 leading-relaxed max-w-md mx-auto"
style={{ color: 'var(--color-text-secondary)' }}
>
Private AI that runs on your hardware. Chat, tools, agents, and
energy profiling &mdash; no cloud required.
</p>
<span
className="inline-block text-[11px] font-mono px-2.5 py-1 rounded-full"
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
>
v2.8
</span>
</div>
{/* Desktop Download */}
<div className="mb-10">
<div
className="rounded-xl p-8 text-center"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center justify-center gap-2 mb-1">
<Monitor size={18} style={{ color: 'var(--color-text-secondary)' }} />
<h2 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
Desktop App
</h2>
</div>
<p className="text-xs mb-6" style={{ color: 'var(--color-text-tertiary)' }}>
One-click install. Bundles Ollama and the server &mdash; no setup required.
</p>
<a
href={`${GITHUB_BASE}/${primary.file}`}
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-xl text-sm font-medium transition-opacity cursor-pointer"
style={{ background: 'var(--color-accent)', color: 'white' }}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')}
>
<Download size={18} />
Download for {primary.label}
</a>
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
Or
</span>
{others.map((p) => (
<a
key={p.id}
href={`${GITHUB_BASE}/${p.file}`}
className="text-[11px] underline underline-offset-2 transition-colors"
style={{ color: 'var(--color-text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-secondary)')}
>
{p.shortLabel}
</a>
))}
</div>
</div>
</div>
{/* CLI + Browser sections */}
<div className="flex flex-col gap-3 mb-10">
<Section icon={Terminal} title="Command Line (macOS / Linux)" defaultOpen>
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Install with pip (Python 3.10+ required):
</p>
<CodeBlock code="pip install openjarvis" />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Or with uv for faster installs:
</p>
<CodeBlock code="uv pip install openjarvis" />
<p className="text-xs mt-1" style={{ color: 'var(--color-text-secondary)' }}>
Then get started:
</p>
<CodeBlock code="jarvis init\njarvis doctor\njarvis chat" />
</Section>
<Section icon={Globe} title="Browser App (Self-Hosted)">
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Launch the API server to get the full UI in your browser:
</p>
<CodeBlock code="pip install 'openjarvis[server]'\njarvis serve --port 8000" />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
The chat, dashboard, energy profiling, and cost comparison all run
locally on your machine.
</p>
</Section>
<Section icon={Globe} title="Docker (Cloud / VPS Deploy)">
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Deploy with Docker Compose for a zero-setup hosted instance:
</p>
<CodeBlock code="git clone https://github.com/hazy/OpenJarvis.git\ncd OpenJarvis\ndocker compose -f deploy/docker/docker-compose.yml up -d" />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
This starts both the API server and Ollama. The web UI is bundled and
served automatically at port 8000.
</p>
</Section>
</div>
{/* System Requirements */}
<div
className="rounded-xl px-6 py-5"
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2 mb-3">
<Cpu size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<h3 className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--color-text-tertiary)' }}>
System Requirements
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<div>
<div className="font-medium mb-0.5" style={{ color: 'var(--color-text)' }}>Desktop App</div>
No prerequisites &mdash; everything is bundled
</div>
<div>
<div className="font-medium mb-0.5" style={{ color: 'var(--color-text)' }}>CLI / Self-Hosted</div>
Python 3.10+ and an inference engine (Ollama recommended)
</div>
<div>
<div className="font-medium mb-0.5" style={{ color: 'var(--color-text)' }}>Memory</div>
8 GB+ RAM recommended
</div>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Main page — delegates to the context-appropriate view
// ---------------------------------------------------------------------------
export function GetStartedPage() {
const context = useMemo(detectContext, []);
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-16">
{context === 'hosted' && <HostedView />}
{context === 'desktop' && <DesktopView />}
{context === 'selfhosted' && <SelfHostedView />}
</div>
</div>
);
}
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect } from 'react';
import {
Settings,
Palette,
Globe,
Cpu,
Database,
Info,
Check,
Sun,
Moon,
Monitor,
Download,
Upload,
Trash2,
Mic,
} from 'lucide-react';
import { useAppStore, type ThemeMode } from '../lib/store';
import { checkHealth, fetchSpeechHealth } 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 [speechBackendAvailable, setSpeechBackendAvailable] = useState<boolean | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
checkHealth().then(setHealthy);
fetchSpeechHealth()
.then((h) => setSpeechBackendAvailable(h.available))
.catch(() => setSpeechBackendAvailable(false));
}, []);
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>
{/* Speech */}
<Section title="Speech">
<SettingRow label="Speech-to-Text" description="Enable microphone input for voice dictation">
<button
onClick={() => { updateSettings({ speechEnabled: !settings.speechEnabled }); showSaved(); }}
className="relative w-11 h-6 rounded-full transition-colors cursor-pointer"
style={{
background: settings.speechEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
}}
>
<span
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform bg-white"
style={{
transform: settings.speechEnabled ? 'translateX(20px)' : 'translateX(0)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}
/>
</button>
</SettingRow>
<SettingRow label="Backend status" description="Requires Whisper, Deepgram, or another speech backend">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{
background: speechBackendAvailable === true ? 'var(--color-success)'
: speechBackendAvailable === false ? 'var(--color-text-tertiary)'
: 'var(--color-text-tertiary)',
}}
/>
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{speechBackendAvailable === null ? 'Checking...'
: speechBackendAvailable ? 'Available'
: 'Not configured'}
</span>
</div>
</SettingRow>
{!speechBackendAvailable && speechBackendAvailable !== null && (
<div className="text-xs mt-2 px-1" style={{ color: 'var(--color-text-tertiary)' }}>
Set up a speech backend to use voice input.
See the <a href="https://hazyresearch.stanford.edu/OpenJarvis/user-guide/tools/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--color-accent)' }}>documentation</a> for details.
</div>
)}
</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;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/api/client.ts","./src/api/sse.ts","./src/components/Chat/ChatArea.tsx","./src/components/Chat/CopyButton.tsx","./src/components/Chat/InputArea.tsx","./src/components/Chat/MessageBubble.tsx","./src/components/Chat/MessageList.tsx","./src/components/Chat/StreamingIndicator.tsx","./src/components/Chat/ToolCallIndicator.tsx","./src/components/Sidebar/ConversationList.tsx","./src/components/Sidebar/ModelSelector.tsx","./src/components/Sidebar/SavingsPanel.tsx","./src/components/Sidebar/Sidebar.tsx","./src/hooks/useChat.ts","./src/hooks/useConversations.ts","./src/hooks/useModels.ts","./src/hooks/useSavings.ts","./src/hooks/useServerInfo.ts","./src/storage/conversations.ts","./src/types/index.ts"],"version":"5.7.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/setupscreen.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/lib/api.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/getstartedpage.tsx","./src/pages/settingspage.tsx","./src/types/index.ts"],"version":"5.7.3"}
+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,
+11 -4
View File
@@ -11,6 +11,9 @@ copyright: Copyright &copy; 2026 OpenJarvis Contributors
theme:
name: material
language: en
font:
text: Merriweather
code: JetBrains Mono
features:
- navigation.tabs
- navigation.tabs.sticky
@@ -27,21 +30,24 @@ theme:
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: amber
primary: custom
accent: blue
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: amber
primary: custom
accent: blue
toggle:
icon: material/brightness-4
name: Switch to light mode
icon:
repo: fontawesome/brands/github
extra_css:
- stylesheets/extra.css
plugins:
- search
- mkdocstrings:
@@ -118,6 +124,7 @@ extra:
nav:
- Home: index.md
- Downloads: downloads.md
- Getting Started:
- Installation: getting-started/installation.md
- Quick Start: getting-started/quickstart.md
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
set -euo pipefail
# ── OpenJarvis Quickstart ─────────────────────────────────────────────
# One-command setup: installs deps, starts Ollama + model, launches
# the backend API server and frontend, then opens the browser.
#
# Usage:
# git clone https://github.com/HazyResearch/OpenJarvis.git
# cd OpenJarvis
# ./scripts/quickstart.sh
# ──────────────────────────────────────────────────────────────────────
BLUE='\033[0;34m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
BOLD='\033[1m'
info() { echo -e "${BLUE}[info]${NC} $*"; }
ok() { echo -e "${GREEN}[ok]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
fail() { echo -e "${RED}[fail]${NC} $*"; exit 1; }
CLEANUP_PIDS=()
cleanup() {
echo ""
info "Shutting down..."
for pid in "${CLEANUP_PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
ok "Done."
}
trap cleanup EXIT INT TERM
# ── Navigate to repo root ────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
echo -e "${BOLD}"
echo " ┌──────────────────────────────────┐"
echo " │ OpenJarvis Quickstart │"
echo " └──────────────────────────────────┘"
echo -e "${NC}"
# ── 1. Check Python ──────────────────────────────────────────────────
info "Checking Python..."
if command -v python3 &>/dev/null; then
PY_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 10 ]; then
ok "Python $PY_VERSION"
else
fail "Python 3.10+ required (found $PY_VERSION)"
fi
else
fail "Python 3 not found. Install from https://python.org"
fi
# ── 2. Check / install uv ───────────────────────────────────────────
info "Checking uv..."
if command -v uv &>/dev/null; then
ok "uv $(uv --version 2>/dev/null | head -1)"
else
warn "uv not found — installing..."
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
ok "uv installed"
fi
# ── 3. Check Node.js ────────────────────────────────────────────────
info "Checking Node.js..."
if command -v node &>/dev/null; then
NODE_VERSION=$(node --version)
NODE_MAJOR=$(echo "$NODE_VERSION" | sed 's/v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -ge 18 ]; then
ok "Node.js $NODE_VERSION"
else
fail "Node.js 18+ required (found $NODE_VERSION). Install from https://nodejs.org"
fi
else
fail "Node.js not found. Install from https://nodejs.org"
fi
# ── 4. Check / install Ollama ────────────────────────────────────────
info "Checking Ollama..."
if command -v ollama &>/dev/null; then
ok "Ollama found"
else
warn "Ollama not found — installing..."
case "$(uname -s)" in
Darwin)
if command -v brew &>/dev/null; then
brew install ollama
else
echo " Download Ollama from https://ollama.com/download"
echo " Then re-run this script."
exit 1
fi
;;
Linux)
curl -fsSL https://ollama.com/install.sh | sh
;;
*)
echo " Download Ollama from https://ollama.com/download"
echo " Then re-run this script."
exit 1
;;
esac
ok "Ollama installed"
fi
# ── 5. Start Ollama if not running ───────────────────────────────────
info "Checking if Ollama is running..."
if curl -sf http://localhost:11434/api/tags &>/dev/null; then
ok "Ollama is running"
else
info "Starting Ollama..."
ollama serve &>/dev/null &
CLEANUP_PIDS+=($!)
sleep 3
if curl -sf http://localhost:11434/api/tags &>/dev/null; then
ok "Ollama started"
else
fail "Could not start Ollama. Try running 'ollama serve' manually."
fi
fi
# ── 6. Pull a starter model ─────────────────────────────────────────
MODEL="${OPENJARVIS_MODEL:-qwen3:0.6b}"
info "Ensuring model '$MODEL' is available..."
if ollama list 2>/dev/null | grep -q "$MODEL"; then
ok "Model '$MODEL' already pulled"
else
info "Pulling '$MODEL' (this may take a minute)..."
ollama pull "$MODEL"
ok "Model '$MODEL' ready"
fi
# ── 7. Install Python dependencies ──────────────────────────────────
info "Installing Python dependencies..."
uv sync --extra server --quiet 2>/dev/null || uv sync --extra server
ok "Python dependencies installed"
# ── 8. Install frontend dependencies ────────────────────────────────
info "Installing frontend dependencies..."
(cd frontend && npm install --silent 2>/dev/null || npm install)
ok "Frontend dependencies installed"
# ── 9. Start backend ────────────────────────────────────────────────
info "Starting backend API server on port 8000..."
uv run jarvis serve --port 8000 &>/dev/null &
CLEANUP_PIDS+=($!)
sleep 3
if curl -sf http://localhost:8000/health &>/dev/null; then
ok "Backend running at http://localhost:8000"
else
warn "Backend may still be starting..."
fi
# ── 10. Start frontend ──────────────────────────────────────────────
info "Starting frontend dev server on port 5173..."
(cd frontend && npm run dev) &>/dev/null &
CLEANUP_PIDS+=($!)
sleep 3
ok "Frontend running at http://localhost:5173"
# ── 11. Open browser ────────────────────────────────────────────────
URL="http://localhost:5173"
info "Opening $URL ..."
case "$(uname -s)" in
Darwin) open "$URL" ;;
Linux) xdg-open "$URL" 2>/dev/null || true ;;
*) true ;;
esac
echo ""
echo -e "${GREEN}${BOLD} OpenJarvis is running!${NC}"
echo ""
echo " Chat UI: http://localhost:5173"
echo " API: http://localhost:8000"
echo " Model: $MODEL"
echo ""
echo " Press Ctrl+C to stop all services."
echo ""
wait
+26 -2
View File
@@ -211,6 +211,8 @@ class OrchestratorAgent(ToolUsingAgent):
all_tool_results: list[ToolResult] = []
turns = 0
total_prompt_tokens = 0
total_completion_tokens = 0
for _turn in range(self._max_turns):
turns += 1
@@ -222,17 +224,28 @@ class OrchestratorAgent(ToolUsingAgent):
result = self._generate(messages, **gen_kwargs)
# Accumulate token usage
usage = result.get("usage", {})
total_prompt_tokens += usage.get("prompt_tokens", 0)
total_completion_tokens += usage.get("completion_tokens", 0)
content = result.get("content", "")
raw_tool_calls = result.get("tool_calls", [])
# No tool calls -> check continuation, then final answer
if not raw_tool_calls:
content = self._check_continuation(result, messages)
content = self._strip_think_tags(content)
self._emit_turn_end(turns=turns, content_length=len(content))
return AgentResult(
content=content,
tool_results=all_tool_results,
turns=turns,
metadata={
"prompt_tokens": total_prompt_tokens,
"completion_tokens": total_completion_tokens,
"total_tokens": total_prompt_tokens + total_completion_tokens,
},
)
# Build ToolCall objects from raw dicts
@@ -286,8 +299,19 @@ class OrchestratorAgent(ToolUsingAgent):
))
# Max turns exceeded
final_content = content if content else ""
return self._max_turns_result(all_tool_results, turns, content=final_content)
final_content = self._strip_think_tags(content) if content else ""
self._emit_turn_end(turns=turns, max_turns_exceeded=True)
return AgentResult(
content=final_content or "Maximum turns reached without a final answer.",
tool_results=all_tool_results,
turns=turns,
metadata={
"max_turns_exceeded": True,
"prompt_tokens": total_prompt_tokens,
"completion_tokens": total_completion_tokens,
"total_tokens": total_prompt_tokens + total_completion_tokens,
},
)
__all__ = ["OrchestratorAgent"]
+1 -1
View File
@@ -222,7 +222,7 @@ def _check_optional_deps() -> List[CheckResult]:
try:
__import__(pkg)
results.append(CheckResult(f"Optional: {label}", "ok", "Installed"))
except ImportError:
except Exception:
results.append(
CheckResult(f"Optional: {label}", "warn", "Not installed")
)
+26 -1
View File
@@ -70,9 +70,13 @@ def serve(
telem_store = None
if config.telemetry.enabled:
try:
from pathlib import Path
from openjarvis.telemetry.store import TelemetryStore
telem_store = TelemetryStore(config.telemetry.db_path)
db_path = Path(config.telemetry.db_path).expanduser()
db_path.parent.mkdir(parents=True, exist_ok=True)
telem_store = TelemetryStore(str(db_path))
telem_store.subscribe_to_bus(bus)
except Exception:
pass # telemetry is best-effort
@@ -87,6 +91,27 @@ def serve(
engine_name, engine = resolved
# Wrap engine with InstrumentedEngine for telemetry recording
try:
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
energy_mon = None
try:
from openjarvis.telemetry.energy_monitor import create_energy_monitor
energy_mon = create_energy_monitor()
if energy_mon is not None:
console.print(
f" Energy: [cyan]{energy_mon.vendor().value}[/cyan] "
f"({energy_mon.energy_method()})"
)
except Exception:
pass
engine = InstrumentedEngine(engine, bus, energy_monitor=energy_mon)
except Exception:
pass # instrumentation is best-effort
# Discover models
all_engines = discover_engines(config)
all_models = discover_models(all_engines)
+19 -2
View File
@@ -45,7 +45,11 @@ class OllamaEngine(InferenceEngine):
"model": model,
"messages": messages_to_dicts(messages),
"stream": False,
"options": {"temperature": temperature, "num_predict": max_tokens},
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
},
}
# Pass tools if provided
tools = kwargs.get("tools")
@@ -53,11 +57,20 @@ class OllamaEngine(InferenceEngine):
payload["tools"] = tools
try:
resp = self._client.post("/api/chat", json=payload)
if resp.status_code == 400 and tools:
# Model may not support function calling -- retry without tools
payload.pop("tools", None)
resp = self._client.post("/api/chat", json=payload)
resp.raise_for_status()
except (httpx.ConnectError, httpx.TimeoutException) as exc:
raise EngineConnectionError(
f"Ollama not reachable at {self._host}"
) from exc
except httpx.HTTPStatusError as exc:
body = exc.response.text[:500] if exc.response else ""
raise RuntimeError(
f"Ollama returned {exc.response.status_code}: {body}"
) from exc
data = resp.json()
prompt_tokens = data.get("prompt_eval_count", 0)
completion_tokens = data.get("eval_count", 0)
@@ -102,7 +115,11 @@ class OllamaEngine(InferenceEngine):
"model": model,
"messages": messages_to_dicts(messages),
"stream": True,
"options": {"temperature": temperature, "num_predict": max_tokens},
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
},
}
try:
with self._client.stream("POST", "/api/chat", json=payload) as resp:
+53 -5
View File
@@ -198,9 +198,26 @@ telemetry_router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"])
async def telemetry_stats(request: Request):
"""Get aggregated telemetry statistics."""
try:
from dataclasses import asdict
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.telemetry.aggregator import TelemetryAggregator
agg = TelemetryAggregator()
return agg.summary()
db_path = DEFAULT_CONFIG_DIR / "telemetry.db"
if not db_path.exists():
return {"total_requests": 0, "total_tokens": 0}
session_start = getattr(request.app.state, "session_start", None)
agg = TelemetryAggregator(db_path)
try:
stats = agg.summary(since=session_start)
d = asdict(stats)
d.pop("per_model", None)
d.pop("per_engine", None)
d["total_requests"] = d.pop("total_calls", 0)
return d
finally:
agg.close()
except Exception as exc:
return {"error": str(exc)}
@@ -208,9 +225,33 @@ async def telemetry_stats(request: Request):
async def telemetry_energy(request: Request):
"""Get energy monitoring data."""
try:
from dataclasses import asdict
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.telemetry.aggregator import TelemetryAggregator
agg = TelemetryAggregator()
return agg.energy_summary()
db_path = DEFAULT_CONFIG_DIR / "telemetry.db"
if not db_path.exists():
return {"total_energy_j": 0, "energy_per_token_j": 0, "avg_power_w": 0}
session_start = getattr(request.app.state, "session_start", None)
agg = TelemetryAggregator(db_path)
try:
stats = agg.summary(since=session_start)
total_energy = stats.total_energy_joules
total_tokens = stats.total_tokens
total_latency = stats.total_latency
return {
"total_energy_j": total_energy,
"energy_per_token_j": (
total_energy / total_tokens if total_tokens > 0 else 0
),
"avg_power_w": (
total_energy / total_latency if total_latency > 0 else 0
),
}
finally:
agg.close()
except Exception as exc:
return {"error": str(exc)}
@@ -319,8 +360,15 @@ metrics_router = APIRouter(tags=["metrics"])
async def prometheus_metrics(request: Request):
"""Prometheus-compatible metrics endpoint."""
try:
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.telemetry.aggregator import TelemetryAggregator
agg = TelemetryAggregator()
db_path = DEFAULT_CONFIG_DIR / "telemetry.db"
if not db_path.exists():
from starlette.responses import PlainTextResponse
return PlainTextResponse("# no telemetry data\n", media_type="text/plain")
agg = TelemetryAggregator(db_path)
stats = agg.summary()
lines = [
+6 -7
View File
@@ -10,14 +10,13 @@ from typing import Any, Dict, List
# ---------------------------------------------------------------------------
CLOUD_PRICING: Dict[str, Dict[str, float]] = {
"gpt-5.2": {
"input_per_1m": 1.75,
"output_per_1m": 14.00,
"label": "GPT 5.2",
"gpt-5.3": {
"input_per_1m": 2.00,
"output_per_1m": 10.00,
"label": "GPT-5.3",
"provider": "OpenAI",
# Rough estimates for energy/compute
"energy_wh_per_1k_tokens": 0.4, # Wh per 1K tokens (datacenter)
"flops_per_token": 3.0e12, # ~1.5T params * 2 (forward pass)
"energy_wh_per_1k_tokens": 0.4,
"flops_per_token": 3.0e12,
},
"claude-opus-4.6": {
"input_per_1m": 5.00,
+54 -16
View File
@@ -161,14 +161,23 @@ class AgentStreamBridge:
try:
agent_result = agent_task.result()
except Exception as exc:
# Engine or agent error — emit a user-friendly error chunk
# instead of crashing the SSE stream.
import logging
logger = logging.getLogger("openjarvis.server")
logger.error("Agent stream error: %s", exc, exc_info=True)
error_str = str(exc)
if "400" in error_str:
if "context length" in error_str.lower() or (
"400" in error_str and "too long" in error_str.lower()
):
error_content = (
"The input is too long for the model's context window. "
"Please try a shorter message."
)
elif "400" in error_str:
error_content = (
f"The model returned an error: {error_str}"
)
else:
error_content = f"Sorry, an error occurred: {error_str}"
error_chunk = ChatCompletionChunk(
@@ -183,7 +192,7 @@ class AgentStreamBridge:
yield "data: [DONE]\n\n"
return
# Emit single content chunk with finish_reason + usage
# Emit tool results metadata if any
tool_results_data = []
for tr in agent_result.tool_results:
tool_results_data.append({
@@ -193,25 +202,54 @@ class AgentStreamBridge:
"latency_ms": tr.latency_seconds * 1000,
})
content_chunk = ChatCompletionChunk(
if tool_results_data:
yield self._format_named_event(
"tool_results", {"results": tool_results_data},
)
# Stream content progressively (word-by-word) for a
# real-time feel, then send a final chunk with usage.
content = agent_result.content or ""
if content:
words = content.split(" ")
for i, word in enumerate(words):
token = word if i == 0 else " " + word
chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[StreamChoice(
delta=DeltaMessage(content=token),
)],
)
yield f"data: {chunk.model_dump_json()}\n\n"
await asyncio.sleep(0.012)
# Final chunk: finish_reason + usage
prompt_tokens = agent_result.metadata.get("prompt_tokens", 0)
completion_tokens = agent_result.metadata.get(
"completion_tokens", 0,
)
total_tokens = agent_result.metadata.get("total_tokens", 0)
if total_tokens == 0 and content:
completion_tokens = max(len(content) // 4, 1)
prompt_tokens = completion_tokens # rough estimate
total_tokens = prompt_tokens + completion_tokens
final_chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[StreamChoice(
delta=DeltaMessage(content=agent_result.content),
delta=DeltaMessage(),
finish_reason="stop",
)],
)
content_data = json.loads(content_chunk.model_dump_json())
if tool_results_data:
content_data["tool_results"] = tool_results_data
content_data["usage"] = UsageInfo(
prompt_tokens=agent_result.metadata.get("prompt_tokens", 0),
completion_tokens=agent_result.metadata.get(
"completion_tokens", 0,
),
total_tokens=agent_result.metadata.get("total_tokens", 0),
final_data = json.loads(final_chunk.model_dump_json())
final_data["usage"] = UsageInfo(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
).model_dump()
yield f"data: {json.dumps(content_data)}\n\n"
yield f"data: {json.dumps(final_data)}\n\n"
yield "data: [DONE]\n\n"

Some files were not shown because too many files have changed in this diff Show More