mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
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:
co-authored by
Jon Saad-Falcon
parent
54d79ef235
commit
f339c1c2d6
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} · {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 & Messaging Channels
|
||||
Data Sources, Channels & 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>
|
||||
);
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user