mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 10:52:15 +00:00
initial frontend setup
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a1e" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="description" content="OpenJarvis — on-device AI assistant" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
|
||||
Generated
+2917
-91
File diff suppressed because it is too large
Load Diff
+22
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+6184
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 394 B |
Binary file not shown.
|
After Width: | Height: | Size: 858 B |
Binary file not shown.
|
After Width: | Height: | Size: 858 B |
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicknisi/tauri-v2-json-schema/main/tauri-v2-schema.json",
|
||||
"productName": "OpenJarvis",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.openjarvis.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build:tauri"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpenJarvis",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:*; img-src 'self' data: blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "On-device AI assistant — private, fast, always available",
|
||||
"longDescription": "OpenJarvis is a local AI assistant with full chat, tool use, energy profiling, and trace debugging. Runs your models on your hardware.",
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFNzUzMzhEOEY2MjNEMDMKUldRRFBXS1BqVE4xSG8vK0lkUWN4WnZQYVIrbmc4RmpoOGlJWTBLTE15RlIya3JvQisvdUR3a0QK",
|
||||
"endpoints": [
|
||||
"https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/latest.json"
|
||||
]
|
||||
},
|
||||
"notification": {
|
||||
"permissionState": "prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-91
@@ -1,103 +1,78 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Sidebar } from './components/Sidebar/Sidebar';
|
||||
import { ChatArea } from './components/Chat/ChatArea';
|
||||
import { useConversations } from './hooks/useConversations';
|
||||
import { useModels } from './hooks/useModels';
|
||||
import { useSavings } from './hooks/useSavings';
|
||||
import { useChat } from './hooks/useChat';
|
||||
import { useServerInfo } from './hooks/useServerInfo';
|
||||
import { useEffect } from 'react';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { Layout } from './components/Layout';
|
||||
import { ChatPage } from './pages/ChatPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { useAppStore } from './lib/store';
|
||||
import { fetchModels, fetchServerInfo, fetchSavings } from './lib/api';
|
||||
|
||||
export default function App() {
|
||||
const { models } = useModels();
|
||||
const { savings, refresh: refreshSavings } = useSavings();
|
||||
const serverInfo = useServerInfo();
|
||||
const {
|
||||
conversations,
|
||||
activeId,
|
||||
activeConversation,
|
||||
createConversation,
|
||||
selectConversation,
|
||||
removeConversation,
|
||||
reload: reloadConversations,
|
||||
} = useConversations();
|
||||
const setModels = useAppStore((s) => s.setModels);
|
||||
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
|
||||
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
|
||||
const selectedModel = useAppStore((s) => s.selectedModel);
|
||||
const setServerInfo = useAppStore((s) => s.setServerInfo);
|
||||
const setSavings = useAppStore((s) => s.setSavings);
|
||||
const settings = useAppStore((s) => s.settings);
|
||||
const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen);
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// Set default model
|
||||
// Apply theme class to <html>
|
||||
useEffect(() => {
|
||||
if (!selectedModel && models.length > 0) {
|
||||
setSelectedModel(models[0].id);
|
||||
}
|
||||
}, [models, selectedModel]);
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark', 'light');
|
||||
if (settings.theme === 'dark') root.classList.add('dark');
|
||||
else if (settings.theme === 'light') root.classList.add('light');
|
||||
}, [settings.theme]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
streamState,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
reloadMessages,
|
||||
} = useChat(activeId, selectedModel);
|
||||
|
||||
// Reload messages when active conversation changes
|
||||
// Fetch models on mount
|
||||
useEffect(() => {
|
||||
reloadMessages();
|
||||
}, [activeId, reloadMessages]);
|
||||
fetchModels()
|
||||
.then((m) => {
|
||||
setModels(m);
|
||||
if (!selectedModel && m.length > 0) setSelectedModel(m[0].id);
|
||||
})
|
||||
.catch(() => setModels([]))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleNewChat = () => {
|
||||
createConversation(selectedModel || 'default');
|
||||
reloadMessages();
|
||||
};
|
||||
// Fetch server info
|
||||
useEffect(() => {
|
||||
fetchServerInfo().then(setServerInfo).catch(() => {});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!activeId) {
|
||||
createConversation(selectedModel || 'default');
|
||||
// Need to wait a tick for state to update
|
||||
setTimeout(async () => {
|
||||
reloadConversations();
|
||||
await sendMessage(content);
|
||||
reloadConversations();
|
||||
refreshSavings();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
await sendMessage(content);
|
||||
reloadConversations();
|
||||
refreshSavings();
|
||||
};
|
||||
// Poll savings
|
||||
useEffect(() => {
|
||||
const refresh = () => fetchSavings().then(setSavings).catch(() => {});
|
||||
refresh();
|
||||
const interval = setInterval(refresh, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Global Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setCommandPaletteOpen(!commandPaletteOpen);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [commandPaletteOpen, setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
{sidebarOpen ? '\u2715' : '\u2630'}
|
||||
</button>
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
savings={savings}
|
||||
localModel={serverInfo?.model}
|
||||
onSelectModel={setSelectedModel}
|
||||
onNewChat={handleNewChat}
|
||||
onSelectConversation={(id) => {
|
||||
selectConversation(id);
|
||||
}}
|
||||
onDeleteConversation={removeConversation}
|
||||
/>
|
||||
<ChatArea
|
||||
messages={messages}
|
||||
streamState={streamState}
|
||||
onSendMessage={handleSendMessage}
|
||||
onStopStreaming={stopStreaming}
|
||||
activeConversation={activeConversation}
|
||||
serverInfo={serverInfo}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
{commandPaletteOpen && <CommandPalette />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL || ''; // relative to same origin by default
|
||||
|
||||
export async function fetchModels(): Promise<ModelInfo[]> {
|
||||
const res = await fetch(`${BASE}/v1/models`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.data || [];
|
||||
}
|
||||
|
||||
export async function fetchSavings(): Promise<SavingsData> {
|
||||
const res = await fetch(`${BASE}/v1/savings`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchServerInfo(): Promise<ServerInfo> {
|
||||
const res = await fetch(`${BASE}/v1/info`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,75 +1,73 @@
|
||||
import type { ChatMessage, StreamState, Conversation, ServerInfo } from '../../types';
|
||||
import { MessageList } from './MessageList';
|
||||
import { StreamingIndicator } from './StreamingIndicator';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { InputArea } from './InputArea';
|
||||
import { StreamingDots } from './StreamingDots';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
interface ChatAreaProps {
|
||||
messages: ChatMessage[];
|
||||
streamState: StreamState;
|
||||
onSendMessage: (content: string) => void;
|
||||
onStopStreaming: () => void;
|
||||
activeConversation: Conversation | null;
|
||||
serverInfo: ServerInfo | null;
|
||||
function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
messages,
|
||||
streamState,
|
||||
onSendMessage,
|
||||
onStopStreaming,
|
||||
activeConversation,
|
||||
serverInfo,
|
||||
}: ChatAreaProps) {
|
||||
// Cumulative token counts for the conversation
|
||||
const totalPrompt = messages.reduce(
|
||||
(sum, m) => sum + (m.usage?.prompt_tokens || 0),
|
||||
0,
|
||||
);
|
||||
const totalCompletion = messages.reduce(
|
||||
(sum, m) => sum + (m.usage?.completion_tokens || 0),
|
||||
0,
|
||||
);
|
||||
const totalTokens = totalPrompt + totalCompletion;
|
||||
export function ChatArea() {
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const streamState = useAppStore((s) => s.streamState);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
return (
|
||||
<main className="chat-area">
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-title">
|
||||
{activeConversation ? activeConversation.title : 'OpenJarvis Chat'}
|
||||
</div>
|
||||
<div className="chat-header-meta">
|
||||
{serverInfo && (
|
||||
<div className="chat-header-info">
|
||||
<span className="header-badge model-badge">
|
||||
{serverInfo.model || 'unknown'}
|
||||
</span>
|
||||
{serverInfo.agent && (
|
||||
<span className="header-badge agent-badge">
|
||||
{serverInfo.agent}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col h-full">
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center mb-4"
|
||||
style={{ background: 'var(--color-accent-subtle)', color: 'var(--color-accent)' }}
|
||||
>
|
||||
<Sparkles size={24} />
|
||||
</div>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
<div className="chat-header-tokens">
|
||||
{totalPrompt.toLocaleString()} in / {totalCompletion.toLocaleString()} out
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
|
||||
{getGreeting()}
|
||||
</h2>
|
||||
<p className="text-sm text-center max-w-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Ask anything. Your AI runs locally — private, fast, and always available.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[var(--chat-max-width)] mx-auto px-4 py-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{streamState.isStreaming && streamState.content === '' && (
|
||||
<div className="flex justify-start mb-4">
|
||||
<StreamingDots phase={streamState.phase} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MessageList messages={messages} isStreaming={streamState.isStreaming} />
|
||||
{streamState.isStreaming && (
|
||||
<StreamingIndicator
|
||||
phase={streamState.phase}
|
||||
elapsedMs={streamState.elapsedMs}
|
||||
toolCalls={streamState.activeToolCalls}
|
||||
/>
|
||||
)}
|
||||
<InputArea
|
||||
onSend={onSendMessage}
|
||||
onStop={onStopStreaming}
|
||||
isStreaming={streamState.isStreaming}
|
||||
/>
|
||||
</main>
|
||||
<InputArea />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ text }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for non-secure contexts
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button className="copy-btn" onClick={handleCopy} title="Copy to clipboard">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +1,252 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Square, Paperclip } from 'lucide-react';
|
||||
import { useAppStore, generateId } from '../../lib/store';
|
||||
import { streamChat } from '../../lib/sse';
|
||||
import type { ChatMessage, ToolCallInfo, TokenUsage } from '../../types';
|
||||
|
||||
const COLLAPSE_CHAR_THRESHOLD = 500;
|
||||
const COLLAPSE_LINE_THRESHOLD = 6;
|
||||
|
||||
function shouldCollapse(text: string): boolean {
|
||||
return (
|
||||
text.length > COLLAPSE_CHAR_THRESHOLD ||
|
||||
text.split('\n').length > COLLAPSE_LINE_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
function formatSize(text: string): string {
|
||||
const chars = text.length;
|
||||
const lines = text.split('\n').length;
|
||||
if (chars >= 1000) {
|
||||
return `${(chars / 1000).toFixed(1)}k chars, ${lines} line${lines !== 1 ? 's' : ''}`;
|
||||
}
|
||||
return `${chars} chars, ${lines} line${lines !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
interface InputAreaProps {
|
||||
onSend: (content: string) => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function InputArea({ onSend, onStop, isStreaming }: InputAreaProps) {
|
||||
// Pasted/long content stored separately as an "attachment"
|
||||
const [attachment, setAttachment] = useState('');
|
||||
// Text typed in the visible textarea
|
||||
const [typed, setTyped] = useState('');
|
||||
export function InputArea() {
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fullMessage = attachment ? attachment + '\n' + typed : typed;
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const selectedModel = useAppStore((s) => s.selectedModel);
|
||||
const streamState = useAppStore((s) => s.streamState);
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const createConversation = useAppStore((s) => s.createConversation);
|
||||
const addMessage = useAppStore((s) => s.addMessage);
|
||||
const updateLastAssistant = useAppStore((s) => s.updateLastAssistant);
|
||||
const setStreamState = useAppStore((s) => s.setStreamState);
|
||||
const resetStream = useAppStore((s) => s.resetStream);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!fullMessage.trim() || isStreaming) return;
|
||||
onSend(fullMessage);
|
||||
setAttachment('');
|
||||
setTyped('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
}, [input]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, [fullMessage, isStreaming, onSend]);
|
||||
resetStream();
|
||||
}, [resetStream]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const sendMessage = useCallback(async () => {
|
||||
const content = input.trim();
|
||||
if (!content || streamState.isStreaming) return;
|
||||
|
||||
setInput('');
|
||||
|
||||
let convId = activeId;
|
||||
if (!convId) {
|
||||
convId = createConversation(selectedModel);
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addMessage(convId, userMsg);
|
||||
|
||||
// Build API messages before adding assistant placeholder
|
||||
const currentMessages = useAppStore.getState().messages;
|
||||
const apiMessages = currentMessages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addMessage(convId, assistantMsg);
|
||||
|
||||
// Start streaming
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
setStreamState({ elapsedMs: Date.now() - startTime });
|
||||
}, 100);
|
||||
timerRef.current = timer;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
let accumulatedContent = '';
|
||||
let usage: TokenUsage | undefined;
|
||||
const toolCalls: ToolCallInfo[] = [];
|
||||
|
||||
setStreamState({
|
||||
isStreaming: true,
|
||||
phase: 'Sending...',
|
||||
elapsedMs: 0,
|
||||
activeToolCalls: [],
|
||||
content: '',
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const sseEvent of streamChat(
|
||||
{ model: selectedModel, messages: apiMessages, stream: true },
|
||||
controller.signal,
|
||||
)) {
|
||||
const eventName = sseEvent.event;
|
||||
|
||||
if (eventName === 'agent_turn_start') {
|
||||
setStreamState({ phase: 'Agent thinking...' });
|
||||
} else if (eventName === 'inference_start') {
|
||||
setStreamState({ phase: 'Generating...' });
|
||||
} else if (eventName === 'tool_call_start') {
|
||||
try {
|
||||
const data = JSON.parse(sseEvent.data);
|
||||
const tc: ToolCallInfo = {
|
||||
id: generateId(),
|
||||
tool: data.tool,
|
||||
arguments: data.arguments || '',
|
||||
status: 'running',
|
||||
};
|
||||
toolCalls.push(tc);
|
||||
setStreamState({
|
||||
phase: `Running ${data.tool}...`,
|
||||
activeToolCalls: [...toolCalls],
|
||||
});
|
||||
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
|
||||
} catch {}
|
||||
} else if (eventName === 'tool_call_end') {
|
||||
try {
|
||||
const data = JSON.parse(sseEvent.data);
|
||||
const tc = toolCalls.find(
|
||||
(t) => t.tool === data.tool && t.status === 'running',
|
||||
);
|
||||
if (tc) {
|
||||
tc.status = data.success ? 'success' : 'error';
|
||||
tc.latency = data.latency;
|
||||
tc.result = data.result;
|
||||
}
|
||||
setStreamState({
|
||||
phase: 'Generating...',
|
||||
activeToolCalls: [...toolCalls],
|
||||
});
|
||||
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
const data = JSON.parse(sseEvent.data);
|
||||
const delta = data.choices?.[0]?.delta;
|
||||
if (data.usage) usage = data.usage;
|
||||
if (delta?.content) {
|
||||
accumulatedContent += delta.content;
|
||||
setStreamState({ content: accumulatedContent });
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
toolCalls.length > 0 ? [...toolCalls] : undefined,
|
||||
);
|
||||
}
|
||||
if (data.choices?.[0]?.finish_reason === 'stop') break;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
accumulatedContent =
|
||||
accumulatedContent || 'Error: Failed to get response.';
|
||||
}
|
||||
} finally {
|
||||
if (!accumulatedContent) {
|
||||
accumulatedContent = 'No response was generated. Please try again.';
|
||||
}
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
toolCalls.length > 0 ? toolCalls : undefined,
|
||||
usage,
|
||||
);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
resetStream();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [
|
||||
input,
|
||||
activeId,
|
||||
selectedModel,
|
||||
streamState.isStreaming,
|
||||
createConversation,
|
||||
addMessage,
|
||||
updateLastAssistant,
|
||||
setStreamState,
|
||||
resetStream,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setTyped(newValue);
|
||||
// Auto-resize textarea
|
||||
const ta = e.target;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
|
||||
};
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const pasted = e.clipboardData.getData('text');
|
||||
if (shouldCollapse(pasted)) {
|
||||
e.preventDefault();
|
||||
// Store long paste as an attachment pill
|
||||
setAttachment((prev) => (prev ? prev + '\n' + pasted : pasted));
|
||||
}
|
||||
// Short pastes go directly into the textarea as normal
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClearAttachment = useCallback(() => {
|
||||
setAttachment('');
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleExpandAttachment = useCallback(() => {
|
||||
// Move attachment content back into the textarea
|
||||
setTyped((prev) => (attachment + (prev ? '\n' + prev : '')));
|
||||
setAttachment('');
|
||||
// Let React render, then resize
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height =
|
||||
Math.min(textareaRef.current.scrollHeight, 200) + 'px';
|
||||
}
|
||||
}, 0);
|
||||
}, [attachment]);
|
||||
|
||||
// Focus textarea after attachment changes
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, [attachment]);
|
||||
|
||||
return (
|
||||
<div className="input-area">
|
||||
{attachment && (
|
||||
<div className="input-attachment-row">
|
||||
<div className="pasted-pill">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||
</svg>
|
||||
<span className="pasted-pill-text">Pasted text</span>
|
||||
<span className="pasted-pill-size">{formatSize(attachment)}</span>
|
||||
<button
|
||||
className="pasted-pill-action"
|
||||
onClick={handleExpandAttachment}
|
||||
title="Expand to edit"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="pasted-pill-action pasted-pill-remove"
|
||||
onClick={handleClearAttachment}
|
||||
title="Remove pasted text"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="input-container">
|
||||
<div className="px-4 pb-4 pt-2" style={{ maxWidth: 'var(--chat-max-width)', margin: '0 auto', width: '100%' }}>
|
||||
<div
|
||||
className="flex items-end gap-2 rounded-2xl px-4 py-3 transition-shadow"
|
||||
style={{
|
||||
background: 'var(--color-input-bg)',
|
||||
border: '1px solid var(--color-input-border)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={typed}
|
||||
onChange={handleInput}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={
|
||||
attachment
|
||||
? 'Add instructions for the pasted text...'
|
||||
: 'Type a message... (Shift+Enter for new line)'
|
||||
}
|
||||
rows={3}
|
||||
disabled={isStreaming}
|
||||
placeholder="Message OpenJarvis..."
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent outline-none resize-none text-sm leading-relaxed"
|
||||
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
|
||||
disabled={streamState.isStreaming}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button className="stop-btn" onClick={onStop}>
|
||||
Stop
|
||||
{streamState.isStreaming ? (
|
||||
<button
|
||||
onClick={stopStreaming}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
|
||||
style={{ background: 'var(--color-error)', color: 'white' }}
|
||||
title="Stop generating"
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!fullMessage.trim()}
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim()}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
background: input.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
|
||||
color: input.trim() ? 'white' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
title="Send message"
|
||||
>
|
||||
Send
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center mt-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
<span>
|
||||
<kbd className="font-mono">Enter</kbd> to send ·{' '}
|
||||
<kbd className="font-mono">Shift+Enter</kbd> for new line
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import type { ChatMessage } from '../../types';
|
||||
import { ToolCallIndicator } from './ToolCallIndicator';
|
||||
import { CopyButton } from './CopyButton';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
function CodeBlock({ className, children, ...props }: any) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match ? match[1] : '';
|
||||
const code = String(children).replace(/\n$/, '');
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const usage = message.usage;
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (!className) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`message-bubble ${message.role}`}>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-1.5 text-xs rounded-t-[var(--radius-md)]"
|
||||
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
<span className="font-mono">{lang || 'code'}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-text-secondary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="!mt-0 !rounded-t-none">
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyMessageButton({ content }: { content: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
title="Copy message"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end mb-4">
|
||||
<div
|
||||
className="max-w-[85%] px-4 py-2.5 text-sm leading-relaxed"
|
||||
style={{
|
||||
background: 'var(--color-user-bubble)',
|
||||
color: 'var(--color-user-bubble-text)',
|
||||
borderRadius: 'var(--radius-xl) var(--radius-xl) var(--radius-sm) var(--radius-xl)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group mb-6">
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="tool-calls">
|
||||
<div className="mb-3 flex flex-col gap-2">
|
||||
{message.toolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.id} toolCall={tc} />
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-content">
|
||||
{message.content || (message.role === 'assistant' ? '\u200B' : '')}
|
||||
{message.role === 'assistant' && message.content && (
|
||||
<CopyButton text={message.content} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message-meta">
|
||||
<span className="message-time">{formatTime(message.timestamp)}</span>
|
||||
{usage && (
|
||||
<span className="message-tokens">
|
||||
{usage.prompt_tokens} in / {usage.completion_tokens} out
|
||||
|
||||
{/* Assistant message */}
|
||||
{message.content && (
|
||||
<div className="prose max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: usage + copy */}
|
||||
<div className="flex items-center gap-2 mt-1.5 min-h-[24px]">
|
||||
<CopyMessageButton content={message.content} />
|
||||
{message.usage && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{message.usage.total_tokens} tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import type { ChatMessage } from '../../types';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
|
||||
function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 5 && hour < 12) return 'Good Morning! What shall we build today?';
|
||||
if (hour >= 12 && hour < 17) return 'Good Afternoon! Ready to create something?';
|
||||
if (hour >= 17 && hour < 21) return 'Good Evening! Let\'s get things done.';
|
||||
return 'Late Night Session \u2014 Let\'s make it count.';
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const greeting = useMemo(() => getGreeting(), []);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (autoScroll && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, autoScroll, isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="message-list">
|
||||
<div className="message-list-empty" style={{ flexDirection: 'column', gap: '8px' }}>
|
||||
<span style={{ fontSize: '24px', fontWeight: 600, color: '#1e293b' }}>
|
||||
{greeting}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
Type a message below to get started.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list" ref={listRef} onScroll={handleScroll}>
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface Props {
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export function StreamingDots({ phase }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full animate-bounce"
|
||||
style={{ background: 'var(--color-text-tertiary)', animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full animate-bounce"
|
||||
style={{ background: 'var(--color-text-tertiary)', animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full animate-bounce"
|
||||
style={{ background: 'var(--color-text-tertiary)', animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
{phase && (
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{phase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ToolCallInfo } from '../../types';
|
||||
import { ToolCallIndicator } from './ToolCallIndicator';
|
||||
|
||||
interface StreamingIndicatorProps {
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
toolCalls: ToolCallInfo[];
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const tenths = Math.floor((ms % 1000) / 100);
|
||||
return `${seconds}.${tenths}s`;
|
||||
}
|
||||
|
||||
export function StreamingIndicator({
|
||||
phase,
|
||||
elapsedMs,
|
||||
toolCalls,
|
||||
}: StreamingIndicatorProps) {
|
||||
return (
|
||||
<div className="streaming-indicator">
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="streaming-tool-calls">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="streaming-progress-row">
|
||||
<div className="streaming-bar-container">
|
||||
<div className="streaming-bar" />
|
||||
</div>
|
||||
<span className="streaming-phase">{phase}</span>
|
||||
<span className="streaming-elapsed">{formatElapsed(elapsedMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Wrench, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '../../types';
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCallInfo;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
running: { icon: Loader2, label: 'Running', color: 'var(--color-accent)' },
|
||||
success: { icon: CheckCircle2, label: 'Done', color: 'var(--color-success)' },
|
||||
error: { icon: XCircle, label: 'Failed', color: 'var(--color-error)' },
|
||||
};
|
||||
|
||||
export function ToolCallCard({ toolCall }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = statusConfig[toolCall.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg text-sm overflow-hidden"
|
||||
style={{ border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 transition-colors cursor-pointer"
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={14} style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
) : (
|
||||
<ChevronRight size={14} style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
)}
|
||||
<Wrench size={14} style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<span style={{ color: 'var(--color-text)' }} className="font-medium">
|
||||
{toolCall.tool}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<StatusIcon
|
||||
size={14}
|
||||
style={{ color: config.color }}
|
||||
className={toolCall.status === 'running' ? 'animate-spin' : ''}
|
||||
/>
|
||||
{toolCall.latency != null && (
|
||||
<span className="text-[11px] font-mono" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{toolCall.latency < 1000
|
||||
? `${Math.round(toolCall.latency)}ms`
|
||||
: `${(toolCall.latency / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3" style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||
{toolCall.arguments && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Arguments
|
||||
</div>
|
||||
<pre
|
||||
className="text-xs p-2 rounded overflow-x-auto font-mono"
|
||||
style={{ background: 'var(--color-code-bg)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{toolCall.arguments}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.result && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Result
|
||||
</div>
|
||||
<pre
|
||||
className="text-xs p-2 rounded overflow-x-auto font-mono max-h-48"
|
||||
style={{ background: 'var(--color-code-bg)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{toolCall.result}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ToolCallInfo } from '../../types';
|
||||
|
||||
interface ToolCallIndicatorProps {
|
||||
toolCall: ToolCallInfo;
|
||||
}
|
||||
|
||||
export function ToolCallIndicator({ toolCall }: ToolCallIndicatorProps) {
|
||||
return (
|
||||
<div className="tool-call">
|
||||
<span className={`tool-status ${toolCall.status}`} />
|
||||
<span className="tool-name">{toolCall.tool}</span>
|
||||
{toolCall.latency !== undefined && (
|
||||
<span className="tool-latency">{toolCall.latency.toFixed(0)}ms</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Search, Cpu, X } from 'lucide-react';
|
||||
import { useAppStore } from '../lib/store';
|
||||
|
||||
export function CommandPalette() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const models = useAppStore((s) => s.models);
|
||||
const selectedModel = useAppStore((s) => s.selectedModel);
|
||||
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const filtered = query
|
||||
? models.filter((m) =>
|
||||
m.id.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
: models;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIdx(0);
|
||||
}, [query]);
|
||||
|
||||
const handleSelect = (modelId: string) => {
|
||||
setSelectedModel(modelId);
|
||||
setCommandPaletteOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setCommandPaletteOpen(false);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(filtered[selectedIdx].id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
|
||||
onClick={() => setCommandPaletteOpen(false)}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0" style={{ background: 'rgba(0,0,0,0.5)' }} />
|
||||
|
||||
{/* Palette */}
|
||||
<div
|
||||
className="relative w-full max-w-lg rounded-xl overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<Search size={18} style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search models..."
|
||||
className="flex-1 bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(false)}
|
||||
className="p-1 rounded cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[300px] overflow-y-auto py-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{models.length === 0 ? 'No models available — is the server running?' : 'No matching models'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((model, idx) => {
|
||||
const isActive = model.id === selectedModel;
|
||||
const isSelected = idx === selectedIdx;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelect(model.id)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-left transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isSelected ? 'var(--color-bg-secondary)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
>
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-sm truncate"
|
||||
style={{
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text)',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{model.id}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full" style={{
|
||||
background: 'var(--color-accent-subtle)',
|
||||
color: 'var(--color-accent)',
|
||||
}}>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-2 text-[11px]"
|
||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
<span><kbd className="font-mono">↑↓</kbd> Navigate</span>
|
||||
<span><kbd className="font-mono">Enter</kbd> Select</span>
|
||||
<span><kbd className="font-mono">Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-4o', input: 2.50, output: 10.00 },
|
||||
{ name: 'Claude 3.5', input: 3.00, output: 15.00 },
|
||||
{ name: 'GPT-4o-mini', input: 0.15, output: 0.60 },
|
||||
];
|
||||
|
||||
export function CostComparison() {
|
||||
const savings = useAppStore((s) => s.savings);
|
||||
|
||||
if (!savings || savings.total_tokens === 0) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-6"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
|
||||
<DollarSign size={16} style={{ color: 'var(--color-success)' }} />
|
||||
Cost Comparison
|
||||
</h3>
|
||||
<div className="h-48 flex items-center justify-center text-sm" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Start chatting to see local vs. cloud cost savings.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const promptK = savings.total_prompt_tokens / 1000;
|
||||
const completionK = savings.total_completion_tokens / 1000;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-6"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-medium mb-4 flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
|
||||
<DollarSign size={16} style={{ color: 'var(--color-success)' }} />
|
||||
Cost Comparison
|
||||
</h3>
|
||||
|
||||
{/* Local stats */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 rounded-lg mb-3"
|
||||
style={{ background: 'var(--color-accent-subtle)', border: '1px solid var(--color-accent)' }}
|
||||
>
|
||||
<HardDrive size={18} style={{ color: 'var(--color-accent)' }} />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Local (your hardware)
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{savings.total_calls} requests · {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>·</span>
|
||||
<span>{totalMs.toFixed(0)}ms</span>
|
||||
<span>·</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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from './Sidebar/Sidebar';
|
||||
import { useAppStore } from '../lib/store';
|
||||
|
||||
export function Layout() {
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<Sidebar />
|
||||
{/* Overlay for mobile when sidebar is open */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black/40 md:hidden"
|
||||
onClick={() => useAppStore.getState().setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<main className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--color-bg)' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,98 @@
|
||||
import type { Conversation } from '../../types';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
interface Props {
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
activeId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: ConversationListProps) {
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function ConversationList({ searchQuery }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const selectConversation = useAppStore((s) => s.selectConversation);
|
||||
const deleteConversation = useAppStore((s) => s.deleteConversation);
|
||||
|
||||
const filtered = searchQuery
|
||||
? conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: conversations;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-8 text-center text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{searchQuery ? 'No matching chats' : 'No conversations yet'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="conversation-list">
|
||||
{conversations.length === 0 && (
|
||||
<div style={{ padding: '16px', color: 'var(--color-text-secondary)', fontSize: '13px', textAlign: 'center' }}>
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
{conversations.map((conv) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={`conversation-item ${conv.id === activeId ? 'active' : ''}`}
|
||||
onClick={() => onSelect(conv.id)}
|
||||
>
|
||||
<span className="conv-title">{conv.title}</span>
|
||||
<button
|
||||
className="conv-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(conv.id);
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{filtered.map((conv) => {
|
||||
const isActive = conv.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
className="group flex items-center rounded-lg cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: isActive ? 'var(--color-bg-tertiary)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,164 @@
|
||||
import type { Conversation, ModelInfo, SavingsData } from '../../types';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import {
|
||||
MessageSquare,
|
||||
Plus,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Search,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import { ConversationList } from './ConversationList';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { SavingsPanel } from './SavingsPanel';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
models: ModelInfo[];
|
||||
selectedModel: string;
|
||||
savings: SavingsData | null;
|
||||
localModel?: string;
|
||||
onSelectModel: (model: string) => void;
|
||||
onNewChat: () => void;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
}
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen);
|
||||
const toggleSidebar = useAppStore((s) => s.toggleSidebar);
|
||||
const createConversation = useAppStore((s) => s.createConversation);
|
||||
const selectedModel = useAppStore((s) => s.selectedModel);
|
||||
const serverInfo = useAppStore((s) => s.serverInfo);
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const handleNewChat = () => {
|
||||
createConversation(selectedModel);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: MessageSquare, label: 'Chat' },
|
||||
{ path: '/dashboard', icon: BarChart3, label: 'Dashboard' },
|
||||
{ path: '/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
conversations,
|
||||
activeId,
|
||||
models,
|
||||
selectedModel,
|
||||
savings,
|
||||
localModel,
|
||||
onSelectModel,
|
||||
onNewChat,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<h1>OpenJarvis</h1>
|
||||
<button className="new-chat-btn" onClick={onNewChat}>
|
||||
+ New Chat
|
||||
<>
|
||||
{/* Collapse button when sidebar is closed */}
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="fixed top-3 left-3 z-30 p-2 rounded-lg transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-secondary)', background: 'var(--color-bg-secondary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selected={selectedModel}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
onSelect={onSelectConversation}
|
||||
onDelete={onDeleteConversation}
|
||||
/>
|
||||
<SavingsPanel savings={savings} localModel={localModel} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`
|
||||
flex flex-col h-full shrink-0 transition-all duration-200 ease-in-out overflow-hidden
|
||||
fixed md:relative z-30
|
||||
${sidebarOpen ? 'w-[260px]' : 'w-0'}
|
||||
`}
|
||||
style={{ background: 'var(--color-sidebar)', borderRight: sidebarOpen ? '1px solid var(--color-border)' : 'none' }}
|
||||
>
|
||||
<div className="flex flex-col h-full w-[260px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-2">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-lg transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<PanelLeftClose size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="p-2 rounded-lg transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model badge */}
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="mx-3 mb-2 flex items-center gap-2 px-3 py-2 rounded-lg text-xs transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
|
||||
>
|
||||
<Cpu size={14} />
|
||||
<span className="truncate flex-1 text-left" style={{ color: 'var(--color-text)' }}>
|
||||
{selectedModel || serverInfo?.model || 'Select model'}
|
||||
</span>
|
||||
<kbd
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-mono"
|
||||
style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 mb-2">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
|
||||
style={{ background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<Search size={14} style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search chats..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto px-2">
|
||||
<ConversationList searchQuery={searchQuery} />
|
||||
</div>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<nav className="px-2 pb-3 pt-2 flex flex-col gap-0.5" style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? 'var(--color-bg-tertiary)' : 'transparent',
|
||||
color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'var(--color-bg-secondary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* OpenJarvis design tokens.
|
||||
* Zinc/slate neutrals, blue accent. Light default, dark via class or system pref.
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f4f4f5;
|
||||
--color-bg-tertiary: #e4e4e7;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar: #fafafa;
|
||||
--color-border: #e4e4e7;
|
||||
--color-border-subtle: #f4f4f5;
|
||||
|
||||
--color-text: #09090b;
|
||||
--color-text-secondary: #71717a;
|
||||
--color-text-tertiary: #a1a1aa;
|
||||
--color-text-inverse: #fafafa;
|
||||
|
||||
--color-accent: #2563eb;
|
||||
--color-accent-hover: #1d4ed8;
|
||||
--color-accent-subtle: #eff6ff;
|
||||
|
||||
--color-success: #16a34a;
|
||||
--color-warning: #ca8a04;
|
||||
--color-error: #dc2626;
|
||||
|
||||
--color-code-bg: #f4f4f5;
|
||||
--color-input-bg: #ffffff;
|
||||
--color-input-border: #d4d4d8;
|
||||
|
||||
--color-user-bubble: #2563eb;
|
||||
--color-user-bubble-text: #ffffff;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--sidebar-width: 260px;
|
||||
--chat-max-width: 720px;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-bg: #09090b;
|
||||
--color-bg-secondary: #18181b;
|
||||
--color-bg-tertiary: #27272a;
|
||||
--color-surface: #18181b;
|
||||
--color-sidebar: #0f0f12;
|
||||
--color-border: #27272a;
|
||||
--color-border-subtle: #1e1e22;
|
||||
|
||||
--color-text: #fafafa;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-tertiary: #71717a;
|
||||
--color-text-inverse: #09090b;
|
||||
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #60a5fa;
|
||||
--color-accent-subtle: #172554;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-error: #ef4444;
|
||||
|
||||
--color-code-bg: #1e1e22;
|
||||
--color-input-bg: #18181b;
|
||||
--color-input-border: #3f3f46;
|
||||
|
||||
--color-user-bubble: #3b82f6;
|
||||
--color-user-bubble-text: #ffffff;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
--color-bg: #09090b;
|
||||
--color-bg-secondary: #18181b;
|
||||
--color-bg-tertiary: #27272a;
|
||||
--color-surface: #18181b;
|
||||
--color-sidebar: #0f0f12;
|
||||
--color-border: #27272a;
|
||||
--color-border-subtle: #1e1e22;
|
||||
|
||||
--color-text: #fafafa;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-tertiary: #71717a;
|
||||
--color-text-inverse: #09090b;
|
||||
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #60a5fa;
|
||||
--color-accent-subtle: #172554;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-error: #ef4444;
|
||||
|
||||
--color-code-bg: #1e1e22;
|
||||
--color-input-bg: #18181b;
|
||||
--color-input-border: #3f3f46;
|
||||
|
||||
--color-user-bubble: #3b82f6;
|
||||
--color-user-bubble-text: #ffffff;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Syntax highlighting theme (highlight.js) */
|
||||
@layer components {
|
||||
.hljs {
|
||||
background: var(--color-code-bg) !important;
|
||||
color: var(--color-text) !important;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem !important;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-literal,
|
||||
.hljs-section,
|
||||
.hljs-link { color: #7c3aed; }
|
||||
.dark .hljs-keyword,
|
||||
.dark .hljs-selector-tag,
|
||||
.dark .hljs-literal,
|
||||
.dark .hljs-section,
|
||||
.dark .hljs-link { color: #a78bfa; }
|
||||
|
||||
.hljs-string,
|
||||
.hljs-title,
|
||||
.hljs-name,
|
||||
.hljs-type,
|
||||
.hljs-attribute,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-addition,
|
||||
.hljs-variable,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable { color: #059669; }
|
||||
.dark .hljs-string,
|
||||
.dark .hljs-title,
|
||||
.dark .hljs-name,
|
||||
.dark .hljs-type,
|
||||
.dark .hljs-attribute,
|
||||
.dark .hljs-symbol,
|
||||
.dark .hljs-bullet,
|
||||
.dark .hljs-addition,
|
||||
.dark .hljs-variable,
|
||||
.dark .hljs-template-tag,
|
||||
.dark .hljs-template-variable { color: #34d399; }
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote,
|
||||
.hljs-deletion,
|
||||
.hljs-meta { color: #a1a1aa; }
|
||||
|
||||
.hljs-number,
|
||||
.hljs-regexp,
|
||||
.hljs-built_in,
|
||||
.hljs-params { color: #d97706; }
|
||||
.dark .hljs-number,
|
||||
.dark .hljs-regexp,
|
||||
.dark .hljs-built_in,
|
||||
.dark .hljs-params { color: #fbbf24; }
|
||||
|
||||
.hljs-function { color: #2563eb; }
|
||||
.dark .hljs-function { color: #60a5fa; }
|
||||
|
||||
/* Markdown prose styling */
|
||||
.prose {
|
||||
line-height: 1.65;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose code:not(pre code) {
|
||||
background: var(--color-code-bg);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85em;
|
||||
font-family: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
margin: 0.75em 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
font-family: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding-left: 1em;
|
||||
margin: 0.75em 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 {
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose h1 { font-size: 1.375em; }
|
||||
.prose h2 { font-size: 1.2em; }
|
||||
.prose h3 { font-size: 1.075em; }
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
|
||||
|
||||
const getBase = () => import.meta.env.VITE_API_URL || '';
|
||||
|
||||
async function tauriInvoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const apiUrl = getBase() || 'http://localhost:8000';
|
||||
return invoke<T>(command, { apiUrl, ...args });
|
||||
}
|
||||
|
||||
export async function fetchModels(): Promise<ModelInfo[]> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const result = await tauriInvoke<{ data?: ModelInfo[] }>('fetch_models');
|
||||
return result?.data || [];
|
||||
} catch {
|
||||
// Fall through to fetch
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/models`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.data || [];
|
||||
}
|
||||
|
||||
export async function fetchSavings(): Promise<SavingsData> {
|
||||
const res = await fetch(`${getBase()}/v1/savings`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchServerInfo(): Promise<ServerInfo> {
|
||||
const res = await fetch(`${getBase()}/v1/info`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const apiUrl = getBase() || 'http://localhost:8000';
|
||||
await tauriInvoke('check_health', { apiUrl });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${getBase()}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEnergy(): Promise<unknown> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const apiUrl = getBase() || 'http://localhost:8000';
|
||||
return await tauriInvoke('fetch_energy', { apiUrl });
|
||||
} catch {}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/telemetry/energy`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTelemetry(): Promise<unknown> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const apiUrl = getBase() || 'http://localhost:8000';
|
||||
return await tauriInvoke('fetch_telemetry', { apiUrl });
|
||||
} catch {}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/telemetry/stats`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTraces(limit: number = 50): Promise<unknown> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const apiUrl = getBase() || 'http://localhost:8000';
|
||||
return await tauriInvoke('fetch_traces', { apiUrl, limit });
|
||||
} catch {}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export async function* streamChat(
|
||||
request: ChatRequest,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const response = await fetch('/v1/chat/completions', {
|
||||
const base = import.meta.env.VITE_API_URL || '';
|
||||
const response = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
@@ -41,9 +42,7 @@ export async function* streamChat(
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
return;
|
||||
}
|
||||
if (data === '[DONE]') return;
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = undefined;
|
||||
} else if (line.trim() === '') {
|
||||
@@ -0,0 +1,319 @@
|
||||
import { create } from 'zustand';
|
||||
import type {
|
||||
Conversation,
|
||||
ChatMessage,
|
||||
ModelInfo,
|
||||
SavingsData,
|
||||
ServerInfo,
|
||||
StreamState,
|
||||
ToolCallInfo,
|
||||
TokenUsage,
|
||||
} from '../types';
|
||||
|
||||
// ── localStorage persistence ──────────────────────────────────────────
|
||||
|
||||
const CONVERSATIONS_KEY = 'openjarvis-conversations';
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
|
||||
interface ConversationStore {
|
||||
version: 1;
|
||||
conversations: Record<string, Conversation>;
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function loadConversations(): ConversationStore {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONVERSATIONS_KEY);
|
||||
if (!raw) return { version: 1, conversations: {}, activeId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.version === 1) return parsed;
|
||||
return { version: 1, conversations: {}, activeId: null };
|
||||
} catch {
|
||||
return { version: 1, conversations: {}, activeId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function saveConversations(store: ConversationStore): void {
|
||||
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(store));
|
||||
}
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
interface Settings {
|
||||
theme: ThemeMode;
|
||||
apiUrl: string;
|
||||
fontSize: 'small' | 'default' | 'large';
|
||||
defaultModel: string;
|
||||
defaultAgent: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
function loadSettings(): Settings {
|
||||
const defaults: Settings = {
|
||||
theme: 'system',
|
||||
apiUrl: '',
|
||||
fontSize: 'default',
|
||||
defaultModel: '',
|
||||
defaultAgent: '',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (!raw) return defaults;
|
||||
return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings: Settings): void {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INITIAL_STREAM: StreamState = {
|
||||
isStreaming: false,
|
||||
phase: '',
|
||||
elapsedMs: 0,
|
||||
activeToolCalls: [],
|
||||
content: '',
|
||||
};
|
||||
|
||||
interface AppState {
|
||||
// Conversations
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
messages: ChatMessage[];
|
||||
streamState: StreamState;
|
||||
|
||||
// Models & server
|
||||
models: ModelInfo[];
|
||||
modelsLoading: boolean;
|
||||
selectedModel: string;
|
||||
serverInfo: ServerInfo | null;
|
||||
savings: SavingsData | null;
|
||||
|
||||
// Settings
|
||||
settings: Settings;
|
||||
|
||||
// Command palette
|
||||
commandPaletteOpen: boolean;
|
||||
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
|
||||
// Actions: conversations
|
||||
loadConversations: () => void;
|
||||
createConversation: (model?: string) => string;
|
||||
selectConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
loadMessages: (conversationId: string | null) => void;
|
||||
addMessage: (conversationId: string, message: ChatMessage) => void;
|
||||
updateLastAssistant: (
|
||||
conversationId: string,
|
||||
content: string,
|
||||
toolCalls?: ToolCallInfo[],
|
||||
usage?: TokenUsage,
|
||||
) => void;
|
||||
setStreamState: (state: Partial<StreamState>) => void;
|
||||
resetStream: () => void;
|
||||
|
||||
// Actions: models & server
|
||||
setModels: (models: ModelInfo[]) => void;
|
||||
setModelsLoading: (loading: boolean) => void;
|
||||
setSelectedModel: (model: string) => void;
|
||||
setServerInfo: (info: ServerInfo | null) => void;
|
||||
setSavings: (data: SavingsData | null) => void;
|
||||
|
||||
// Actions: settings
|
||||
updateSettings: (partial: Partial<Settings>) => void;
|
||||
|
||||
// Actions: UI
|
||||
setCommandPaletteOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => {
|
||||
const initial = loadConversations();
|
||||
const convList = Object.values(initial.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
);
|
||||
|
||||
return {
|
||||
conversations: convList,
|
||||
activeId: initial.activeId,
|
||||
messages:
|
||||
initial.activeId && initial.conversations[initial.activeId]
|
||||
? initial.conversations[initial.activeId].messages
|
||||
: [],
|
||||
streamState: INITIAL_STREAM,
|
||||
|
||||
models: [],
|
||||
modelsLoading: true,
|
||||
selectedModel: '',
|
||||
serverInfo: null,
|
||||
savings: null,
|
||||
|
||||
settings: loadSettings(),
|
||||
|
||||
commandPaletteOpen: false,
|
||||
sidebarOpen: true,
|
||||
|
||||
// ── Conversations ───────────────────────────────────────────────
|
||||
|
||||
loadConversations: () => {
|
||||
const store = loadConversations();
|
||||
set({
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
activeId: store.activeId,
|
||||
});
|
||||
},
|
||||
|
||||
createConversation: (model?: string) => {
|
||||
const store = loadConversations();
|
||||
const conv: Conversation = {
|
||||
id: generateId(),
|
||||
title: 'New chat',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
model: model || get().selectedModel || 'default',
|
||||
messages: [],
|
||||
};
|
||||
store.conversations[conv.id] = conv;
|
||||
store.activeId = conv.id;
|
||||
saveConversations(store);
|
||||
set({
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
activeId: conv.id,
|
||||
messages: [],
|
||||
});
|
||||
return conv.id;
|
||||
},
|
||||
|
||||
selectConversation: (id: string) => {
|
||||
const store = loadConversations();
|
||||
store.activeId = id;
|
||||
saveConversations(store);
|
||||
const conv = store.conversations[id];
|
||||
set({
|
||||
activeId: id,
|
||||
messages: conv ? conv.messages : [],
|
||||
});
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const store = loadConversations();
|
||||
delete store.conversations[id];
|
||||
if (store.activeId === id) {
|
||||
const remaining = Object.keys(store.conversations);
|
||||
store.activeId = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
saveConversations(store);
|
||||
const convList = Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
);
|
||||
const activeConv = store.activeId
|
||||
? store.conversations[store.activeId]
|
||||
: null;
|
||||
set({
|
||||
conversations: convList,
|
||||
activeId: store.activeId,
|
||||
messages: activeConv ? activeConv.messages : [],
|
||||
});
|
||||
},
|
||||
|
||||
loadMessages: (conversationId: string | null) => {
|
||||
if (!conversationId) {
|
||||
set({ messages: [] });
|
||||
return;
|
||||
}
|
||||
const store = loadConversations();
|
||||
const conv = store.conversations[conversationId];
|
||||
set({ messages: conv ? conv.messages : [] });
|
||||
},
|
||||
|
||||
addMessage: (conversationId: string, message: ChatMessage) => {
|
||||
const store = loadConversations();
|
||||
const conv = store.conversations[conversationId];
|
||||
if (!conv) return;
|
||||
conv.messages.push(message);
|
||||
conv.updatedAt = Date.now();
|
||||
if (message.role === 'user' && conv.title === 'New chat') {
|
||||
conv.title =
|
||||
message.content.slice(0, 50) +
|
||||
(message.content.length > 50 ? '...' : '');
|
||||
}
|
||||
saveConversations(store);
|
||||
set({
|
||||
messages: [...conv.messages],
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
updateLastAssistant: (
|
||||
conversationId: string,
|
||||
content: string,
|
||||
toolCalls?: ToolCallInfo[],
|
||||
usage?: TokenUsage,
|
||||
) => {
|
||||
const store = loadConversations();
|
||||
const conv = store.conversations[conversationId];
|
||||
if (!conv) return;
|
||||
const lastMsg = conv.messages[conv.messages.length - 1];
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = content;
|
||||
if (toolCalls) lastMsg.toolCalls = toolCalls;
|
||||
if (usage) lastMsg.usage = usage;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
}
|
||||
},
|
||||
|
||||
setStreamState: (partial: Partial<StreamState>) => {
|
||||
set((s) => ({ streamState: { ...s.streamState, ...partial } }));
|
||||
},
|
||||
|
||||
resetStream: () => {
|
||||
set({ streamState: INITIAL_STREAM });
|
||||
},
|
||||
|
||||
// ── Models & server ────────────────────────────────────────────
|
||||
|
||||
setModels: (models: ModelInfo[]) => set({ models }),
|
||||
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
|
||||
setSelectedModel: (model: string) => set({ selectedModel: model }),
|
||||
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
|
||||
setSavings: (data: SavingsData | null) => set({ savings: data }),
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────
|
||||
|
||||
updateSettings: (partial: Partial<Settings>) => {
|
||||
const updated = { ...get().settings, ...partial };
|
||||
saveSettings(updated);
|
||||
set({ settings: updated });
|
||||
},
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────────
|
||||
|
||||
setCommandPaletteOpen: (open: boolean) => set({ commandPaletteOpen: open }),
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
|
||||
};
|
||||
});
|
||||
|
||||
export { generateId };
|
||||
+22
-6
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChatArea } from '../components/Chat/ChatArea';
|
||||
|
||||
export function ChatPage() {
|
||||
return <ChatArea />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Palette,
|
||||
Globe,
|
||||
Cpu,
|
||||
Database,
|
||||
Info,
|
||||
Check,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Download,
|
||||
Upload,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type ThemeMode } from '../lib/store';
|
||||
import { checkHealth } from '../lib/api';
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3" style={{ borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<div>
|
||||
<div className="text-sm" style={{ color: 'var(--color-text)' }}>{label}</div>
|
||||
{description && (
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const settings = useAppStore((s) => s.settings);
|
||||
const updateSettings = useAppStore((s) => s.updateSettings);
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const serverInfo = useAppStore((s) => s.serverInfo);
|
||||
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth().then(setHealthy);
|
||||
}, []);
|
||||
|
||||
const showSaved = () => {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const data = localStorage.getItem('openjarvis-conversations') || '{}';
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `openjarvis-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.target?.result as string);
|
||||
if (data.version === 1) {
|
||||
localStorage.setItem('openjarvis-conversations', JSON.stringify(data));
|
||||
useAppStore.getState().loadConversations();
|
||||
showSaved();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (confirm('Delete all conversations? This cannot be undone.')) {
|
||||
localStorage.removeItem('openjarvis-conversations');
|
||||
useAppStore.getState().loadConversations();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings size={24} style={{ color: 'var(--color-accent)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Settings
|
||||
</h1>
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-1 rounded-full" style={{
|
||||
background: 'var(--color-accent-subtle)',
|
||||
color: 'var(--color-success)',
|
||||
}}>
|
||||
<Check size={12} /> Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Appearance */}
|
||||
<Section title="Appearance">
|
||||
<SettingRow label="Theme" description="Choose how OpenJarvis looks">
|
||||
<div className="flex gap-1 p-0.5 rounded-lg" style={{ background: 'var(--color-bg-secondary)' }}>
|
||||
{themeOptions.map((opt) => {
|
||||
const isActive = settings.theme === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { updateSettings({ theme: opt.value }); showSaved(); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? 'var(--color-surface)' : 'transparent',
|
||||
color: isActive ? 'var(--color-text)' : 'var(--color-text-tertiary)',
|
||||
boxShadow: isActive ? 'var(--shadow-sm)' : 'none',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={14} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="Font size">
|
||||
<select
|
||||
value={settings.fontSize}
|
||||
onChange={(e) => { updateSettings({ fontSize: e.target.value as any }); showSaved(); }}
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none cursor-pointer"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<option value="small">Small</option>
|
||||
<option value="default">Default</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Connection */}
|
||||
<Section title="Connection">
|
||||
<SettingRow label="Server status" description={serverInfo ? `${serverInfo.engine} / ${serverInfo.model}` : 'Not connected'}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ background: healthy === true ? 'var(--color-success)' : healthy === false ? 'var(--color-error)' : 'var(--color-text-tertiary)' }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{healthy === true ? 'Connected' : healthy === false ? 'Disconnected' : 'Checking...'}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="API URL" description="Leave empty for same-origin">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => { updateSettings({ apiUrl: e.target.value }); showSaved(); }}
|
||||
placeholder="http://localhost:8000"
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Model defaults */}
|
||||
<Section title="Model Defaults">
|
||||
<SettingRow label="Temperature" description={`${settings.temperature}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={settings.temperature}
|
||||
onChange={(e) => { updateSettings({ temperature: parseFloat(e.target.value) }); showSaved(); }}
|
||||
className="w-32 cursor-pointer accent-[var(--color-accent)]"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Max tokens" description={`${settings.maxTokens}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="256"
|
||||
max="32768"
|
||||
step="256"
|
||||
value={settings.maxTokens}
|
||||
onChange={(e) => { updateSettings({ maxTokens: parseInt(e.target.value) }); showSaved(); }}
|
||||
className="w-32 cursor-pointer accent-[var(--color-accent)]"
|
||||
/>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Data */}
|
||||
<Section title="Data">
|
||||
<SettingRow label="Conversations" description={`${conversations.length} stored locally`}>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
|
||||
>
|
||||
<Download size={12} /> Export
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-bg-secondary)')}
|
||||
>
|
||||
<Upload size={12} /> Import
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="Clear all data" description="Permanently delete all conversations">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(220,38,38,0.1)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section title="About">
|
||||
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<p className="mb-2">
|
||||
<span className="font-semibold" style={{ color: 'var(--color-text)' }}>OpenJarvis</span> — Programming abstractions for on-device AI.
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Part of Intelligence Per Watt, a research initiative at Stanford SAIL.
|
||||
</p>
|
||||
<div className="flex gap-3 mt-3 text-xs">
|
||||
<a
|
||||
href="https://www.intelligence-per-watt.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
Project site
|
||||
</a>
|
||||
<a
|
||||
href="https://hazyresearch.stanford.edu/OpenJarvis/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/* Catppuccin Mocha theme tokens */
|
||||
:root {
|
||||
/* Base palette */
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
|
||||
/* Semantic tokens */
|
||||
--color-primary: var(--ctp-blue);
|
||||
--color-primary-light: var(--ctp-sapphire);
|
||||
--color-primary-dark: var(--ctp-lavender);
|
||||
--color-bg: var(--ctp-base);
|
||||
--color-bg-sidebar: var(--ctp-mantle);
|
||||
--color-bg-surface: var(--ctp-surface0);
|
||||
--color-text: var(--ctp-text);
|
||||
--color-text-secondary: var(--ctp-subtext0);
|
||||
--color-border: var(--ctp-surface0);
|
||||
--color-user-bubble: var(--ctp-blue);
|
||||
--color-assistant-bubble: var(--ctp-surface0);
|
||||
--color-tool-bg: rgba(137, 180, 250, 0.08);
|
||||
--color-tool-border: var(--ctp-surface1);
|
||||
--color-tool-success: var(--ctp-green);
|
||||
--color-tool-running: var(--ctp-yellow);
|
||||
--color-tool-error: var(--ctp-red);
|
||||
--color-savings: var(--ctp-green);
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
+16
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user