diff --git a/README.md b/README.md index d722ec8..8618685 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # RouteBox -macOS menu bar app — LLM API proxy with intelligent routing, real-time monitoring, and cost tracking. +macOS menu bar app — LLM API proxy with intelligent routing, real-time monitoring, cost tracking, built-in chat, and web search. + +**v2.3.0** · [Download DMG](https://github.com/createpjf/RouteBox/releases/latest) · MIT License ## What it does @@ -9,9 +11,12 @@ RouteBox runs a local OpenAI-compatible proxy (`http://localhost:3001/v1`). You 1. **Routes** requests to the best provider based on your rules (cheapest, fastest, or smartest) 2. **Tracks** tokens, cost, latency, and savings in real-time 3. **Manages** multiple provider API keys securely in macOS Keychain +4. **Chat** directly with any model via the built-in Chat window or Spotlight +5. **Search** the web in real-time with Brave Search integration ``` Your App → RouteBox (localhost:3001) → OpenAI / Anthropic / Google / DeepSeek / MiniMax / Kimi / FLock.io + ↑ Brave Search results injected as context ``` ## Supported Providers @@ -26,7 +31,66 @@ Your App → RouteBox (localhost:3001) → OpenAI / Anthropic / Google / Dee | [Kimi](https://kimi.ai) | Kimi K2.5, Kimi K2, Moonshot | [platform.moonshot.ai](https://platform.moonshot.ai/) | | [FLock.io](https://flock.io) | Qwen3-235B, Qwen3-30B, DeepSeek-V3.2, Kimi K2.5 | [platform.flock.io](https://platform.flock.io) | -> **Tip:** [FLock API Platform](https://platform.flock.io) provides access to open-source models (Qwen3, DeepSeek, Kimi)— a good option for cost-effective routing. +**Local models**: Ollama and LM Studio are auto-discovered on your network — no API key needed. + +> **Tip:** [FLock API Platform](https://platform.flock.io) provides access to open-source models (Qwen3, DeepSeek, Kimi) — a good option for cost-effective routing. + +## Features + +### Three Windows + +| Window | Shortcut | Description | +|--------|----------|-------------| +| **Panel** | `⌘⇧R` | Menu bar dashboard — routing, analytics, logs, settings | +| **Spotlight** | `⌘⇧Space` | Quick floating window — ask a question, get an instant answer | +| **Chat** | Via panel | Full chat interface with conversation history and sidebar | + +### Dashboard Tabs + +| Tab | What it shows | +|-----|---------------| +| **Dashboard** | Requests, tokens, cost, savings, traffic sparkline, provider status | +| **Routing** | Strategy selector, model preferences (pin/exclude), content-aware rules | +| **Logs** | Full request history with model, provider, latency, cost per request | +| **Analytics** | Charts for cost trends, provider latency, model usage breakdown | +| **My Usage** | Today/month usage, budget tracking, weekly trends, model breakdown | + +### Web Search (Brave Search) + +RouteBox can inject real-time web search results into any LLM conversation: + +1. Go to **Settings → Web Search** and add your [Brave Search API key](https://brave.com/search/api) (free tier available) +2. Toggle the 🌐 button in Chat or Spotlight to enable search for a message +3. RouteBox searches the web, injects results as context, and the model responds with up-to-date information and source citations + +### Intelligent Routing + +| Strategy | Behavior | +|----------|----------| +| Smart Auto | AI picks the best route per request | +| Cost First | Always pick the cheapest provider | +| Speed First | Always pick the lowest latency provider | +| Quality First | Always pick the best available model | + +### Routing Rules + +| Rule Type | Triggers when... | Example use | +|-----------|-------------------|-------------| +| **Alias** | Model name matches your virtual name | `route-code` → `deepseek-coder` | +| **Code** | Request contains ≥3 code markers | Auto-route code tasks to DeepSeek | +| **Long** | Message ≥8,000 characters | Auto-route long context to Gemini | +| **General** | Catch-all fallback | Default model for everything else | + +### Model Preferences + +Pin a model to a specific provider, or exclude a provider for a model: + +- **Pin**: `gpt-4o` → always use OpenAI (never fall back) +- **Exclude**: `gpt-4o` → never use provider X + +### Thinking Model Support + +Models that output `` blocks (DeepSeek-R1, Qwen3, etc.) are automatically handled — thinking content is hidden behind a collapsible "💭 Thinking..." section. ## Setup @@ -78,45 +142,11 @@ client = OpenAI( ) ``` -## App Tabs - -| Tab | What it shows | -|-----|---------------| -| **Dashboard** | Requests, tokens, cost, savings, traffic sparkline, provider status | -| **Routing** | Strategy selector, model preferences (pin/exclude), content-aware rules | -| **Logs** | Full request history with model, provider, latency, cost per request | -| **Analytics** | Charts for cost trends, provider latency, model usage breakdown | - -## Routing - -### Strategy - -Pick one in the Routing tab: - -| Strategy | Behavior | -|----------|----------| -| Smart Auto | AI picks the best route per request | -| Cost First | Always pick the cheapest provider | -| Speed First | Always pick the lowest latency provider | -| Quality First | Always pick the best available model | - -### Rules - -Create rules to route specific request types: +### 4. Enable Web Search (Optional) -| Rule Type | Triggers when... | Example use | -|-----------|-------------------|-------------| -| **Alias** | Model name matches your virtual name | `route-code` → `deepseek-coder` | -| **Code** | Request contains ≥3 code markers | Auto-route code tasks to DeepSeek | -| **Long** | Message ≥8,000 characters | Auto-route long context to Gemini | -| **General** | Catch-all fallback | Default model for everything else | +Go to **Settings → Web Search** → Paste your Brave Search API key → Save. -### Model Preferences - -Pin a model to a specific provider, or exclude a provider for a model: - -- **Pin**: `gpt-4o` → always use OpenAI (never fall back) -- **Exclude**: `gpt-4o` → never use provider X +Then toggle 🌐 in Chat or Spotlight before sending a message to include web results. ## Build DMG @@ -146,6 +176,7 @@ docker build -t routebox-gateway . docker run -p 3001:3001 \ -e OPENAI_API_KEY=sk-... \ -e ANTHROPIC_API_KEY=sk-ant-... \ + -e BRAVE_API_KEY=BSA-... \ -v routebox-data:/data \ routebox-gateway ``` @@ -158,6 +189,7 @@ See [`apps/gateway/.env.example`](apps/gateway/.env.example) for all environment |---------|----------|-------| | Provider API Keys | Settings → Providers | Stored in macOS Keychain | | Monthly Budget | Settings → Budget | Alerts at 80% and 100% | +| Web Search | Settings → Web Search | Brave Search API key for real-time search | | Gateway URL | Settings → Connection | Default `http://localhost:3001`, customizable | | Auth Token | Settings → Authentication | Auto-generated, stored in Keychain | | Auto-start Gateway | Settings → Gateway | On/off toggle | @@ -168,13 +200,29 @@ See [`apps/gateway/.env.example`](apps/gateway/.env.example) for all environment | Shortcut | Action | |----------|--------| | `⌘⇧R` | Toggle panel (global) | +| `⌘⇧Space` | Toggle Spotlight (global) | | `⌘C` | Copy API key | | `⌘P` | Pause/resume traffic | +| `⌘⏎` | Send message (Spotlight) | +| `Esc` | Close Spotlight | + +## Architecture + +``` +RouteBox/ +├── apps/ +│ ├── desktop/ Tauri v2 + React 19 (panel, spotlight, chat windows) +│ │ └── src-tauri/ Rust backend (system tray, global shortcuts, keychain) +│ └── gateway/ Bun + Hono (proxy, routing, analytics, search) +├── package.json pnpm monorepo root +└── README.md +``` ## Tech Stack - **Desktop**: Tauri v2 (Rust) + React 19 + TypeScript + Tailwind CSS v4 - **Gateway**: Bun + Hono + bun:sqlite +- **Search**: Brave Search API - **Design**: SF Pro, frosted glass (macOS native) ## License diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 967befa..c1231d1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@routebox/desktop", "private": true, - "version": "1.2.0", + "version": "2.3.0", "type": "module", "scripts": { "dev": "vite", @@ -23,7 +23,10 @@ "lucide-react": "^0.460.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "recharts": "^2.15.0" + "react-markdown": "^9.1.0", + "recharts": "^2.15.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index bb9c3fc..48ebeb9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3445,7 +3445,7 @@ dependencies = [ [[package]] name = "routebox-desktop" -version = "1.2.0" +version = "2.3.0" dependencies = [ "arboard", "keyring", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 30ba61e..c49ed10 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "routebox-desktop" -version = "1.2.0" +version = "2.3.0" description = "RouteBox macOS Menu Bar App" authors = ["RouteBox"] edition = "2021" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 4d2d983..7751489 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "global-shortcut:default", "notification:default", "store:default", - "process:default" + "process:default", + "updater:default" ] } diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 2572002..9130e4f 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,5 +1,5 @@ use crate::keychain; -use tauri::Manager; +use tauri::{Emitter, Manager}; use tauri_plugin_positioner::{Position, WindowExt}; use arboard::Clipboard; use std::sync::Mutex; @@ -70,6 +70,62 @@ pub fn toggle_panel_internal(app: &tauri::AppHandle) -> Result<(), String> { Ok(()) } +// ── Spotlight toggle ──────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn toggle_spotlight(app: tauri::AppHandle) -> Result<(), String> { + toggle_spotlight_internal(&app) +} + +pub fn toggle_spotlight_internal(app: &tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("spotlight") { + if window.is_visible().unwrap_or(false) { + window.hide().map_err(|e| e.to_string())?; + } else { + window.center().map_err(|e| e.to_string())?; + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +// ── Chat window ───────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn open_chat(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("chat") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + } + Ok(()) +} + +// ── Clipboard read ────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn read_clipboard() -> Result { + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + clipboard.get_text().map_err(|e| e.to_string()) +} + +// ── Clipboard action (read + emit event + show spotlight) ─────────────────── + +#[tauri::command] +pub async fn clipboard_action(app: tauri::AppHandle, action: String) -> Result<(), String> { + clipboard_action_sync(&app, &action) +} + +/// Synchronous version for use from shortcut handlers (no async runtime needed) +pub fn clipboard_action_sync(app: &tauri::AppHandle, action: &str) -> Result<(), String> { + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + let text = clipboard.get_text().unwrap_or_default(); + app.emit("spotlight-action", serde_json::json!({ "action": action, "text": text })) + .map_err(|e: tauri::Error| e.to_string())?; + toggle_spotlight_internal(app)?; + Ok(()) +} + // ── Gateway process management ────────────────────────────────────────────── #[tauri::command] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index de6560a..3cba166 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -29,8 +29,6 @@ pub fn run() { window.on_window_event(move |event| { if let tauri::WindowEvent::Focused(false) = event { let win = w.clone(); - // Small delay: if focus returns within 200ms (e.g. DevTools, HMR), - // don't hide. This prevents the panel flickering in dev mode. std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(200)); if !win.is_focused().unwrap_or(true) { @@ -41,27 +39,70 @@ pub fn run() { }); } - // Register global hotkey: Cmd+Shift+R + // Hide spotlight when it loses focus + if let Some(window) = app.get_webview_window("spotlight") { + let w = window.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + let win = w.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + if !win.is_focused().unwrap_or(true) { + let _ = win.hide(); + } + }); + } + }); + } + + // Register global hotkeys #[cfg(desktop)] { use tauri_plugin_global_shortcut::{ Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState, }; - let shortcut = + let shortcut_panel = Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyR); + let shortcut_spotlight = + Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyX); + let shortcut_translate = + Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyT); + let shortcut_summarize = + Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyS); + let shortcut_explain = + Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyE); let handle = app.handle().clone(); + let sc_panel = shortcut_panel.clone(); + let sc_spotlight = shortcut_spotlight.clone(); + let sc_translate = shortcut_translate.clone(); + let sc_summarize = shortcut_summarize.clone(); + app.handle().plugin( tauri_plugin_global_shortcut::Builder::new() - .with_handler(move |_app, _shortcut, event| { + .with_handler(move |_app, shortcut, event| { if event.state() == ShortcutState::Pressed { - let _ = commands::toggle_panel_internal(&handle); + if *shortcut == sc_panel { + let _ = commands::toggle_panel_internal(&handle); + } else if *shortcut == sc_spotlight { + let _ = commands::toggle_spotlight_internal(&handle); + } else if *shortcut == sc_translate { + let _ = commands::clipboard_action_sync(&handle, "translate"); + } else if *shortcut == sc_summarize { + let _ = commands::clipboard_action_sync(&handle, "summarize"); + } else { + let _ = commands::clipboard_action_sync(&handle, "explain"); + } } }) .build(), )?; - app.global_shortcut().register(shortcut)?; + app.global_shortcut().register(shortcut_panel)?; + app.global_shortcut().register(shortcut_spotlight)?; + app.global_shortcut().register(shortcut_translate)?; + app.global_shortcut().register(shortcut_summarize)?; + app.global_shortcut().register(shortcut_explain)?; } Ok(()) @@ -73,6 +114,10 @@ pub fn run() { commands::copy_to_clipboard, commands::show_notification, commands::toggle_panel, + commands::toggle_spotlight, + commands::open_chat, + commands::read_clipboard, + commands::clipboard_action, commands::spawn_gateway, commands::stop_gateway, commands::is_gateway_running, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c645ad4..4bd938e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "RouteBox", - "version": "1.2.0", + "version": "2.3.0", "identifier": "com.routebox.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -22,6 +22,34 @@ "visible": false, "alwaysOnTop": true, "skipTaskbar": true + }, + { + "label": "spotlight", + "title": "RouteBox Spotlight", + "url": "index.html#/spotlight", + "width": 540, + "height": 420, + "resizable": false, + "decorations": false, + "transparent": true, + "visible": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "center": true + }, + { + "label": "chat", + "title": "RouteBox Chat", + "url": "index.html#/chat", + "width": 900, + "height": 700, + "minWidth": 700, + "minHeight": 500, + "resizable": true, + "decorations": true, + "transparent": false, + "visible": false, + "alwaysOnTop": false } ], "security": { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 14e43c9..797ae87 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Panel } from "@/components/Panel"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { HeroSection } from "@/components/HeroSection"; @@ -11,6 +11,7 @@ import { Settings } from "@/components/Settings"; import { Onboarding } from "@/components/Onboarding"; import { RequestDetail } from "@/components/RequestDetail"; import { AnalyticsPage } from "@/components/AnalyticsPage"; +import { UsagePage } from "@/components/UsagePage"; import { AlertBanner } from "@/components/AlertBanner"; import { ToastContainer } from "@/components/ToastContainer"; import { useRealtimeStats } from "@/hooks/useRealtimeStats"; @@ -151,6 +152,25 @@ export function App() { } }, [connected, stats, onboardingDismissed, showSettings]); + // Auto-check for updates on startup (once, 5s delay) + const updateCheckedRef = useRef(false); + useEffect(() => { + if (updateCheckedRef.current || gatewayState !== "running") return; + updateCheckedRef.current = true; + const timer = setTimeout(async () => { + try { + const { check } = await import("@tauri-apps/plugin-updater"); + const update = await check(); + if (update) { + showToast(`New version available! Open Settings → About to update.`, "info", 8000); + } + } catch { + // Silent — update check is best-effort + } + }, 5000); + return () => clearTimeout(timer); + }, [gatewayState, showToast]); + const handleDismissOnboarding = useCallback(async () => { setShowOnboarding(false); setOnboardingDismissed(true); @@ -175,6 +195,12 @@ export function App() { gatewayState={gatewayState} gatewayError={gatewayError} onOpenSettings={() => setShowSettings(true)} + onOpenChat={() => { + import("@tauri-apps/api/core").then(({ invoke }) => invoke("open_chat")).catch(() => {}); + }} + onOpenSpotlight={() => { + import("@tauri-apps/api/core").then(({ invoke }) => invoke("toggle_spotlight")).catch(() => {}); + }} /> {alert && ( )} + {activeTab === "usage" && } {activeTab === "analytics" && } diff --git a/apps/desktop/src/__tests__/components.test.tsx b/apps/desktop/src/__tests__/components.test.tsx index b045e17..d55a38c 100644 --- a/apps/desktop/src/__tests__/components.test.tsx +++ b/apps/desktop/src/__tests__/components.test.tsx @@ -14,7 +14,7 @@ vi.mock("lucide-react", async () => { Wallet: icon, Activity: icon, XCircle: icon, Key: icon, Shield: icon, AlertCircle: icon, BookOpen: icon, ArrowRight: icon, BarChart3: icon, Pin: icon, Ban: icon, Plus: icon, Square: icon, - TrendingUp: icon, Cpu: icon, Clock: icon, + TrendingUp: icon, Cpu: icon, Clock: icon, PieChart: icon, }; }); @@ -163,9 +163,10 @@ describe("RequestLogPage", () => { }); describe("TabBar", () => { - test("renders four tabs", () => { + test("renders five tabs", () => { render( {}} />); expect(screen.getByText("Dashboard")).toBeDefined(); + expect(screen.getByText("My Usage")).toBeDefined(); expect(screen.getByText("Routing")).toBeDefined(); expect(screen.getByText("Logs")).toBeDefined(); expect(screen.getByText("Analytics")).toBeDefined(); diff --git a/apps/desktop/src/components/HeroSection.tsx b/apps/desktop/src/components/HeroSection.tsx index e482be4..1e29b1f 100644 --- a/apps/desktop/src/components/HeroSection.tsx +++ b/apps/desktop/src/components/HeroSection.tsx @@ -1,4 +1,4 @@ -import { Settings, Loader2 } from "lucide-react"; +import { Settings, Loader2, MessageSquare, Sparkles } from "lucide-react"; type GatewayState = "idle" | "checking" | "starting" | "running" | "failed"; @@ -8,9 +8,11 @@ interface HeroSectionProps { gatewayState?: GatewayState; gatewayError?: string | null; onOpenSettings: () => void; + onOpenChat?: () => void; + onOpenSpotlight?: () => void; } -export function HeroSection({ connected, stale, gatewayState, gatewayError, onOpenSettings }: HeroSectionProps) { +export function HeroSection({ connected, stale, gatewayState, gatewayError, onOpenSettings, onOpenChat, onOpenSpotlight }: HeroSectionProps) { // Derive status text & color let statusText = connected ? "Online" : "Offline"; let statusColor = connected ? "#34C759" : "#C7C7CC"; @@ -68,12 +70,32 @@ export function HeroSection({ connected, stale, gatewayState, gatewayError, onOp - +
+ {onOpenSpotlight && ( + + )} + {onOpenChat && ( + + )} + +
{gatewayState === "failed" && shortError && (
diff --git a/apps/desktop/src/components/ProviderKeyManager.tsx b/apps/desktop/src/components/ProviderKeyManager.tsx index 9b55ff6..0653e1b 100644 --- a/apps/desktop/src/components/ProviderKeyManager.tsx +++ b/apps/desktop/src/components/ProviderKeyManager.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; -import { Key, Trash2, Check, Loader2, AlertCircle, Shield } from "lucide-react"; +import { Key, Trash2, Check, Loader2, AlertCircle, Shield, RefreshCw, Monitor } from "lucide-react"; import clsx from "clsx"; -import { api, type ProviderRegistryEntry } from "@/lib/api"; +import { api, type ProviderRegistryEntry, type LocalProviderInfo } from "@/lib/api"; interface ProviderKeyManagerProps { /** Called when a key is saved/deleted so parent can react */ @@ -10,17 +10,26 @@ interface ProviderKeyManagerProps { export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerProps) { const [providers, setProviders] = useState([]); + const [localProviders, setLocalProviders] = useState([]); const [loading, setLoading] = useState(true); const [editingProvider, setEditingProvider] = useState(null); const [keyInput, setKeyInput] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [successProvider, setSuccessProvider] = useState(null); + const [editingLocalUrl, setEditingLocalUrl] = useState(null); + const [localUrlInput, setLocalUrlInput] = useState(""); + const [localApiKeyInput, setLocalApiKeyInput] = useState(""); + const [refreshingLocal, setRefreshingLocal] = useState(null); const fetchRegistry = useCallback(async () => { try { - const res = await api.getProviderRegistry(); - setProviders(res.providers); + const [regRes, localRes] = await Promise.all([ + api.getProviderRegistry(), + api.getLocalProviders().catch(() => ({ providers: [] })), + ]); + setProviders(regRes.providers); + setLocalProviders(localRes.providers); } catch { // silent — may not be connected yet } finally { @@ -61,6 +70,37 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro } }, [fetchRegistry, onProvidersChanged]); + const handleRefreshLocal = useCallback(async (name: string) => { + setRefreshingLocal(name); + try { + const updated = await api.refreshLocalProvider(name); + setLocalProviders((prev) => + prev.map((lp) => lp.name === name ? { ...lp, ...updated } : lp) + ); + } catch {} + setRefreshingLocal(null); + }, []); + + const handleSaveLocalUrl = useCallback(async (name: string) => { + if (!localUrlInput.trim()) return; + setSaving(true); + setError(null); + try { + const apiKeyArg = localApiKeyInput.trim() || undefined; + const updated = await api.setLocalProviderUrl(name, localUrlInput.trim(), apiKeyArg); + setLocalProviders((prev) => + prev.map((lp) => lp.name === name ? { ...lp, ...updated } : lp) + ); + setEditingLocalUrl(null); + setLocalUrlInput(""); + setLocalApiKeyInput(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update URL"); + } finally { + setSaving(false); + } + }, [localUrlInput, localApiKeyInput]); + if (loading) { return (
@@ -80,6 +120,140 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro } return ( +
+ {/* Local providers */} + {localProviders.length > 0 && ( +
+
+ + Local +
+ {localProviders.map((lp, idx) => { + const isEditingUrl = editingLocalUrl === lp.name; + const isRefreshing = refreshingLocal === lp.name; + const isLast = idx === localProviders.length - 1; + + return ( +
+
{ + if (!isEditingUrl) { + setEditingLocalUrl(lp.name); + setLocalUrlInput(lp.baseUrl); + setLocalApiKeyInput(""); + setError(null); + } + }} + > +
+ {lp.name} +
+ {lp.hasApiKey && ( + + + + )} + {lp.isOnline ? ( + + {lp.modelCount} model{lp.modelCount !== 1 ? "s" : ""} + + ) : ( + Offline + )} + +
+
+ + {isEditingUrl && ( +
+
+ { + setLocalUrlInput(e.target.value); + setError(null); + }} + placeholder={`Base URL (e.g. http://192.168.1.100:1234/v1)`} + className="input input-mono flex-1 !h-8 !text-[11px]" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveLocalUrl(lp.name); + if (e.key === "Escape") { + setEditingLocalUrl(null); + setLocalApiKeyInput(""); + setError(null); + } + }} + /> + +
+ setLocalApiKeyInput(e.target.value)} + placeholder={lp.hasApiKey ? "API Key (configured — leave blank to keep)" : "API Key (optional)"} + className="input input-mono w-full !h-8 !text-[11px] mt-1.5" + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveLocalUrl(lp.name); + if (e.key === "Escape") { + setEditingLocalUrl(null); + setLocalApiKeyInput(""); + setError(null); + } + }} + /> + {error && editingLocalUrl === lp.name && ( +
+ + {error} +
+ )} +
+ )} +
+ ); + })} +
+ )} + + {/* Cloud providers */}
{providers.map((p, idx) => { const isEditing = editingProvider === p.name; @@ -200,5 +374,6 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro ); })}
+
); } diff --git a/apps/desktop/src/components/Settings.tsx b/apps/desktop/src/components/Settings.tsx index 481df37..cf96b10 100644 --- a/apps/desktop/src/components/Settings.tsx +++ b/apps/desktop/src/components/Settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import { Check, Loader2, Trash2, ChevronRight, X, BookOpen, Play, Square, Download, RefreshCw, Wifi } from "lucide-react"; +import { Check, Loader2, Trash2, ChevronRight, X, BookOpen, Play, Square, Download, RefreshCw, Wifi, Globe } from "lucide-react"; import clsx from "clsx"; import { getGatewayUrl, setGatewayUrl as setGatewayUrlGlobal, setAuthToken, getPortFromUrl } from "@/lib/constants"; import { checkGatewayHealth } from "@/lib/gateway-health"; @@ -42,6 +42,10 @@ export function Settings({ onClose, onShowOnboarding }: SettingsProps) { "idle" | "checking" | "upToDate" | "available" | "downloading" | "ready" >("idle"); const [updateError, setUpdateError] = useState(null); + const [searchKey, setSearchKey] = useState(""); + const [searchHasKey, setSearchHasKey] = useState(false); + const [searchSaving, setSearchSaving] = useState(false); + const [searchSaved, setSearchSaved] = useState(false); useEffect(() => { tauriInvoke("get_token") @@ -72,6 +76,10 @@ export function Settings({ onClose, onShowOnboarding }: SettingsProps) { }) .catch(() => {}); + api.getSearchStatus() + .then((res) => setSearchHasKey(res.hasKey)) + .catch(() => {}); + // Gateway state — single HTTP health check (works for both spawned and external gateways) checkGatewayHealth(getGatewayUrl()) .then(setGwRunning) @@ -200,6 +208,27 @@ export function Settings({ onClose, onShowOnboarding }: SettingsProps) { } }, []); + const handleSaveSearchKey = useCallback(async () => { + if (!searchKey.trim()) return; + setSearchSaving(true); + try { + await api.setSearchKey(searchKey.trim()); + setSearchHasKey(true); + setSearchSaved(true); + setSearchKey(""); + setTimeout(() => setSearchSaved(false), 2000); + } catch {} + setSearchSaving(false); + }, [searchKey]); + + const handleDeleteSearchKey = useCallback(async () => { + try { + await api.deleteSearchKey(); + setSearchHasKey(false); + setSearchKey(""); + } catch {} + }, []); + const handleRelaunch = useCallback(async () => { try { const { relaunch } = await import("@tauri-apps/plugin-process"); @@ -288,6 +317,69 @@ export function Settings({ onClose, onShowOnboarding }: SettingsProps) {
+ {/* Web Search */} +
+

Web Search

+
+
+ + Brave Search + {searchHasKey && ( + + Active + + )} +
+ {!searchHasKey && ( +

+ Add a Brave Search API key to enable web search in Chat and Spotlight. + Get a free key at{" "} + brave.com/search/api +

+ )} + + { setSearchKey(e.target.value); setSearchSaved(false); }} + placeholder={searchHasKey ? "Key saved • paste new to replace" : "BSA-xxxxxxxxxxxxxxxx"} + className="input" + /> +
+ + {searchHasKey && ( + + )} +
+
+
+ {/* Gateway */}

Gateway

diff --git a/apps/desktop/src/components/TabBar.tsx b/apps/desktop/src/components/TabBar.tsx index b214f31..be5dcfb 100644 --- a/apps/desktop/src/components/TabBar.tsx +++ b/apps/desktop/src/components/TabBar.tsx @@ -1,7 +1,7 @@ -import { LayoutDashboard, Route, ScrollText, BarChart3 } from "lucide-react"; +import { LayoutDashboard, Route, ScrollText, BarChart3, PieChart } from "lucide-react"; import clsx from "clsx"; -export type TabId = "dashboard" | "routing" | "logs" | "analytics"; +export type TabId = "dashboard" | "usage" | "routing" | "logs" | "analytics"; interface TabBarProps { activeTab: TabId; @@ -10,6 +10,7 @@ interface TabBarProps { const TABS: { id: TabId; label: string; icon: typeof LayoutDashboard }[] = [ { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, + { id: "usage", label: "My Usage", icon: PieChart }, { id: "routing", label: "Routing", icon: Route }, { id: "logs", label: "Logs", icon: ScrollText }, { id: "analytics", label: "Analytics", icon: BarChart3 }, diff --git a/apps/desktop/src/components/ToastContainer.tsx b/apps/desktop/src/components/ToastContainer.tsx index 7a601b0..15f6bf8 100644 --- a/apps/desktop/src/components/ToastContainer.tsx +++ b/apps/desktop/src/components/ToastContainer.tsx @@ -19,6 +19,8 @@ export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) { "flex items-center gap-2 px-3 h-8 rounded-lg text-[11px] font-medium animate-slide-in pointer-events-auto", toast.type === "error" ? "bg-[rgba(255,59,48,0.06)] text-[#FF3B30] border border-[rgba(255,59,48,0.12)]" + : toast.type === "info" + ? "bg-[rgba(0,122,255,0.06)] text-[#007AFF] border border-[rgba(0,122,255,0.12)]" : "bg-[rgba(52,199,89,0.06)] text-[#34C759] border border-[rgba(52,199,89,0.12)]" )} > diff --git a/apps/desktop/src/components/UsagePage.tsx b/apps/desktop/src/components/UsagePage.tsx new file mode 100644 index 0000000..5ed0fb3 --- /dev/null +++ b/apps/desktop/src/components/UsagePage.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect } from "react"; +import { Loader2, DollarSign, Zap, TrendingDown, Lightbulb } from "lucide-react"; +import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import { api } from "@/lib/api"; +import type { UsageTodayResponse, UsageMonthResponse, WeeklyTrendRow, ModelBreakdownRow, UsageSuggestion } from "@/lib/api"; + +function formatCost(n: number): string { + if (!Number.isFinite(n)) return "$0.00"; + if (n >= 1) return `$${n.toFixed(2)}`; + if (n >= 0.01) return `$${n.toFixed(3)}`; + return `$${n.toFixed(4)}`; +} + +const tooltipStyle = { + background: "#FFFFFF", + border: "0.5px solid rgba(0, 0, 0, 0.08)", + borderRadius: "8px", + fontSize: "11px", + fontFamily: "var(--font-sans)", + color: "#1D1D1F", + padding: "4px 8px", + boxShadow: "0 2px 8px rgba(0,0,0,0.06)", +}; + +const MODEL_COLORS = [ + "#34C759", "#007AFF", "#FF9500", "#AF52DE", "#FF3B30", + "#5AC8FA", "#FFCC00", "#FF2D55", "#64D2FF", "#30D158", +]; + +export function UsagePage() { + const [loading, setLoading] = useState(true); + const [today, setToday] = useState(null); + const [month, setMonth] = useState(null); + const [weekly, setWeekly] = useState([]); + const [models, setModels] = useState([]); + const [suggestions, setSuggestions] = useState([]); + + useEffect(() => { + async function load() { + setLoading(true); + try { + const [t, m, w, md, s] = await Promise.all([ + api.getUsageToday(), + api.getUsageMonth(), + api.getUsageWeekly(), + api.getUsageModels(), + api.getUsageSuggestions(), + ]); + setToday(t); + setMonth(m); + setWeekly(w); + setModels(md); + setSuggestions(s); + } catch {} + setLoading(false); + } + load(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Stat cards */} +
+ } + label="Today" + value={formatCost(today?.cost ?? 0)} + sub={`${today?.requests ?? 0} requests`} + /> + } + label="This Month" + value={formatCost(month?.cost ?? 0)} + sub={month?.budgetPct ? `${month.budgetPct}% of budget` : "No budget set"} + /> + } + label="Saved" + value={formatCost(today?.saved ?? 0)} + sub="by routing" + /> +
+ + {/* 7-day trend chart */} +
+
+ 7-Day Cost Trend +
+
+ + + + + + + + + { + const parts = d.split("-"); + return `${parts[1]}/${parts[2]}`; + }} + tick={{ fontSize: 9, fill: "#AEAEB2" }} + axisLine={false} + tickLine={false} + /> + [formatCost(value), "Cost"]} + /> + + + +
+
+ + {/* Model breakdown */} +
+
+ Model Breakdown +
+ {models.length === 0 ? ( +
No data yet
+ ) : ( +
+ {models.map((m, i) => ( +
+
+ {m.model} +
+
+
+
+
+ {formatCost(m.cost)} +
+
+ {m.pct}% +
+
+ ))} +
+ )} +
+ + {/* Cost tips */} + {suggestions.length > 0 && ( +
+
+ Tips +
+
+ {suggestions.map((s, i) => ( +
+ {s.message} + {s.savingsEstimate && ( + + Save {s.savingsEstimate} + + )} +
+ ))} +
+
+ )} +
+ ); +} + +function StatCard({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: string; sub: string }) { + return ( +
+
+ {icon} {label} +
+
+ {value} +
+
+ {sub} +
+
+ ); +} diff --git a/apps/desktop/src/components/shared/CostBar.tsx b/apps/desktop/src/components/shared/CostBar.tsx new file mode 100644 index 0000000..f70a6c7 --- /dev/null +++ b/apps/desktop/src/components/shared/CostBar.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import type { RouteboxMeta } from "../../types/chat"; + +interface CostBarProps { + meta: RouteboxMeta; + compact?: boolean; +} + +function formatCost(cost: number): string { + if (cost < 0.001) return `$${cost.toFixed(6)}`; + if (cost < 0.01) return `$${cost.toFixed(4)}`; + return `$${cost.toFixed(3)}`; +} + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +export const CostBar: React.FC = ({ meta, compact }) => { + const items = [ + meta.model, + meta.provider, + `${formatTokens(meta.usage.total_tokens)} tok`, + formatCost(meta.cost), + `${meta.latency_ms}ms`, + ]; + + return ( +
+ {items.map((item, i) => ( + + {i > 0 && ·} + {item} + + ))} + {meta.is_fallback && ( + fallback + )} +
+ ); +}; diff --git a/apps/desktop/src/components/shared/MarkdownRenderer.tsx b/apps/desktop/src/components/shared/MarkdownRenderer.tsx new file mode 100644 index 0000000..c47c01e --- /dev/null +++ b/apps/desktop/src/components/shared/MarkdownRenderer.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; +import { ChevronRight } from "lucide-react"; +import { stripThinkingTags } from "../../lib/thinking"; + +interface MarkdownRendererProps { + content: string; + className?: string; + /** If true, show thinking/reasoning blocks when present */ + showThinking?: boolean; +} + +export const MarkdownRenderer: React.FC = ({ + content, + className, + showThinking = true, +}) => { + const [thinkingOpen, setThinkingOpen] = useState(false); + const { content: cleaned, thinking } = stripThinkingTags(content); + + return ( +
+ {/* Thinking block — collapsible */} + {showThinking && thinking && ( +
+ + {thinkingOpen && ( +
+ {thinking} +
+ )} +
+ )} + + {/* Main content */} + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre({ children, ...props }) { + return ( +
+                {children}
+              
+ ); + }, + }} + > + {cleaned} +
+
+ ); +}; diff --git a/apps/desktop/src/components/shared/ModelSwitcher.tsx b/apps/desktop/src/components/shared/ModelSwitcher.tsx new file mode 100644 index 0000000..6e2dc7b --- /dev/null +++ b/apps/desktop/src/components/shared/ModelSwitcher.tsx @@ -0,0 +1,250 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { ChevronDown, Search } from "lucide-react"; +import { api } from "../../lib/api"; +import { PROVIDER_COLORS } from "../../lib/constants"; + +interface ModelSwitcherProps { + value: string; + onChange: (model: string) => void; + compact?: boolean; +} + +interface ModelEntry { + id: string; + provider: string; + active: boolean; +} + +export const ModelSwitcher: React.FC = ({ value, onChange, compact }) => { + const [models, setModels] = useState([]); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + const searchRef = useRef(null); + + const fetchModels = useCallback(() => { + api.getModels().then((res) => { + const flat: ModelEntry[] = []; + for (const p of res.providers) { + if (!p.active) continue; // Only show usable providers + for (const m of p.models) { + flat.push({ id: m.id, provider: p.provider, active: p.active }); + } + } + setModels(flat); + }).catch(() => {}); + }, []); + + useEffect(() => { + fetchModels(); + }, [fetchModels]); + + // Re-fetch when opening dropdown + useEffect(() => { + if (open) { + fetchModels(); + setTimeout(() => searchRef.current?.focus(), 50); + } else { + setSearch(""); + } + }, [open, fetchModels]); + + // Click outside to close + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const selected = models.find((m) => m.id === value); + + const filtered = search + ? models.filter((m) => + m.id.toLowerCase().includes(search.toLowerCase()) || + m.provider.toLowerCase().includes(search.toLowerCase()) + ) + : models; + const filteredProviders = Array.from(new Set(filtered.map((m) => m.provider))); + + return ( +
+ + + {open && ( +
+ {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Search models..." + style={{ + flex: 1, + background: "transparent", + border: "none", + outline: "none", + color: "rgba(255,255,255,0.85)", + fontSize: 12, + fontFamily: "inherit", + }} + /> +
+
+ + {/* Model list */} +
+ {filteredProviders.length === 0 && ( +
+ No models found +
+ )} + {filteredProviders.map((provider) => { + const providerModels = filtered.filter((m) => m.provider === provider); + const color = PROVIDER_COLORS[provider] ?? "#888"; + + return ( +
+ {/* Provider header */} +
+ + {provider} +
+ + {/* Models */} + {providerModels.map((m) => ( + + ))} +
+ ); + })} +
+
+ )} +
+ ); +}; diff --git a/apps/desktop/src/hooks/useStreamChat.ts b/apps/desktop/src/hooks/useStreamChat.ts new file mode 100644 index 0000000..5379313 --- /dev/null +++ b/apps/desktop/src/hooks/useStreamChat.ts @@ -0,0 +1,115 @@ +import { useState, useRef, useCallback } from "react"; +import { getGatewayUrl, getAuthToken } from "../lib/constants"; +import type { RouteboxMeta } from "../types/chat"; + +interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; +} + +interface UseStreamChatReturn { + streaming: boolean; + streamedText: string; + meta: RouteboxMeta | null; + sendMessage: (messages: ChatMessage[], model: string, options?: { search?: boolean }) => Promise; + abort: () => void; +} + +export function useStreamChat(): UseStreamChatReturn { + const [streaming, setStreaming] = useState(false); + const [streamedText, setStreamedText] = useState(""); + const [meta, setMeta] = useState(null); + const abortRef = useRef(null); + + const abort = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setStreaming(false); + }, []); + + const sendMessage = useCallback( + async (messages: ChatMessage[], model: string, options?: { search?: boolean }) => { + // Reset state + setStreamedText(""); + setMeta(null); + setStreaming(true); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const token = getAuthToken(); + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch(`${getGatewayUrl()}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + messages, + stream: true, + ...(options?.search ? { routebox_search: true } : {}), + }), + signal: controller.signal, + }); + + if (!res.ok || !res.body) { + const err = await res.text().catch(() => "Stream error"); + throw new Error(err); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let accumulated = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop()!; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data); + + // Capture routebox.meta chunk + if (parsed.object === "routebox.meta") { + setMeta(parsed as RouteboxMeta); + continue; + } + + // Standard OpenAI chunk + const delta = parsed.choices?.[0]?.delta; + if (delta?.content) { + accumulated += delta.content; + setStreamedText(accumulated); + } + } catch { + // skip malformed + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + setStreamedText((prev) => prev || `Error: ${(err as Error).message}`); + } + } finally { + setStreaming(false); + abortRef.current = null; + } + }, + [], + ); + + return { streaming, streamedText, meta, sendMessage, abort }; +} diff --git a/apps/desktop/src/hooks/useToast.ts b/apps/desktop/src/hooks/useToast.ts index d409780..65337f4 100644 --- a/apps/desktop/src/hooks/useToast.ts +++ b/apps/desktop/src/hooks/useToast.ts @@ -3,19 +3,19 @@ import { useState, useCallback, useRef } from "react"; export interface Toast { id: string; message: string; - type: "error" | "success"; + type: "error" | "success" | "info"; } export function useToast() { const [toasts, setToasts] = useState([]); const counterRef = useRef(0); - const showToast = useCallback((message: string, type: "error" | "success" = "error") => { + const showToast = useCallback((message: string, type: "error" | "success" | "info" = "error", durationMs = 3000) => { const id = `toast_${++counterRef.current}`; setToasts((prev) => [...prev.slice(-2), { id, message, type }]); setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)); - }, 3000); + }, durationMs); }, []); const dismissToast = useCallback((id: string) => { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 66e87da..b4624af 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -98,6 +98,25 @@ export interface ProviderRegistryEntry { keySource: "env" | "db" | null; maskedKey: string | null; isActive: boolean; + isLocal?: boolean; + baseUrl?: string; + modelCount?: number; +} + +// ── Local Providers ────────────────────────────────────────────────────────── + +export interface LocalProviderInfo { + name: string; + baseUrl: string; + hasApiKey: boolean; + isOnline: boolean; + modelCount: number; + models: string[]; + lastChecked: number; +} + +export interface LocalProvidersResponse { + providers: LocalProviderInfo[]; } export interface ProviderRegistryResponse { @@ -164,6 +183,7 @@ export interface ModelInfo { export interface ProviderModels { provider: string; + active: boolean; models: ModelInfo[]; } @@ -295,4 +315,106 @@ export const api = { method: "DELETE", retries: 0, }), + + // Local providers + getLocalProviders: () => + request("/api/v1/local-providers"), + setLocalProviderUrl: (name: string, baseUrl: string, apiKey?: string) => + request(`/api/v1/local-providers/${encodeURIComponent(name)}/url`, { + method: "PUT", + body: JSON.stringify({ baseUrl, ...(apiKey !== undefined ? { apiKey } : {}) }), + retries: 0, + }), + refreshLocalProvider: (name: string) => + request(`/api/v1/local-providers/${encodeURIComponent(name)}/refresh`, { + method: "POST", + retries: 0, + }), + + // V2: Usage + getUsageToday: () => request("/api/v1/usage/today"), + getUsageMonth: () => request("/api/v1/usage/month"), + getUsageWeekly: () => request("/api/v1/usage/weekly"), + getUsageModels: () => request("/api/v1/usage/models"), + getUsageSuggestions: () => request("/api/v1/usage/suggestions"), + + // V2: Conversations + getConversations: () => request("/api/v1/conversations"), + createConversation: (title: string, model: string) => + request("/api/v1/conversations", { + method: "POST", + body: JSON.stringify({ title, model }), + retries: 0, + }), + getConversation: (id: string) => + request(`/api/v1/conversations/${encodeURIComponent(id)}`), + deleteConversation: (id: string) => + request<{ success: boolean }>(`/api/v1/conversations/${encodeURIComponent(id)}`, { + method: "DELETE", + retries: 0, + }), + updateConversation: (id: string, data: { title?: string; pinned?: boolean; archived?: boolean }) => + request<{ success: boolean }>(`/api/v1/conversations/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(data), + retries: 0, + }), + sendMessage: (conversationId: string, msg: { role: string; content: string; model?: string; provider?: string; inputTokens?: number; outputTokens?: number; cost?: number; latencyMs?: number }) => + request(`/api/v1/conversations/${encodeURIComponent(conversationId)}/messages`, { + method: "POST", + body: JSON.stringify(msg), + retries: 0, + }), + + // V2: Web Search + getSearchStatus: () => request("/api/v1/search/status"), + setSearchKey: (apiKey: string) => + request<{ success: boolean }>("/api/v1/search/key", { + method: "PUT", + body: JSON.stringify({ apiKey }), + retries: 0, + }), + deleteSearchKey: () => + request<{ success: boolean }>("/api/v1/search/key", { + method: "DELETE", + retries: 0, + }), + + // V2: Spotlight + getSpotlightHistory: (limit = 3) => + request(`/api/v1/spotlight/history?limit=${limit}`), + saveSpotlightEntry: (data: { prompt: string; response: string; model?: string; provider?: string; cost?: number; tokens?: number; latencyMs?: number }) => + request("/api/v1/spotlight/history", { + method: "POST", + body: JSON.stringify(data), + retries: 0, + }), }; + +// V2 response types + +export interface UsageTodayResponse { requests: number; tokens: number; cost: number; saved: number } +export interface UsageMonthResponse { requests: number; tokens: number; cost: number; budgetPct: number } +export interface WeeklyTrendRow { date: string; cost: number; requests: number } +export interface ModelBreakdownRow { model: string; requests: number; cost: number; pct: number } +export interface UsageSuggestion { message: string; savingsEstimate?: string } + +export interface ConversationSummary { + id: string; title: string; model: string; msg_count: number; + total_cost: number; pinned: number; updated_at: number; +} +export interface ConversationsResponse { conversations: ConversationSummary[] } +export interface ConversationDetail extends ConversationSummary { + messages: MessageResponse[]; +} +export interface MessageResponse { + id: string; conversation_id: string; role: string; content: string; + model: string; provider: string; input_tokens: number; output_tokens: number; + cost: number; latency_ms: number; created_at: number; +} +export interface SearchStatusResponse { enabled: boolean; hasKey: boolean } +export interface SpotlightHistoryResponse { entries: SpotlightEntryResponse[] } +export interface SpotlightEntryResponse { + id: string; prompt: string; response: string; model: string; + provider: string; cost: number; tokens: number; latency_ms: number; created_at: number; +} diff --git a/apps/desktop/src/lib/constants.ts b/apps/desktop/src/lib/constants.ts index 7aac1d8..da5cbd2 100644 --- a/apps/desktop/src/lib/constants.ts +++ b/apps/desktop/src/lib/constants.ts @@ -47,6 +47,8 @@ export const PROVIDER_COLORS: Record = { Groq: "#F55036", Together: "#6366F1", "FLock.io": "#7C3AED", + Ollama: "#0077B6", + "LM Studio": "#E63946", }; export const ROUTING_STRATEGIES = [ diff --git a/apps/desktop/src/lib/thinking.ts b/apps/desktop/src/lib/thinking.ts new file mode 100644 index 0000000..00a561b --- /dev/null +++ b/apps/desktop/src/lib/thinking.ts @@ -0,0 +1,26 @@ +/** + * Strip ... reasoning blocks from model output. + * Handles both complete and in-progress (streaming) blocks. + */ +export function stripThinkingTags(text: string): { content: string; thinking: string } { + let thinking = ""; + let content = text; + + // Extract completed ... blocks + const completeBlocks = content.match(/[\s\S]*?<\/think>/g); + if (completeBlocks) { + for (const block of completeBlocks) { + thinking += block.slice(7, -8).trim() + "\n"; + } + content = content.replace(/[\s\S]*?<\/think>\s*/g, ""); + } + + // During streaming: if there's an unclosed , hide from to end + const openIdx = content.lastIndexOf(""); + if (openIdx !== -1 && content.indexOf("", openIdx) === -1) { + thinking += content.slice(openIdx + 7).trim(); + content = content.slice(0, openIdx); + } + + return { content: content.trimStart(), thinking: thinking.trim() }; +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index dfc35a8..475f97c 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,10 +1,58 @@ -import React from "react"; +import React, { lazy, Suspense, useEffect, useState } from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import { setAuthToken } from "./lib/constants"; import "./styles/globals.css"; +const SpotlightWindow = lazy(() => + import("./windows/SpotlightWindow").then((m) => ({ default: m.SpotlightWindow })), +); +const ChatWindow = lazy(() => + import("./windows/ChatWindow").then((m) => ({ default: m.ChatWindow })), +); + +function Root() { + const hash = window.location.hash; + const [tokenReady, setTokenReady] = useState(false); + + // Load auth token for ALL windows (panel, spotlight, chat) + useEffect(() => { + import("@tauri-apps/api/core") + .then(({ invoke }) => invoke("get_token")) + .then((t) => { if (t) setAuthToken(t); }) + .catch(() => {}) + .finally(() => setTokenReady(true)); + }, []); + + // Main panel doesn't need to wait — App.tsx has its own token loading + if (!hash || hash === "#/") { + return ; + } + + // Sub-windows wait for token before rendering + if (!tokenReady) return null; + + if (hash === "#/spotlight") { + return ( + + + + ); + } + + if (hash === "#/chat") { + return ( + + + + ); + } + + return ; +} + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + , ); diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index e4771e5..9878477 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -302,3 +302,86 @@ gap: 8px; border-radius: 8px; } + +/* ── V2: Spotlight gradient ── */ +.spotlight-gradient { + background: rgba(30, 30, 30, 0.96); + backdrop-filter: blur(50px) saturate(200%); + -webkit-backdrop-filter: blur(50px) saturate(200%); + border-radius: 14px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 0.5px rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +@keyframes spotlight-in { + from { + opacity: 0; + transform: scale(0.96) translateY(-8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.spotlight-animate { + animation: spotlight-in 180ms ease-out; +} + +/* ── V2: Markdown body ── */ +.markdown-body { + color: rgba(255, 255, 255, 0.85); + font-size: 13px; + line-height: 1.6; + word-wrap: break-word; +} + +.markdown-body h1, .markdown-body h2, .markdown-body h3 { + color: rgba(255, 255, 255, 0.95); + margin: 12px 0 6px; + font-weight: 600; +} + +.markdown-body h1 { font-size: 18px; } +.markdown-body h2 { font-size: 15px; } +.markdown-body h3 { font-size: 14px; } + +.markdown-body p { margin: 6px 0; } + +.markdown-body ul, .markdown-body ol { + padding-left: 20px; + margin: 6px 0; +} + +.markdown-body a { + color: #58a6ff; + text-decoration: none; +} + +.markdown-body blockquote { + border-left: 3px solid rgba(255, 255, 255, 0.15); + padding-left: 12px; + color: rgba(255, 255, 255, 0.6); + margin: 8px 0; +} + +.markdown-body table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; +} + +.markdown-body th, .markdown-body td { + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 10px; + font-size: 12px; +} + +.markdown-body th { + background: rgba(255, 255, 255, 0.05); +} + +/* hljs override for code blocks */ +.markdown-body .hljs { + background: transparent !important; +} diff --git a/apps/desktop/src/types/chat.ts b/apps/desktop/src/types/chat.ts new file mode 100644 index 0000000..27c9da6 --- /dev/null +++ b/apps/desktop/src/types/chat.ts @@ -0,0 +1,57 @@ +// V2 shared types for Chat, Spotlight, and Usage features + +export interface Conversation { + id: string; + title: string; + model: string; + strategy: string; + msg_count: number; + total_tokens: number; + total_cost: number; + pinned: number; + archived: number; + created_at: number; + updated_at: number; +} + +export interface Message { + id: string; + conversation_id: string; + role: "user" | "assistant" | "system"; + content: string; + model: string; + provider: string; + input_tokens: number; + output_tokens: number; + cost: number; + latency_ms: number; + cache_hit: number; + created_at: number; +} + +export interface SpotlightEntry { + id: string; + prompt: string; + response: string; + model: string; + provider: string; + cost: number; + tokens: number; + latency_ms: number; + created_at: number; +} + +export interface RouteboxMeta { + object: "routebox.meta"; + provider: string; + model: string; + requested_model: string; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + cost: number; + latency_ms: number; + is_fallback: boolean; +} diff --git a/apps/desktop/src/windows/ChatWindow.tsx b/apps/desktop/src/windows/ChatWindow.tsx new file mode 100644 index 0000000..f17ef2f --- /dev/null +++ b/apps/desktop/src/windows/ChatWindow.tsx @@ -0,0 +1,457 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Send, Square, Columns, MessageSquare, Globe } from "lucide-react"; +import { useStreamChat } from "../hooks/useStreamChat"; +import { ModelSwitcher } from "../components/shared/ModelSwitcher"; +import { CostBar } from "../components/shared/CostBar"; +import { MarkdownRenderer } from "../components/shared/MarkdownRenderer"; +import { ChatSidebar } from "./chat/ChatSidebar"; +import { api } from "../lib/api"; +import type { ConversationSummary, MessageResponse } from "../lib/api"; +import type { RouteboxMeta } from "../types/chat"; + +interface DisplayMessage { + id: string; + role: "user" | "assistant"; + content: string; + model?: string; + provider?: string; + cost?: number; + latency_ms?: number; + input_tokens?: number; + output_tokens?: number; +} + +export const ChatWindow: React.FC = () => { + const [conversations, setConversations] = useState([]); + const [activeConvId, setActiveConvId] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [model, setModel] = useState("claude-sonnet-4"); + const [compareMode, setCompareMode] = useState(false); + const [searchEnabled, setSearchEnabled] = useState(false); + const [searchOn, setSearchOn] = useState(false); + const { streaming, streamedText, meta, sendMessage, abort } = useStreamChat(); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + // Load conversations + const loadConversations = useCallback(async () => { + try { + const res = await api.getConversations(); + setConversations(res.conversations); + } catch {} + }, []); + + useEffect(() => { + loadConversations(); + api.getSearchStatus().then((s) => setSearchEnabled(s.enabled)).catch(() => {}); + }, [loadConversations]); + + // Load messages when active conversation changes + useEffect(() => { + if (!activeConvId) { + setMessages([]); + return; + } + api.getConversation(activeConvId).then((conv) => { + setMessages( + conv.messages.map((m: MessageResponse) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + model: m.model, + provider: m.provider, + cost: m.cost, + latency_ms: m.latency_ms, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + })), + ); + if (conv.model) setModel(conv.model); + }).catch(() => {}); + }, [activeConvId]); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, streamedText]); + + // Append streamed response to messages when done + useEffect(() => { + if (!streaming && streamedText && meta && activeConvId) { + api.sendMessage(activeConvId, { + role: "assistant", + content: streamedText, + model: meta.model, + provider: meta.provider, + cost: meta.cost, + inputTokens: meta.usage.prompt_tokens, + outputTokens: meta.usage.completion_tokens, + latencyMs: meta.latency_ms, + }).then((savedMsg) => { + setMessages((prev) => [ + ...prev, + { + id: savedMsg.id, + role: "assistant", + content: streamedText, + model: meta.model, + provider: meta.provider, + cost: meta.cost, + latency_ms: meta.latency_ms, + input_tokens: meta.usage.prompt_tokens, + output_tokens: meta.usage.completion_tokens, + }, + ]); + loadConversations(); + }).catch(() => {}); + } + }, [streaming]); + + // Auto-resize textarea + const handleInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + const el = e.target; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 140) + "px"; + }; + + const handleSend = async () => { + if (!input.trim() || streaming) return; + + let convId = activeConvId; + + if (!convId) { + try { + const conv = await api.createConversation(input.slice(0, 50), model); + convId = conv.id; + setActiveConvId(conv.id); + loadConversations(); + } catch { + return; + } + } + + const userMsg = await api.sendMessage(convId, { role: "user", content: input }); + const newUserMsg: DisplayMessage = { + id: userMsg.id, + role: "user", + content: input, + }; + setMessages((prev) => [...prev, newUserMsg]); + + const history = [...messages, newUserMsg].map((m) => ({ + role: m.role as "user" | "assistant" | "system", + content: m.content, + })); + + setInput(""); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + await sendMessage(history, model, searchOn ? { search: true } : undefined); + }; + + const handleNewChat = () => { + setActiveConvId(null); + setMessages([]); + setInput(""); + textareaRef.current?.focus(); + }; + + const handleDeleteConversation = async (id: string) => { + await api.deleteConversation(id); + if (activeConvId === id) handleNewChat(); + loadConversations(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const isEmpty = messages.length === 0 && !streaming; + + return ( +
+ {/* Sidebar */} + + + {/* Main chat area */} +
+ {/* Header — draggable title bar */} +
+ + +
+ + {/* Messages area */} +
+ {/* Empty state */} + {isEmpty && ( +
+ + Start a conversation + + Select a model and type a message below + +
+ )} + + {messages.map((msg) => ( + + ))} + + {/* Streaming response */} + {streaming && streamedText && ( +
+
+ + +
+
+ )} + + {/* Streaming meta */} + {!streaming && meta && streamedText && ( +
+ +
+ )} + +
+
+ + {/* Input area */} +
+
+ {/* Search toggle */} + {searchEnabled && ( + + )} +