New MCP tool and portal UI for executing natural language instructions against the memory graph via Ollama (qwen3-coder:30b). Single LLM call generates a JSON action plan which is executed sequentially. Supports 8 action types: add_node, update_node, remove_node, add_edge, remove_edge, bulk_tag, reorganize, query. Actions can reference previous results via $result[N].field interpolation. Uses /api/chat with few-shot assistant example, format:json, and temperature:0 for reliable output.
192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import GraphView from './components/GraphView';
|
|
import Sidebar from './components/Sidebar';
|
|
import NodePanel from './components/NodePanel';
|
|
import AddNodeModal from './components/AddNodeModal';
|
|
import QueryBar from './components/QueryBar';
|
|
import LinkModal from './components/LinkModal';
|
|
import Toast from './components/Toast';
|
|
import MaintenancePanel from './components/MaintenancePanel';
|
|
import PromptPanel from './components/PromptPanel';
|
|
|
|
export default function App() {
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [showAddNode, setShowAddNode] = useState(false);
|
|
const [linkFromId, setLinkFromId] = useState<string | null>(null);
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [showQuery, setShowQuery] = useState(false);
|
|
const [showMaintenance, setShowMaintenance] = useState(false);
|
|
const [showPrompt, setShowPrompt] = useState(false);
|
|
const qc = useQueryClient();
|
|
|
|
const refresh = useCallback(() => {
|
|
qc.invalidateQueries({ queryKey: ['graph'] });
|
|
qc.invalidateQueries({ queryKey: ['nodes'] });
|
|
}, [qc]);
|
|
|
|
const notify = useCallback((msg: string) => {
|
|
setToast(msg);
|
|
setTimeout(() => setToast(null), 3000);
|
|
}, []);
|
|
|
|
const selectNode = useCallback((id: string) => {
|
|
setSelectedId(id);
|
|
setDrawerOpen(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (selectedId) setSelectedId(null);
|
|
else if (showPrompt) setShowPrompt(false);
|
|
else if (showQuery) setShowQuery(false);
|
|
else if (drawerOpen) setDrawerOpen(false);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [selectedId, drawerOpen, showQuery, showPrompt]);
|
|
|
|
return (
|
|
<div className="h-screen w-screen overflow-hidden relative">
|
|
{/* Full-screen graph */}
|
|
<div className="absolute inset-0">
|
|
<GraphView selectedId={selectedId} onSelect={selectNode} />
|
|
</div>
|
|
|
|
{/* Floating action buttons — bottom-left */}
|
|
<div className="fixed bottom-5 left-5 flex flex-col gap-2 z-30">
|
|
<button
|
|
onClick={() => setDrawerOpen(!drawerOpen)}
|
|
className="w-12 h-12 rounded-full bg-gray-800/90 backdrop-blur border border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white shadow-lg flex items-center justify-center text-lg"
|
|
title="Node list"
|
|
>
|
|
☰
|
|
</button>
|
|
<button
|
|
onClick={() => setShowAddNode(true)}
|
|
className="w-12 h-12 rounded-full bg-indigo-600/90 backdrop-blur border border-indigo-500 text-white hover:bg-indigo-500 shadow-lg flex items-center justify-center text-xl font-light"
|
|
title="Add node"
|
|
>
|
|
+
|
|
</button>
|
|
<button
|
|
onClick={() => setShowQuery(!showQuery)}
|
|
className="w-12 h-12 rounded-full bg-purple-600/90 backdrop-blur border border-purple-500 text-white hover:bg-purple-500 shadow-lg flex items-center justify-center text-lg font-bold"
|
|
title="Query memory"
|
|
>
|
|
?
|
|
</button>
|
|
<button
|
|
onClick={() => setShowPrompt(!showPrompt)}
|
|
className="w-12 h-12 rounded-full bg-amber-600/90 backdrop-blur border border-amber-500 text-white hover:bg-amber-500 shadow-lg flex items-center justify-center text-lg"
|
|
title="AI Prompt"
|
|
>
|
|
✦
|
|
</button>
|
|
<button
|
|
onClick={() => setShowMaintenance(!showMaintenance)}
|
|
className="w-12 h-12 rounded-full bg-emerald-600/90 backdrop-blur border border-emerald-500 text-white hover:bg-emerald-500 shadow-lg flex items-center justify-center text-lg"
|
|
title="Maintenance"
|
|
>
|
|
⚙
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sidebar drawer — slides in from left */}
|
|
{drawerOpen && (
|
|
<div className="fixed inset-0 z-40" onClick={() => setDrawerOpen(false)}>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
<div
|
|
className="absolute top-0 left-0 h-full w-80 max-w-[85vw] animate-slide-in-left"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Sidebar
|
|
selectedId={selectedId}
|
|
onSelect={selectNode}
|
|
onAddNode={() => { setDrawerOpen(false); setShowAddNode(true); }}
|
|
onClose={() => setDrawerOpen(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Node detail panel — slides in from right */}
|
|
{selectedId && (
|
|
<div className="fixed inset-0 z-40 pointer-events-none">
|
|
<div
|
|
className="absolute inset-0 bg-black/40 pointer-events-auto"
|
|
onClick={() => setSelectedId(null)}
|
|
/>
|
|
<div
|
|
className="absolute top-0 right-0 h-full w-80 max-w-[90vw] pointer-events-auto animate-slide-in-right"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<NodePanel
|
|
nodeId={selectedId}
|
|
onClose={() => setSelectedId(null)}
|
|
onLink={(id) => setLinkFromId(id)}
|
|
onRefresh={refresh}
|
|
onNotify={notify}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Query panel — slides up from bottom */}
|
|
{showQuery && (
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowQuery(false)}>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-[70vh] animate-slide-in-up"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<QueryBar
|
|
onSelectNode={(id) => { setShowQuery(false); selectNode(id); }}
|
|
onClose={() => setShowQuery(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Prompt panel — slides up from bottom */}
|
|
{showPrompt && (
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowPrompt(false)}>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-[70vh] animate-slide-in-up"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<PromptPanel
|
|
onClose={() => setShowPrompt(false)}
|
|
onDone={() => { refresh(); notify('Prompt executed'); }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showAddNode && (
|
|
<AddNodeModal
|
|
onClose={() => setShowAddNode(false)}
|
|
onCreated={() => { refresh(); notify('Node created'); }}
|
|
/>
|
|
)}
|
|
{linkFromId && (
|
|
<LinkModal
|
|
fromId={linkFromId}
|
|
onClose={() => setLinkFromId(null)}
|
|
onCreated={() => { refresh(); notify('Edge created'); }}
|
|
/>
|
|
)}
|
|
{showMaintenance && (
|
|
<div className="fixed bottom-20 left-5 z-50">
|
|
<MaintenancePanel onClose={() => setShowMaintenance(false)} onNotify={(msg) => { notify(msg); refresh(); }} />
|
|
</div>
|
|
)}
|
|
{toast && <Toast message={toast} />}
|
|
</div>
|
|
);
|
|
}
|