Add AI prompt tool for natural language graph operations

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.
This commit is contained in:
2026-02-03 02:40:01 +01:00
parent f2f9d729da
commit 661325a235
11 changed files with 569 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ 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);
@@ -17,6 +18,7 @@ export default function App() {
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(() => {
@@ -38,13 +40,14 @@ export default function App() {
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]);
}, [selectedId, drawerOpen, showQuery, showPrompt]);
return (
<div className="h-screen w-screen overflow-hidden relative">
@@ -76,6 +79,13 @@ export default function App() {
>
?
</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"
>
&#x2726;
</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"
@@ -141,6 +151,22 @@ export default function App() {
</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)}

View File

@@ -1,4 +1,4 @@
import type { CortexNode, CortexEdge, GraphData, NodeWithConnections, SearchResult, NodeKind, EdgeType, GroupedQueryResult } from './types';
import type { CortexNode, CortexEdge, GraphData, NodeWithConnections, SearchResult, NodeKind, EdgeType, GroupedQueryResult, PromptResult } from './types';
const BASE = '/api';
@@ -50,4 +50,7 @@ export const api = {
runMaintenance: () =>
request<Record<string, any>>('/maintenance/run', { method: 'POST' }),
prompt: (prompt: string) =>
request<PromptResult>('/prompt', { method: 'POST', body: JSON.stringify({ prompt }) }),
};

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import { api } from '../api';
import type { PromptResult } from '../types';
interface Props {
onClose: () => void;
onDone: () => void;
}
export default function PromptPanel({ onClose, onDone }: Props) {
const [text, setText] = useState('');
const [result, setResult] = useState<PromptResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!text.trim()) return;
setLoading(true);
setError(null);
setResult(null);
try {
const data = await api.prompt(text);
setResult(data);
if (data.success) onDone();
} catch (err: any) {
setError(err.message || 'Request failed');
} finally {
setLoading(false);
}
};
return (
<div className="h-full flex flex-col bg-gray-900/95 backdrop-blur border-t border-amber-500/30 rounded-t-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50">
<span className="text-amber-300 font-medium text-sm">AI Prompt</span>
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 text-lg">&times;</button>
</div>
{/* Input */}
<div className="px-4 py-3 flex gap-2">
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !loading && submit()}
placeholder='e.g. "create a decision node about using Redis for caching"'
className="flex-1 bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-amber-500"
autoFocus
disabled={loading}
/>
<button
onClick={submit}
disabled={loading || !text.trim()}
className="px-4 py-2 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 rounded-lg text-sm text-white font-medium min-w-[80px]"
>
{loading ? (
<span className="inline-block animate-pulse">Running...</span>
) : 'Run'}
</button>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{!result && !loading && !error && (
<div className="text-gray-500 text-sm mt-4 space-y-1">
<p>Give a natural language instruction to modify the graph:</p>
<p className="text-gray-600 ml-2">"create a decision node about using Redis for caching"</p>
<p className="text-gray-600 ml-2">"tag all task nodes with 'backlog'"</p>
<p className="text-gray-600 ml-2">"create a component for auth and link it to the API gateway"</p>
</div>
)}
{loading && (
<div className="text-amber-400/70 text-sm mt-4 animate-pulse">
Generating and executing action plan...
</div>
)}
{error && (
<div className="mt-4 p-3 rounded-lg bg-red-900/30 border border-red-700/50 text-red-300 text-sm">
{error}
</div>
)}
{result && (
<div className="space-y-3 mt-2">
{/* Summary */}
<div className={`p-3 rounded-lg border text-sm ${
result.success
? 'bg-green-900/20 border-green-700/50 text-green-300'
: 'bg-yellow-900/20 border-yellow-700/50 text-yellow-300'
}`}>
<div className="font-medium mb-1">{result.success ? 'Completed' : 'Completed with errors'}</div>
<div>{result.summary}</div>
</div>
{/* Reasoning */}
{result.reasoning && (
<div className="text-xs text-gray-400 bg-gray-800/50 rounded-lg p-3">
<span className="text-gray-500 font-medium">Reasoning: </span>
{result.reasoning}
</div>
)}
{/* Execution log */}
{result.executionLog.length > 0 && (
<div className="space-y-1">
<div className="text-xs text-gray-500 font-medium uppercase tracking-wide">Execution Log</div>
{result.executionLog.map((entry, i) => (
<div
key={i}
className={`flex items-start gap-2 p-2 rounded text-sm ${
entry.status === 'completed' ? 'bg-gray-800/30' : 'bg-red-900/20'
}`}
>
<span className={`mt-0.5 text-xs ${
entry.status === 'completed' ? 'text-green-500' : 'text-red-500'
}`}>
{entry.status === 'completed' ? '✓' : '✗'}
</span>
<div className="flex-1 min-w-0">
<div className="text-gray-300 truncate">{entry.description}</div>
<div className="text-xs text-gray-500">{entry.action}</div>
{entry.error && (
<div className="text-xs text-red-400 mt-1">{entry.error}</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -54,3 +54,18 @@ export interface GroupedQueryResult {
groups: ResultGroup[];
totalResults: number;
}
export interface ActionResultEntry {
action: string;
description: string;
status: 'completed' | 'failed';
result?: any;
error?: string;
}
export interface PromptResult {
success: boolean;
reasoning: string;
executionLog: ActionResultEntry[];
summary: string;
}