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:
@@ -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"
|
||||
>
|
||||
✦
|
||||
</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)}
|
||||
|
||||
@@ -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 }) }),
|
||||
};
|
||||
|
||||
137
portal/src/components/PromptPanel.tsx
Normal file
137
portal/src/components/PromptPanel.tsx
Normal 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">×</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user