fixing ui + adding diff fonts + fixing merge conflicts

This commit is contained in:
Gabriel Bo
2026-03-03 18:54:35 -08:00
parent 9d7e9669b3
commit 494fc39c65
9 changed files with 367 additions and 360 deletions
+56 -74
View File
@@ -5,9 +5,7 @@ hide:
- navigation
---
# OpenJarvis
**Programming abstractions for on-device AI.**
# _Programming abstractions_ for on-device AI
OpenJarvis is a modular framework for building, running, and learning from local AI systems. It provides composable abstractions across **five pillars** with a cross-cutting trace-driven learning system:
@@ -29,55 +27,55 @@ Everything runs on your hardware. Cloud APIs are optional.
---
Intelligence (the model), Agents (agentic harness), Tools (MCP-based tool system with storage), Engine (inference runtime), and Learning (trace-driven improvement) — each with a clear ABC interface and decorator-based registry.
Intelligence, Agents, Tools, Engine, and Learning — each with a clear ABC interface and decorator-based registry.
- **5 Engine Backends**
---
Ollama, vLLM, SGLang, llama.cpp, and cloud (OpenAI/Anthropic/Google). All implement the same `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, and `health()`.
Ollama, vLLM, SGLang, llama.cpp, and cloud (OpenAI/Anthropic/Google). Same `InferenceEngine` ABC.
- **5 Memory Backends**
---
SQLite/FTS5 (default, zero-dependency), FAISS, ColBERTv2, BM25, and Hybrid (reciprocal rank fusion). Document chunking, indexing, and context injection built in.
SQLite/FTS5, FAISS, ColBERTv2, BM25, and Hybrid (reciprocal rank fusion). Zero-dependency default.
- **Hardware-Aware**
---
Auto-detects GPU vendor, model, and VRAM via `nvidia-smi`, `rocm-smi`, and `system_profiler`. Recommends the optimal engine for your hardware automatically.
Auto-detects GPU vendor, model, and VRAM. Recommends the optimal engine for your hardware.
- **Offline-First**
---
All core functionality works without a network connection. Cloud API backends are optional extras for when you need them.
All core functionality works without a network connection. Cloud APIs are optional extras.
- **OpenAI-Compatible API**
---
`jarvis serve` starts a FastAPI server with `POST /v1/chat/completions`, `GET /v1/models`, and SSE streaming. Drop-in replacement for OpenAI-compatible clients.
`jarvis serve` starts a FastAPI server with SSE streaming. Drop-in replacement for OpenAI clients.
- **Trace-Driven Learning**
---
Every agent interaction is recorded as a trace. The learning system improves Intelligence (SFT weight updates) and Agents (system prompt, tool selection, retry logic). Pluggable policies: heuristic, trace-driven, SFT, agent advisor, ICL updater, GRPO.
Every interaction is traced. The learning system improves models (SFT) and agents (prompt, tools, logic).
- **Python SDK**
---
The `Jarvis` class provides a high-level sync API. Three lines of code to ask a question. Full access to agents, tools, memory, and model routing.
The `Jarvis` class: three lines of code to ask a question. Full access to agents, tools, memory, and routing.
- **CLI-First**
---
`jarvis ask`, `jarvis serve`, `jarvis memory`, `jarvis bench`, `jarvis telemetry` — every capability is accessible from the command line with rich terminal output.
`jarvis ask`, `jarvis serve`, `jarvis memory`, `jarvis bench` — every capability from the command line.
</div>
@@ -85,86 +83,70 @@ Everything runs on your hardware. Cloud APIs are optional.
## Quick Start
<div class="quick-start-section" markdown>
=== "Browser App"
### Browser App
Run the full chat UI locally with one script:
Run the full chat UI locally with one script:
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
```bash
git clone https://github.com/HazyResearch/OpenJarvis.git
cd OpenJarvis
./scripts/quickstart.sh
```
This installs dependencies, starts Ollama + a local model, launches the backend
and frontend, and opens `http://localhost:5173` in your browser.
This installs dependencies, starts Ollama + a local model, launches the backend
and frontend, and opens `http://localhost:5173` in your browser.
=== "Desktop App"
</div>
Download the native desktop app — it bundles Ollama and the Python backend
so everything works out of the box.
<div class="quick-start-section" markdown>
[Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg){ .md-button .md-button--primary }
### Desktop App
Also available for [macOS (Intel)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg), [Windows](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm). See the [Downloads](downloads.md) page for details.
Download the native desktop app — it bundles Ollama and the Python backend
so everything works out of the box.
=== "Python SDK"
[Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg){ .md-button .md-button--primary }
```python
from openjarvis import Jarvis
Also available for [macOS (Intel)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg), [Windows](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm). See the [Downloads](downloads.md) page for details.
j = Jarvis()
response = j.ask("Explain quicksort in two sentences.")
print(response)
j.close()
```
</div>
For more control, use `ask_full()` to get usage stats, model info, and tool results:
<div class="quick-start-section" markdown>
```python
result = j.ask_full(
"What is 2 + 2?",
agent="orchestrator",
tools=["calculator"],
)
print(result["content"]) # "4"
print(result["tool_results"]) # [{tool_name: "calculator", ...}]
```
### Python SDK
=== "CLI"
```python
from openjarvis import Jarvis
```bash
# Ask a question
jarvis ask "What is the capital of France?"
j = Jarvis()
response = j.ask("Explain quicksort in two sentences.")
print(response)
j.close()
```
# Use an agent with tools
jarvis ask --agent orchestrator --tools calculator,think "What is 137 * 42?"
For more control, use `ask_full()` to get usage stats, model info, and tool results:
# Start the API server
jarvis serve --port 8000
```python
result = j.ask_full(
"What is 2 + 2?",
agent="orchestrator",
tools=["calculator"],
)
print(result["content"]) # "4"
print(result["tool_results"]) # [{tool_name: "calculator", ...}]
```
# Index documents and search memory
jarvis memory index ./docs/
jarvis memory search "configuration options"
</div>
<div class="quick-start-section" markdown>
### CLI
```bash
# Ask a question
jarvis ask "What is the capital of France?"
# Use an agent with tools
jarvis ask --agent orchestrator --tools calculator,think "What is 137 * 42?"
# Start the API server
jarvis serve --port 8000
# Index documents and search memory
jarvis memory index ./docs/
jarvis memory search "configuration options"
# Run inference benchmarks
jarvis bench run --json
```
</div>
# Run inference benchmarks
jarvis bench run --json
```
---
+171 -236
View File
@@ -1,200 +1,89 @@
/* ── Fonts ─────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&family=Inter:wght@400;500;600;700&display=swap');
:root {
--md-text-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--md-text-font: "Merriweather", Georgia, "Times New Roman", serif;
--md-code-font: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
}
/* ── Color Overrides (Light) ──────────────────────────────────────── */
[data-md-color-scheme="default"] {
--md-primary-fg-color: #1e293b;
--md-primary-fg-color--light: #334155;
--md-primary-fg-color--dark: #0f172a;
--md-accent-fg-color: #3b82f6;
--md-accent-fg-color--transparent: rgba(59, 130, 246, 0.1);
--md-primary-fg-color: #1a1a2e;
--md-primary-fg-color--light: #2d2d44;
--md-primary-fg-color--dark: #0d0d1a;
--md-accent-fg-color: #4361ee;
--md-accent-fg-color--transparent: rgba(67, 97, 238, 0.08);
--md-default-bg-color: #ffffff;
--md-default-fg-color: #1e293b;
--md-default-fg-color--light: #475569;
--md-typeset-a-color: #2563eb;
--md-default-fg-color: #1a1a2e;
--md-default-fg-color--light: #555577;
--md-typeset-a-color: #4361ee;
}
/* ── Color Overrides (Dark) ───────────────────────────────────────── */
[data-md-color-scheme="slate"] {
--md-primary-fg-color: #0f172a;
--md-primary-fg-color--light: #1e293b;
--md-primary-fg-color--dark: #020617;
--md-accent-fg-color: #60a5fa;
--md-accent-fg-color--transparent: rgba(96, 165, 250, 0.1);
--md-default-bg-color: #0f172a;
--md-default-fg-color: #e2e8f0;
--md-default-fg-color--light: #94a3b8;
--md-typeset-a-color: #60a5fa;
--md-code-bg-color: #1e293b;
--md-primary-fg-color: #0d0d1a;
--md-primary-fg-color--light: #1a1a2e;
--md-primary-fg-color--dark: #050510;
--md-accent-fg-color: #7b8cde;
--md-accent-fg-color--transparent: rgba(123, 140, 222, 0.1);
--md-default-bg-color: #0d0d1a;
--md-default-fg-color: #e0e0ec;
--md-default-fg-color--light: #8888aa;
--md-typeset-a-color: #7b8cde;
--md-code-bg-color: #161628;
}
/* ── Hero Section ─────────────────────────────────────────────────── */
.hero {
padding: 4rem 0 3rem;
text-align: center;
position: relative;
/* ── Global Typography ───────────────────────────────────────────── */
.md-typeset {
font-size: 0.92rem;
line-height: 1.8;
}
.hero h1 {
font-size: 3.2rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
margin-bottom: 1rem;
background: linear-gradient(135deg, #1e293b 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
/* Nav and UI chrome use a clean sans-serif */
.md-header,
.md-tabs,
.md-nav,
.md-footer,
.md-search__input,
.md-source {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
[data-md-color-scheme="slate"] .hero h1 {
background: linear-gradient(135deg, #e2e8f0 0%, #60a5fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
.md-nav__link,
.md-tabs__link {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 500;
}
.hero .hero-tagline {
font-size: 1.25rem;
/* ── Hero Section (DSPy-style: clean, italic emphasis) ───────────── */
.md-typeset h1 {
font-size: 2.6rem;
font-weight: 900;
letter-spacing: -0.02em;
line-height: 1.15;
margin-bottom: 0.5rem;
color: var(--md-default-fg-color);
}
.md-typeset h1 em {
font-style: italic;
color: var(--md-accent-fg-color);
}
.hero-tagline {
font-size: 1.15rem;
color: var(--md-default-fg-color--light);
max-width: 640px;
margin: 0 auto 2rem;
line-height: 1.6;
line-height: 1.7;
font-weight: 400;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.hero-buttons a {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.75rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
text-decoration: none;
transition: all 0.2s ease;
}
.hero-buttons .btn-primary {
background: #3b82f6;
color: #fff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.hero-buttons .btn-primary:hover {
background: #2563eb;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
transform: translateY(-1px);
}
.hero-buttons .btn-secondary {
background: transparent;
color: var(--md-default-fg-color);
border: 1.5px solid var(--md-default-fg-color--light);
}
.hero-buttons .btn-secondary:hover {
border-color: var(--md-accent-fg-color);
color: var(--md-accent-fg-color);
transform: translateY(-1px);
}
/* ── Feature Cards ────────────────────────────────────────────────── */
.md-typeset .grid.cards > ol,
.md-typeset .grid.cards > ul {
gap: 1rem;
}
.md-typeset .grid.cards > ol > li,
.md-typeset .grid.cards > ul > li {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 1.5rem;
transition: all 0.25s ease;
background: var(--md-default-bg-color);
}
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ol > li,
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ul > li {
border-color: rgba(255, 255, 255, 0.08);
background: #1e293b;
}
.md-typeset .grid.cards > ol > li:hover,
.md-typeset .grid.cards > ul > li:hover {
border-color: var(--md-accent-fg-color);
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
}
.md-typeset .grid.cards > ol > li > :first-child,
.md-typeset .grid.cards > ul > li > :first-child {
font-weight: 600;
font-size: 1.05rem;
}
/* ── Code Blocks ──────────────────────────────────────────────────── */
.md-typeset code {
border-radius: 4px;
font-size: 0.85em;
}
.md-typeset pre {
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
[data-md-color-scheme="slate"] .md-typeset pre {
border-color: rgba(255, 255, 255, 0.06);
}
.md-typeset pre > code {
font-size: 0.85rem;
line-height: 1.65;
}
/* ── Tabs ─────────────────────────────────────────────────────────── */
.md-typeset .tabbed-labels > label {
font-weight: 500;
font-size: 0.9rem;
}
/* ── Navigation ───────────────────────────────────────────────────── */
.md-tabs {
background: var(--md-primary-fg-color);
}
.md-header {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ── Admonitions ──────────────────────────────────────────────────── */
.md-typeset .admonition,
.md-typeset details {
border-radius: 8px;
border-width: 1px;
border-left-width: 4px;
box-shadow: none;
margin-bottom: 1.5rem;
}
/* ── Section Headers ──────────────────────────────────────────────── */
.md-typeset h2 {
font-weight: 700;
font-size: 1.6rem;
letter-spacing: -0.02em;
font-size: 1.5rem;
letter-spacing: -0.015em;
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
@@ -211,8 +100,93 @@
}
.md-typeset h3 {
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.md-typeset h4 {
font-weight: 600;
font-size: 1.15rem;
font-size: 0.95rem;
}
/* ── Feature Cards (simplified, DSPy-like) ───────────────────────── */
.md-typeset .grid.cards > ol,
.md-typeset .grid.cards > ul {
gap: 0.75rem;
}
.md-typeset .grid.cards > ol > li,
.md-typeset .grid.cards > ul > li {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
padding: 1.25rem 1.5rem;
transition: border-color 0.2s ease;
background: var(--md-default-bg-color);
}
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ol > li,
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ul > li {
border-color: rgba(255, 255, 255, 0.08);
background: #161628;
}
.md-typeset .grid.cards > ol > li:hover,
.md-typeset .grid.cards > ul > li:hover {
border-color: var(--md-accent-fg-color);
}
.md-typeset .grid.cards > ol > li > :first-child,
.md-typeset .grid.cards > ul > li > :first-child {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 0.95rem;
}
/* ── Code Blocks ──────────────────────────────────────────────────── */
.md-typeset code {
border-radius: 4px;
font-size: 0.82em;
}
.md-typeset pre {
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
[data-md-color-scheme="slate"] .md-typeset pre {
border-color: rgba(255, 255, 255, 0.06);
}
.md-typeset pre > code {
font-size: 0.82rem;
line-height: 1.7;
}
/* ── Tabs ─────────────────────────────────────────────────────────── */
.md-typeset .tabbed-labels > label {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 500;
font-size: 0.85rem;
}
/* ── Navigation ───────────────────────────────────────────────────── */
.md-tabs {
background: var(--md-primary-fg-color);
}
.md-header {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
/* ── Admonitions ──────────────────────────────────────────────────── */
.md-typeset .admonition,
.md-typeset details {
border-radius: 8px;
border-width: 1px;
border-left-width: 4px;
box-shadow: none;
font-size: 0.88rem;
}
/* ── Footer ───────────────────────────────────────────────────────── */
@@ -220,14 +194,15 @@
background: var(--md-primary-fg-color--dark);
}
/* ── Pill Badges (used on landing page) ───────────────────────────── */
/* ── Pill Badges ─────────────────────────────────────────────────── */
.pill {
display: inline-block;
padding: 0.3rem 0.9rem;
padding: 0.25rem 0.8rem;
border-radius: 100px;
font-size: 0.8rem;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
letter-spacing: 0.03em;
background: var(--md-accent-fg-color--transparent);
color: var(--md-accent-fg-color);
margin-bottom: 1rem;
@@ -236,27 +211,13 @@
/* ── Divider ──────────────────────────────────────────────────────── */
.md-typeset hr {
border-color: rgba(0, 0, 0, 0.06);
margin: 3rem 0;
margin: 2.5rem 0;
}
[data-md-color-scheme="slate"] .md-typeset hr {
border-color: rgba(255, 255, 255, 0.06);
}
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.hero h1 {
font-size: 2.2rem;
}
.hero .hero-tagline {
font-size: 1.05rem;
}
.hero-buttons {
flex-direction: column;
align-items: center;
}
}
/* ── Inline Code ─────────────────────────────────────────────────── */
.md-typeset :not(pre) > code {
transition: background 0.15s ease;
@@ -271,17 +232,21 @@
transition: color 0.15s ease;
}
.md-typeset a:hover {
text-decoration: underline;
}
/* ── Content Width ───────────────────────────────────────────────── */
.md-content__inner {
max-width: 52rem;
max-width: 50rem;
}
/* ── Tables ──────────────────────────────────────────────────────── */
.md-typeset table:not([class]) {
border-radius: 8px;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
font-size: 0.85rem;
font-size: 0.83rem;
}
[data-md-color-scheme="slate"] .md-typeset table:not([class]) {
@@ -289,58 +254,28 @@
}
.md-typeset table:not([class]) th {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 600;
font-size: 0.8rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Quick-start section cards ────────────────────────────────────── */
.quick-start-section {
border: 1px solid rgba(0, 0, 0, 0.08);
border-left: 4px solid var(--md-accent-fg-color);
border-radius: 10px;
padding: 1.5rem 2rem 1.25rem;
margin-bottom: 1.5rem;
background: var(--md-default-bg-color);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
/* ── Quick Start Tabs (replaces old card sections) ───────────────── */
.md-typeset .tabbed-set {
margin-top: 1rem;
}
.quick-start-section:hover {
border-color: rgba(0, 0, 0, 0.12);
border-left-color: var(--md-accent-fg-color);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
.md-typeset .tabbed-content {
padding-top: 0.5rem;
}
[data-md-color-scheme="slate"] .quick-start-section {
border-color: rgba(255, 255, 255, 0.08);
border-left-color: var(--md-accent-fg-color);
background: #1e293b;
}
[data-md-color-scheme="slate"] .quick-start-section:hover {
border-color: rgba(255, 255, 255, 0.14);
border-left-color: var(--md-accent-fg-color);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.quick-start-section h3 {
font-size: 1.3rem !important;
font-weight: 700 !important;
margin-top: 0 !important;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
[data-md-color-scheme="slate"] .quick-start-section h3 {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.quick-start-section p:last-child {
margin-bottom: 0;
}
.quick-start-section pre:last-child {
margin-bottom: 0;
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.md-typeset h1 {
font-size: 1.8rem;
}
.hero-tagline {
font-size: 1rem;
}
}
+3
View File
@@ -7,6 +7,9 @@
<meta name="description" content="OpenJarvis — on-device AI assistant" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap" rel="stylesheet" />
<title>OpenJarvis</title>
</head>
<body>
+14 -7
View File
@@ -17,6 +17,7 @@ export function InputArea() {
const selectedModel = useAppStore((s) => s.selectedModel);
const streamState = useAppStore((s) => s.streamState);
const messages = useAppStore((s) => s.messages);
const speechEnabled = useAppStore((s) => s.settings.speechEnabled);
const createConversation = useAppStore((s) => s.createConversation);
const addMessage = useAppStore((s) => s.addMessage);
const updateLastAssistant = useAppStore((s) => s.updateLastAssistant);
@@ -25,6 +26,13 @@ export function InputArea() {
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
const micDisabled = !speechEnabled || !speechAvailable || streamState.isStreaming;
const micReason: 'not-enabled' | 'no-backend' | 'streaming' | undefined =
!speechEnabled ? 'not-enabled'
: !speechAvailable ? 'no-backend'
: streamState.isStreaming ? 'streaming'
: undefined;
const handleMicClick = useCallback(async () => {
if (speechState === 'recording') {
try {
@@ -257,13 +265,12 @@ export function InputArea() {
</button>
) : (
<div className="flex items-center gap-1">
{speechAvailable && (
<MicButton
state={speechState}
onClick={handleMicClick}
disabled={streamState.isStreaming}
/>
)}
<MicButton
state={speechState}
onClick={handleMicClick}
disabled={micDisabled}
reason={micReason}
/>
<button
onClick={sendMessage}
disabled={!input.trim()}
+66 -39
View File
@@ -1,53 +1,80 @@
import { useState } from 'react';
import type { SpeechState } from '../../hooks/useSpeech';
interface MicButtonProps {
state: SpeechState;
onClick: () => void;
disabled?: boolean;
reason?: 'not-enabled' | 'no-backend' | 'streaming';
}
export function MicButton({ state, onClick, disabled }: MicButtonProps) {
const title =
state === 'recording'
? 'Stop recording'
: state === 'transcribing'
? 'Transcribing...'
: 'Voice input';
export function MicButton({ state, onClick, disabled, reason }: MicButtonProps) {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipText =
reason === 'not-enabled'
? 'Enable in Settings'
: reason === 'no-backend'
? 'Speech backend not configured'
: reason === 'streaming'
? 'Wait for response'
: state === 'recording'
? 'Stop recording'
: state === 'transcribing'
? 'Transcribing...'
: 'Voice input';
const isInactive = disabled || state === 'transcribing';
return (
<button
className={`mic-btn ${state !== 'idle' ? `mic-${state}` : ''}`}
onClick={onClick}
disabled={disabled || state === 'transcribing'}
title={title}
style={{
background: state === 'recording' ? '#e74c3c' : 'transparent',
border: '1px solid var(--border, #555)',
borderRadius: '8px',
padding: '8px',
cursor: disabled || state === 'transcribing' ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '36px',
height: '36px',
color: state === 'recording' ? '#fff' : 'var(--text, #cdd6f4)',
opacity: disabled || state === 'transcribing' ? 0.5 : 1,
animation: state === 'recording' ? 'pulse 1.5s ease-in-out infinite' : 'none',
}}
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{state === 'transcribing' ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="28" strokeDashoffset="10">
<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="1s" repeatCount="indefinite" />
</circle>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z" />
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" />
</svg>
<button
onClick={onClick}
disabled={isInactive}
className="p-2 rounded-xl transition-all shrink-0"
style={{
background: state === 'recording'
? 'var(--color-error)'
: 'transparent',
color: state === 'recording'
? 'white'
: isInactive
? 'var(--color-text-tertiary)'
: 'var(--color-text-secondary)',
cursor: isInactive ? 'default' : 'pointer',
opacity: isInactive ? 0.35 : 1,
animation: state === 'recording' ? 'pulse 1.5s ease-in-out infinite' : 'none',
}}
>
{state === 'transcribing' ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="28" strokeDashoffset="10">
<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="1s" repeatCount="indefinite" />
</circle>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z" />
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" />
</svg>
)}
</button>
{showTooltip && isInactive && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1.5 rounded-lg text-xs whitespace-nowrap pointer-events-none"
style={{
background: 'var(--color-text)',
color: 'var(--color-bg)',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
>
{tooltipText}
</div>
)}
</button>
</div>
);
}
+4 -2
View File
@@ -138,7 +138,9 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family: "Merriweather", Georgia, "Times New Roman", serif;
font-weight: 400;
line-height: 1.7;
background-color: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
@@ -234,7 +236,7 @@
/* Markdown prose styling */
.prose {
line-height: 1.65;
line-height: 1.75;
font-size: 0.9375rem;
}
+2
View File
@@ -51,6 +51,7 @@ interface Settings {
defaultAgent: string;
temperature: number;
maxTokens: number;
speechEnabled: boolean;
}
function loadSettings(): Settings {
@@ -62,6 +63,7 @@ function loadSettings(): Settings {
defaultAgent: '',
temperature: 0.7,
maxTokens: 4096,
speechEnabled: false,
};
try {
const raw = localStorage.getItem(SETTINGS_KEY);
+50 -1
View File
@@ -13,9 +13,10 @@ import {
Download,
Upload,
Trash2,
Mic,
} from 'lucide-react';
import { useAppStore, type ThemeMode } from '../lib/store';
import { checkHealth } from '../lib/api';
import { checkHealth, fetchSpeechHealth } from '../lib/api';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
@@ -57,10 +58,14 @@ export function SettingsPage() {
const conversations = useAppStore((s) => s.conversations);
const serverInfo = useAppStore((s) => s.serverInfo);
const [healthy, setHealthy] = useState<boolean | null>(null);
const [speechBackendAvailable, setSpeechBackendAvailable] = useState<boolean | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
checkHealth().then(setHealthy);
fetchSpeechHealth()
.then((h) => setSpeechBackendAvailable(h.available))
.catch(() => setSpeechBackendAvailable(false));
}, []);
const showSaved = () => {
@@ -225,6 +230,50 @@ export function SettingsPage() {
</SettingRow>
</Section>
{/* Speech */}
<Section title="Speech">
<SettingRow label="Speech-to-Text" description="Enable microphone input for voice dictation">
<button
onClick={() => { updateSettings({ speechEnabled: !settings.speechEnabled }); showSaved(); }}
className="relative w-11 h-6 rounded-full transition-colors cursor-pointer"
style={{
background: settings.speechEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
}}
>
<span
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform bg-white"
style={{
transform: settings.speechEnabled ? 'translateX(20px)' : 'translateX(0)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}
/>
</button>
</SettingRow>
<SettingRow label="Backend status" description="Requires Whisper, Deepgram, or another speech backend">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{
background: speechBackendAvailable === true ? 'var(--color-success)'
: speechBackendAvailable === false ? 'var(--color-text-tertiary)'
: 'var(--color-text-tertiary)',
}}
/>
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{speechBackendAvailable === null ? 'Checking...'
: speechBackendAvailable ? 'Available'
: 'Not configured'}
</span>
</div>
</SettingRow>
{!speechBackendAvailable && speechBackendAvailable !== null && (
<div className="text-xs mt-2 px-1" style={{ color: 'var(--color-text-tertiary)' }}>
Set up a speech backend to use voice input.
See the <a href="https://hazyresearch.stanford.edu/OpenJarvis/user-guide/tools/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--color-accent)' }}>documentation</a> for details.
</div>
)}
</Section>
{/* Data */}
<Section title="Data">
<SettingRow label="Conversations" description={`${conversations.length} stored locally`}>
+1 -1
View File
@@ -12,7 +12,7 @@ theme:
name: material
language: en
font:
text: Inter
text: Merriweather
code: JetBrains Mono
features:
- navigation.tabs