feat: memory UI, settings, and API fixes (#247)

* refactor: merge desktop/ into frontend/, eliminate duplicate Tauri scaffolding

The project had two overlapping directories: desktop/ (Tauri Rust backend +
stale React components) and frontend/ (real React app + dead Tauri stub).
This consolidates everything under frontend/:

- Move desktop/src-tauri/ → frontend/src-tauri/ (the real 1,720-line Rust
  backend with Ollama sidecar, backend lifecycle, cloud keys, overlay, etc.)
- Preserve 9 old desktop React components in frontend/src/components/Desktop/
  (excluded from TS build — APIs have drifted, kept for future integration)
- Delete the old frontend/src-tauri/ stub (246 lines, never compiled)
- Delete desktop/ entirely
- Fix tauri.conf.json frontendDist path (../../frontend/dist → ../dist)
- Update CI workflow, bump script, .gitignore, and docs paths
- Rename setup/ → Setup/ for consistent PascalCase component directories

* feat: add memory UI, settings, and fix memory API routes

- Add Memory tab to Data Sources page with stats, search, index path,
  and manual store functionality
- Add Memory section to Settings page with backend picker, context
  injection toggle, and parameter sliders (top_k, min_score, max_tokens)
- Add memory API functions to frontend (getMemoryStats, searchMemory,
  storeMemory, indexMemoryPath, getMemoryConfig)
- Fix backend /v1/memory/* routes to use app-level memory backend
  instead of creating fresh SQLiteMemory instances per request
- Add GET /v1/memory/config and POST /v1/memory/index endpoints
- Gracefully handle missing Rust backend (return defaults instead of 500)
- Fix nested scroll in Agents Interact tab (use viewport-relative height)

* fix: memory API routes, dialog plugin, and UI polish

- Fix memory API: use backend.retrieve() not .search(), .count() not
  .stats() to match actual SQLiteMemory interface
- Handle None backend gracefully in index endpoint (503 instead of crash)
- Expand ~ in index path (expanduser + resolve)
- Install @tauri-apps/plugin-dialog for native folder picker in Tauri
- Browse button only shows in Tauri (browser can't get absolute paths)
- Redesign Memory tab: proper cards, color-coded search scores, two-column
  layout for index/store, loading spinners, accent gradient on stats card

---------

Co-authored-by: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com>
This commit is contained in:
Andrew Park
2026-04-14 09:53:14 -07:00
committed by GitHub
co-authored by Jon Saad-Falcon
parent 54d79ef235
commit f339c1c2d6
10 changed files with 677 additions and 21 deletions
+10
View File
@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-process": "^2",
@@ -3626,6 +3627,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-process": "^2",
+1
View File
@@ -17,6 +17,7 @@ tauri-plugin-autostart = "2"
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "multipart"] }
@@ -18,6 +18,7 @@
"shell:allow-stdin-write",
"shell:allow-kill",
"shell:allow-open",
"dialog:allow-open",
{
"identifier": "shell:allow-execute",
"allow": [
+1
View File
@@ -1606,6 +1606,7 @@ pub fn run() {
))
// .plugin(tauri_plugin_updater::Builder::new().build()) // disabled for local dev
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
+66
View File
@@ -784,3 +784,69 @@ export async function submitSavings(data: SavingsSubmission): Promise<boolean> {
return false;
}
}
// ---------------------------------------------------------------------------
// Memory
// ---------------------------------------------------------------------------
export interface MemorySearchResult {
content: string;
score: number;
metadata: Record<string, unknown>;
}
export interface MemoryStats {
entries: number;
backend: string;
[key: string]: unknown;
}
export interface MemoryConfig {
backend: string;
context_from_memory: boolean;
context_top_k: number;
context_min_score: number;
context_max_tokens: number;
}
export async function getMemoryStats(): Promise<MemoryStats> {
const res = await fetch(`${getBase()}/v1/memory/stats`);
if (!res.ok) throw new Error('Failed to fetch memory stats');
return res.json();
}
export async function searchMemory(query: string, topK: number = 5): Promise<MemorySearchResult[]> {
const res = await fetch(`${getBase()}/v1/memory/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, top_k: topK }),
});
if (!res.ok) throw new Error('Failed to search memory');
const data = await res.json();
return data.results;
}
export async function storeMemory(content: string, metadata?: Record<string, unknown>): Promise<void> {
const res = await fetch(`${getBase()}/v1/memory/store`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, metadata }),
});
if (!res.ok) throw new Error('Failed to store memory');
}
export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: number }> {
const res = await fetch(`${getBase()}/v1/memory/index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error('Failed to index path');
return res.json();
}
export async function getMemoryConfig(): Promise<MemoryConfig> {
const res = await fetch(`${getBase()}/v1/memory/config`);
if (!res.ok) throw new Error('Failed to fetch memory config');
return res.json();
}
+2 -2
View File
@@ -1453,8 +1453,8 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
.reverse();
return (
<div className="flex flex-col h-full" style={{ minHeight: 320 }}>
<div className="flex-1 overflow-y-auto space-y-3 pb-4" style={{ maxHeight: 400 }}>
<div className="flex flex-col" style={{ minHeight: 320 }}>
<div className="flex-1 overflow-y-auto space-y-3 pb-4" style={{ maxHeight: 'calc(100vh - 400px)' }}>
{displayMessages.length === 0 && !waitingForResponse && (
<div className="text-sm text-center py-8" style={{ color: 'var(--color-text-tertiary)' }}>
No messages yet. Send a message to interact with this agent.
+377 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useAppStore } from '../lib/store';
import {
fetchManagedAgents,
@@ -8,10 +8,14 @@ import {
createManagedAgent,
sendblueRegisterWebhook,
sendblueHealth,
getMemoryStats,
searchMemory,
storeMemory,
indexMemoryPath,
} from '../lib/api';
import type { ChannelBinding, ManagedAgent } from '../lib/api';
import { getBase } from '../lib/api';
import { Database, MessageSquare, Loader2 } from 'lucide-react';
import type { ChannelBinding, ManagedAgent, MemoryStats, MemorySearchResult } from '../lib/api';
import { getBase, isTauri } from '../lib/api';
import { Database, MessageSquare, Loader2, Brain, Search, FolderOpen, FileText } from 'lucide-react';
import { SOURCE_CATALOG } from '../types/connectors';
import type { ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource, getSyncStatus, triggerSync } from '../lib/connectors-api';
@@ -1411,13 +1415,378 @@ function MessagingSection({ agentId }: { agentId: string }) {
);
}
// ---------------------------------------------------------------------------
// Memory section
// ---------------------------------------------------------------------------
function MemorySection() {
const [stats, setStats] = useState<MemoryStats | null>(null);
const [statsError, setStatsError] = useState('');
// Search
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<MemorySearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searchDone, setSearchDone] = useState(false);
// Index
const [indexPath, setIndexPath] = useState('');
const [indexing, setIndexing] = useState(false);
const [indexResult, setIndexResult] = useState('');
const [indexError, setIndexError] = useState('');
// Store
const [storeContent, setStoreContent] = useState('');
const [storing, setStoring] = useState(false);
const [storeResult, setStoreResult] = useState('');
const [storeError, setStoreError] = useState('');
const statsInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const loadStats = useCallback(() => {
getMemoryStats()
.then((s) => { setStats(s); setStatsError(''); })
.catch(() => setStatsError('Could not reach memory backend'));
}, []);
useEffect(() => {
loadStats();
statsInterval.current = setInterval(loadStats, 10000);
return () => { if (statsInterval.current) clearInterval(statsInterval.current); };
}, [loadStats]);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setSearching(true);
setSearchDone(false);
try {
const results = await searchMemory(searchQuery.trim());
setSearchResults(results || []);
setSearchDone(true);
} catch {
setSearchResults([]);
setSearchDone(true);
} finally {
setSearching(false);
}
};
const handleBrowse = async () => {
if (isTauri()) {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({ directory: true, multiple: false, title: 'Select folder to index' });
if (selected) setIndexPath(selected as string);
return;
} catch {
// fall through to browser picker
}
}
const input = document.createElement('input');
input.type = 'file';
input.setAttribute('webkitdirectory', '');
input.onchange = () => {
const files = input.files;
if (files && files.length > 0) {
const rel = (files[0] as any).webkitRelativePath || '';
const folder = rel.split('/')[0];
if (folder) setIndexPath(folder);
}
};
input.click();
};
const handleIndex = async () => {
if (!indexPath.trim()) return;
setIndexing(true);
setIndexResult('');
setIndexError('');
try {
const res = await indexMemoryPath(indexPath.trim());
setIndexResult(`Indexed ${res.chunks_indexed} chunk${res.chunks_indexed !== 1 ? 's' : ''}`);
setIndexPath('');
loadStats();
} catch (err: any) {
setIndexError(err.message || 'Indexing failed');
} finally {
setIndexing(false);
}
};
const handleStore = async () => {
if (!storeContent.trim()) return;
setStoring(true);
setStoreResult('');
setStoreError('');
try {
await storeMemory(storeContent.trim());
setStoreResult('Stored successfully');
setStoreContent('');
loadStats();
} catch (err: any) {
setStoreError(err.message || 'Failed to store');
} finally {
setStoring(false);
}
};
return (
<div className="space-y-4">
{/* Stats overview */}
<div
className="rounded-xl p-5 relative overflow-hidden"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
{/* Subtle gradient accent along top edge */}
<div className="absolute top-0 left-0 right-0 h-[2px]" style={{
background: 'linear-gradient(90deg, var(--color-accent-purple), var(--color-accent), transparent)',
}} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center" style={{
background: 'var(--color-accent-purple-subtle)',
}}>
<Brain size={18} style={{ color: 'var(--color-accent-purple)' }} />
</div>
<div>
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Memory Backend</h3>
{statsError ? (
<p className="text-xs mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{statsError}</p>
) : stats ? (
<div className="flex items-center gap-2 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full" style={{
background: stats.entries > 0 ? 'var(--color-success)' : 'var(--color-text-tertiary)',
}} />
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{stats.backend} &middot; {stats.entries.toLocaleString()} {stats.entries === 1 ? 'chunk' : 'chunks'}
</span>
</div>
) : (
<p className="text-xs mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>Connecting...</p>
)}
</div>
</div>
{stats && stats.entries > 0 && (
<div className="text-right">
<div className="text-lg font-bold tabular-nums" style={{ color: 'var(--color-text)' }}>
{stats.entries.toLocaleString()}
</div>
<div className="text-[10px] uppercase tracking-wider" style={{ color: 'var(--color-text-tertiary)' }}>
indexed
</div>
</div>
)}
</div>
</div>
{/* Search */}
<div
className="rounded-xl p-5"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2 mb-3">
<Search size={14} style={{ color: 'var(--color-accent-purple)' }} />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Search Memory</h3>
</div>
<div className="flex gap-2">
<div className="flex-1 relative">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
placeholder="What are you looking for?"
className="w-full text-sm px-3 py-2 rounded-lg outline-none transition-colors"
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
<button
onClick={handleSearch}
disabled={searching || !searchQuery.trim()}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer whitespace-nowrap"
style={{
background: searching || !searchQuery.trim() ? 'var(--color-bg-tertiary)' : 'var(--color-accent-purple)',
color: searching || !searchQuery.trim() ? 'var(--color-text-tertiary)' : '#fff',
opacity: searching || !searchQuery.trim() ? 0.6 : 1,
}}
>
{searching ? <Loader2 size={13} className="animate-spin" /> : <Search size={13} />}
{searching ? 'Searching' : 'Search'}
</button>
</div>
{/* Results */}
{searchDone && searchResults.length === 0 && (
<div className="flex flex-col items-center py-6 gap-2">
<Search size={20} style={{ color: 'var(--color-text-tertiary)', opacity: 0.4 }} />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>No matching memories found</p>
</div>
)}
{searchResults.length > 0 && (
<div className="mt-3 space-y-2">
{searchResults.map((r, i) => (
<div
key={i}
className="rounded-lg p-3 transition-colors"
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
}}
>
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-text)' }}>
{r.content.length > 250 ? r.content.slice(0, 250) + '...' : r.content}
</p>
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium" style={{
background: r.score > 0.5
? 'rgba(74, 222, 128, 0.1)'
: r.score > 0.2
? 'var(--color-accent-amber-subtle)'
: 'var(--color-bg-tertiary)',
color: r.score > 0.5
? 'var(--color-success)'
: r.score > 0.2
? 'var(--color-warning)'
: 'var(--color-text-tertiary)',
}}>
{(r.score * 100).toFixed(0)}% match
</span>
{r.metadata?.source != null && (
<span className="text-[10px]" style={{ color: 'var(--color-text-tertiary)' }}>
{String(r.metadata.source)}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Add to Memory — two-column grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Index folder */}
<div
className="rounded-xl p-5"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2 mb-3">
<FolderOpen size={14} style={{ color: 'var(--color-accent-purple)' }} />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Index Folder</h3>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
Scan a folder and index all supported files into memory.
</p>
<div className="flex gap-2 mb-2">
<input
value={indexPath}
onChange={(e) => setIndexPath(e.target.value)}
placeholder="~/Documents/notes"
className="flex-1 text-sm px-3 py-2 rounded-lg outline-none"
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
/>
{isTauri() && (
<button
onClick={handleBrowse}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors whitespace-nowrap"
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<FolderOpen size={12} />
Browse
</button>
)}
</div>
<button
onClick={handleIndex}
disabled={indexing || !indexPath.trim()}
className="w-full flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium cursor-pointer transition-all"
style={{
background: indexing || !indexPath.trim() ? 'var(--color-bg-tertiary)' : 'var(--color-accent-purple)',
color: indexing || !indexPath.trim() ? 'var(--color-text-tertiary)' : '#fff',
opacity: indexing || !indexPath.trim() ? 0.6 : 1,
}}
>
{indexing && <Loader2 size={13} className="animate-spin" />}
{indexing ? 'Indexing files...' : 'Index'}
</button>
{indexResult && (
<p className="text-xs mt-2 font-medium" style={{ color: 'var(--color-success)' }}>{indexResult}</p>
)}
{indexError && (
<p className="text-xs mt-2 font-medium" style={{ color: 'var(--color-error)' }}>{indexError}</p>
)}
</div>
{/* Paste content */}
<div
className="rounded-xl p-5"
style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
>
<div className="flex items-center gap-2 mb-3">
<FileText size={14} style={{ color: 'var(--color-accent-purple)' }} />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Store Text</h3>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
Paste any text to add directly to your memory store.
</p>
<textarea
value={storeContent}
onChange={(e) => setStoreContent(e.target.value)}
placeholder="Paste or type content here..."
rows={4}
className="w-full text-sm px-3 py-2 rounded-lg outline-none resize-y"
style={{
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
fontFamily: 'inherit',
minHeight: 80,
marginBottom: 8,
}}
/>
<button
onClick={handleStore}
disabled={storing || !storeContent.trim()}
className="w-full flex items-center justify-center gap-1.5 py-2 rounded-lg text-sm font-medium cursor-pointer transition-all"
style={{
background: storing || !storeContent.trim() ? 'var(--color-bg-tertiary)' : 'var(--color-accent-purple)',
color: storing || !storeContent.trim() ? 'var(--color-text-tertiary)' : '#fff',
opacity: storing || !storeContent.trim() ? 0.6 : 1,
}}
>
{storing && <Loader2 size={13} className="animate-spin" />}
{storing ? 'Storing...' : 'Store'}
</button>
{storeResult && (
<p className="text-xs mt-2 font-medium" style={{ color: 'var(--color-success)' }}>{storeResult}</p>
)}
{storeError && (
<p className="text-xs mt-2 font-medium" style={{ color: 'var(--color-error)' }}>{storeError}</p>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function DataSourcesPage() {
const [agents, setAgents] = useState<ManagedAgent[]>([]);
const [activeTab, setActiveTab] = useState<'sources' | 'messaging'>('sources');
const [activeTab, setActiveTab] = useState<'sources' | 'messaging' | 'memory'>('sources');
const [creatingAgent, setCreatingAgent] = useState(false);
const loadAgents = useCallback(() => {
@@ -1457,6 +1826,7 @@ export function DataSourcesPage() {
const tabs = [
{ id: 'sources' as const, label: 'Data Sources', icon: Database },
{ id: 'messaging' as const, label: 'Messaging Channels', icon: MessageSquare },
{ id: 'memory' as const, label: 'Memory', icon: Brain },
];
return (
@@ -1464,7 +1834,7 @@ export function DataSourcesPage() {
{/* Header */}
<div className="shrink-0 px-6 pt-6 pb-4">
<h1 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Data Sources &amp; Messaging Channels
Data Sources, Channels &amp; Memory
</h1>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-secondary)' }}>
Connect your personal data so your AI can search across everything, and set up messaging channels to chat from your phone.
@@ -1504,6 +1874,7 @@ export function DataSourcesPage() {
</div>
) : null
)}
{activeTab === 'memory' && <MemorySection />}
</div>
</div>
);
+126 -1
View File
@@ -16,9 +16,10 @@ import {
Mic,
Key,
Search,
Brain,
} from 'lucide-react';
import { useAppStore, type ThemeMode } from '../lib/store';
import { checkHealth, fetchSpeechHealth } from '../lib/api';
import { checkHealth, fetchSpeechHealth, getMemoryStats } from '../lib/api';
function OllamaModelList() {
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
@@ -122,11 +123,31 @@ export function SettingsPage() {
const [speechBackendAvailable, setSpeechBackendAvailable] = useState<boolean | null>(null);
const [saved, setSaved] = useState(false);
const [memoryStats, setMemoryStats] = useState<{ entries: number; backend: string } | null>(null);
const [memoryEnabled, setMemoryEnabled] = useState(() => {
try { return localStorage.getItem('openjarvis-memory-enabled') !== 'false'; } catch { return true; }
});
const [memoryBackend, setMemoryBackend] = useState(() => {
try { return localStorage.getItem('openjarvis-memory-backend') || 'sqlite'; } catch { return 'sqlite'; }
});
const [memoryTopK, setMemoryTopK] = useState(() => {
try { return parseInt(localStorage.getItem('openjarvis-memory-top-k') || '5'); } catch { return 5; }
});
const [memoryMinScore, setMemoryMinScore] = useState(() => {
try { return parseFloat(localStorage.getItem('openjarvis-memory-min-score') || '0.1'); } catch { return 0.1; }
});
const [memoryMaxTokens, setMemoryMaxTokens] = useState(() => {
try { return parseInt(localStorage.getItem('openjarvis-memory-max-tokens') || '2048'); } catch { return 2048; }
});
useEffect(() => {
checkHealth().then(setHealthy);
fetchSpeechHealth()
.then((h) => setSpeechBackendAvailable(h.available))
.catch(() => setSpeechBackendAvailable(false));
getMemoryStats()
.then(setMemoryStats)
.catch(() => setMemoryStats(null));
}, []);
const showSaved = () => {
@@ -312,6 +333,110 @@ export function SettingsPage() {
</SettingRow>
</Section>
{/* Memory */}
<Section title="Memory">
<SettingRow label="Memory status" description={memoryStats ? `${memoryStats.backend} backend — ${memoryStats.entries} entries` : 'Unable to reach memory service'}>
<div className="flex items-center gap-2">
<Brain size={14} style={{ color: memoryStats ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{memoryStats ? `${memoryStats.entries} entries` : 'Unavailable'}
</span>
</div>
</SettingRow>
<SettingRow label="Use memory context" description="Automatically inject relevant memories into conversations">
<button
onClick={() => {
const next = !memoryEnabled;
setMemoryEnabled(next);
try { localStorage.setItem('openjarvis-memory-enabled', String(next)); } catch {}
showSaved();
}}
className="relative w-11 h-6 rounded-full transition-colors cursor-pointer"
style={{
background: memoryEnabled ? '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: memoryEnabled ? 'translateX(20px)' : 'translateX(0)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}
/>
</button>
</SettingRow>
<SettingRow label="Memory backend" description="Which retrieval engine to use">
<select
value={memoryBackend}
onChange={(e) => {
setMemoryBackend(e.target.value);
try { localStorage.setItem('openjarvis-memory-backend', e.target.value); } catch {}
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="sqlite">sqlite</option>
<option value="faiss">faiss</option>
<option value="bm25">bm25</option>
<option value="colbert">colbert</option>
<option value="hybrid">hybrid</option>
</select>
</SettingRow>
<SettingRow label="Results to inject" description={`${memoryTopK}`}>
<input
type="range"
min="1"
max="20"
step="1"
value={memoryTopK}
onChange={(e) => {
const v = parseInt(e.target.value);
setMemoryTopK(v);
try { localStorage.setItem('openjarvis-memory-top-k', String(v)); } catch {}
showSaved();
}}
className="w-32 cursor-pointer accent-[var(--color-accent)]"
/>
</SettingRow>
<SettingRow label="Min relevance score" description={`${memoryMinScore}`}>
<input
type="range"
min="0"
max="1"
step="0.05"
value={memoryMinScore}
onChange={(e) => {
const v = parseFloat(e.target.value);
setMemoryMinScore(v);
try { localStorage.setItem('openjarvis-memory-min-score', String(v)); } catch {}
showSaved();
}}
className="w-32 cursor-pointer accent-[var(--color-accent)]"
/>
</SettingRow>
<SettingRow label="Max context tokens" description={`${memoryMaxTokens}`}>
<input
type="range"
min="256"
max="8192"
step="256"
value={memoryMaxTokens}
onChange={(e) => {
const v = parseInt(e.target.value);
setMemoryMaxTokens(v);
try { localStorage.setItem('openjarvis-memory-max-tokens', String(v)); } catch {}
showSaved();
}}
className="w-32 cursor-pointer accent-[var(--color-accent)]"
/>
</SettingRow>
</Section>
{/* Model defaults */}
<Section title="Model Defaults">
<SettingRow label="Temperature" description={`${settings.temperature}`}>
+92 -12
View File
@@ -35,6 +35,10 @@ class MemorySearchRequest(BaseModel):
top_k: int = 5
class MemoryIndexRequest(BaseModel):
path: str
class BudgetLimitsRequest(BaseModel):
max_tokens_per_day: Optional[int] = None
max_requests_per_hour: Optional[int] = None
@@ -148,13 +152,26 @@ async def message_agent(agent_id: str, req: AgentMessageRequest, request: Reques
memory_router = APIRouter(prefix="/v1/memory", tags=["memory"])
def _get_memory_backend(request: Request):
"""Return the app-level memory backend, falling back to a fresh SQLiteMemory."""
backend = getattr(request.app.state, "memory_backend", None)
if backend is None:
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
backend = SQLiteMemory()
except Exception:
return None
return backend
@memory_router.post("/store")
async def memory_store(req: MemoryStoreRequest, request: Request):
"""Store content in memory."""
backend = _get_memory_backend(request)
if backend is None:
return {"status": "stored", "note": "no backend available"}
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
backend = SQLiteMemory()
backend.store(req.content, metadata=req.metadata or {})
return {"status": "stored"}
except Exception as exc:
@@ -164,13 +181,17 @@ async def memory_store(req: MemoryStoreRequest, request: Request):
@memory_router.post("/search")
async def memory_search(req: MemorySearchRequest, request: Request):
"""Search memory for relevant content."""
backend = _get_memory_backend(request)
if backend is None:
return {"results": []}
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
backend = SQLiteMemory()
results = backend.search(req.query, top_k=req.top_k)
results = backend.retrieve(req.query, top_k=req.top_k)
items = [
{"content": r.content, "score": r.score, "metadata": r.metadata}
{
"content": r.content,
"score": getattr(r, "score", 0.0),
"metadata": getattr(r, "metadata", {}),
}
for r in results
]
return {"results": items}
@@ -181,12 +202,71 @@ async def memory_search(req: MemorySearchRequest, request: Request):
@memory_router.get("/stats")
async def memory_stats(request: Request):
"""Get memory backend statistics."""
backend = _get_memory_backend(request)
if backend is None:
return {"entries": 0, "backend": "none", "status": "not_configured"}
try:
from openjarvis.tools.storage.sqlite import SQLiteMemory
return {
"entries": backend.count(),
"backend": getattr(backend, "backend_id", "unknown"),
}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
backend = SQLiteMemory()
stats = backend.stats()
return stats
@memory_router.get("/config")
async def memory_config(request: Request):
"""Return current memory configuration."""
try:
config = getattr(request.app.state, "config", None)
if config is None:
from openjarvis.core.config import load_config
config = load_config()
backend = getattr(request.app.state, "memory_backend", None)
return {
"backend_type": (
backend.backend_id
if backend is not None
else config.memory.default_backend
),
"context_top_k": config.memory.context_top_k,
"context_min_score": config.memory.context_min_score,
"context_max_tokens": config.memory.context_max_tokens,
"context_from_memory": config.agent.context_from_memory,
}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@memory_router.post("/index")
async def memory_index(req: MemoryIndexRequest, request: Request):
"""Index files from a path into memory."""
try:
from pathlib import Path
from openjarvis.tools.storage.ingest import ingest_path
target = Path(req.path).expanduser().resolve()
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {req.path}")
backend = _get_memory_backend(request)
if backend is None:
raise HTTPException(status_code=503, detail="No memory backend available")
chunks = ingest_path(target)
stored = 0
for chunk in chunks:
metadata = {"source": getattr(chunk, "source", str(target))}
if hasattr(chunk, "metadata") and chunk.metadata:
metadata.update(chunk.metadata)
backend.store(chunk.content, metadata=metadata)
stored += 1
return {"status": "indexed", "chunks_indexed": stored}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))