feat: sync pane workspace overhaul from private repo

- ship the expanded left-nav and pane header workspace updates
- add dimension-driven browse flow, map view upgrades, and delete-node tooling
- expand eval coverage and refresh the public UI docs for the new layout

Generated with Codex
This commit is contained in:
“BeeRad”
2026-03-17 18:20:20 +11:00
parent 0776bccdbd
commit 213c054c9f
25 changed files with 1050 additions and 630 deletions
+48
View File
@@ -0,0 +1,48 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
export const deleteNodeTool = tool({
description: 'Delete a node from the graph by ID',
inputSchema: z.object({
id: z.number().int().positive().describe('Node ID to delete'),
}),
execute: async ({ id }) => {
try {
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
let errorMessage = 'Failed to delete node';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
errorMessage = `Failed to delete node: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null,
};
}
const result = await response.json();
return {
success: true,
data: { nodeId: id, ...(result.data || {}) },
message: result.message || `Node ${id} deleted successfully`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete node',
data: null,
};
}
},
});
+94 -22
View File
@@ -4,8 +4,67 @@ import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import type { Node } from '@/types/database';
type QueryNodeFilters = {
dimensions?: string[];
search?: string;
limit?: number;
createdAfter?: string;
createdBefore?: string;
eventAfter?: string;
eventBefore?: string;
};
function extractSearchTerms(query: string): string[] {
const stopWords = new Set(['a', 'an', 'and', 'for', 'from', 'in', 'of', 'on', 'or', 'recent', 'the', 'to', 'with']);
const rawTerms = query
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const terms = new Set<string>();
for (const term of rawTerms) {
if (!stopWords.has(term) && term.length >= 3) {
terms.add(term);
}
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
for (const part of alphaParts) {
if (!stopWords.has(part) && part.length >= 3) {
terms.add(part);
}
}
}
return Array.from(terms).slice(0, 8);
}
function scoreNodeForSearch(node: Node, searchTerm: string): number {
const normalizedSearch = searchTerm.toLowerCase();
const title = (node.title || '').toLowerCase();
const description = (node.description || '').toLowerCase();
const notes = (node.notes || '').toLowerCase();
const terms = extractSearchTerms(searchTerm);
let score = 0;
if (title === normalizedSearch) score += 100;
if (title.includes(normalizedSearch)) score += 40;
if (description.includes(normalizedSearch)) score += 20;
if (notes.includes(normalizedSearch)) score += 10;
for (const term of terms) {
if (title.includes(term)) score += 8;
if (description.includes(term)) score += 3;
if (notes.includes(term)) score += 2;
}
return score;
}
export const queryNodesTool = tool({
description: 'Search nodes across title, description, notes, and dimensions. Multi-word queries use FTS/tokenized fallback, and agent calls can add node-vector retrieval.',
description: 'Search nodes across title, description, and notes. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.',
inputSchema: z.object({
filters: z.object({
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
@@ -17,7 +76,7 @@ export const queryNodesTool = tool({
eventBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.'),
}).optional()
}),
execute: async ({ filters = {} }) => {
execute: async ({ filters = {} }: { filters?: QueryNodeFilters }) => {
console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2));
try {
const limit = filters.limit || 10;
@@ -63,27 +122,40 @@ export const queryNodesTool = tool({
};
}
// Add timeout to prevent hanging
const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => {
setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000);
});
const runQuery = async (queryFilters: typeof filters): Promise<Node[]> => {
const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => {
setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000);
});
// Use new nodeService with dimension-based filtering
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
limit,
dimensions: filters.dimensions,
search: filters.search,
searchMode: searchTerm ? 'hybrid' : 'standard',
createdAfter: filters.createdAfter,
createdBefore: filters.createdBefore,
eventAfter: filters.eventAfter,
eventBefore: filters.eventBefore,
});
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
limit,
dimensions: queryFilters.dimensions,
search: queryFilters.search,
searchMode: searchTerm ? 'hybrid' : 'standard',
createdAfter: queryFilters.createdAfter,
createdBefore: queryFilters.createdBefore,
eventAfter: queryFilters.eventAfter,
eventBefore: queryFilters.eventBefore,
});
const nodes = await Promise.race<Node[] | undefined>([nodesPromise, timeoutPromise]);
const nodes = await Promise.race<Node[] | undefined>([nodesPromise, timeoutPromise]);
return Array.isArray(nodes) ? nodes : [];
};
const effectiveFilters = searchTerm
? { ...filters, dimensions: undefined }
: { ...filters };
let safeNodes = await runQuery(effectiveFilters);
if (searchTerm) {
safeNodes = safeNodes
.map(node => ({ node, score: scoreNodeForSearch(node, searchTerm) }))
.sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at))
.slice(0, limit)
.map(entry => entry.node);
}
// Handle the case where nodes might be undefined/null
const safeNodes: Node[] = Array.isArray(nodes) ? nodes : [];
const limitedNodes = safeNodes.slice(0, limit);
// Format nodes for chat display
@@ -106,14 +178,14 @@ export const queryNodesTool = tool({
// Create message with formatted node labels only (no full node payload)
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', ');
const message = `Found ${safeNodes.length} nodes${filters.dimensions ? ` with dimensions: ${filters.dimensions.join(', ')}` : ''}${filters.search ? ` matching: "${filters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
const message = `Found ${safeNodes.length} nodes${effectiveFilters.dimensions ? ` with dimensions: ${effectiveFilters.dimensions.join(', ')}` : ''}${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
return {
success: true,
data: {
nodes: formattedNodes,
count: safeNodes.length,
filters_applied: filters
filters_applied: effectiveFilters
},
message: message
};
+1
View File
@@ -49,6 +49,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Execution: Write operations and extraction (workers only)
createNode: 'execution',
updateNode: 'execution',
deleteNode: 'execution',
createEdge: 'execution',
updateEdge: 'execution',
embedContent: 'execution',
+3
View File
@@ -3,6 +3,7 @@ import { queryNodesTool } from '../database/queryNodes';
import { getNodesByIdTool } from '../database/getNodesById';
import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode';
import { deleteNodeTool } from '../database/deleteNode';
import { createEdgeTool } from '../database/createEdge';
import { queryEdgeTool } from '../database/queryEdge';
import { updateEdgeTool } from '../database/updateEdge';
@@ -43,6 +44,7 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool,
updateNode: updateNodeTool,
deleteNode: deleteNodeTool,
createEdge: createEdgeTool,
updateEdge: updateEdgeTool,
createDimension: createDimensionTool,
@@ -71,6 +73,7 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
'think',
'createNode',
'updateNode',
'deleteNode',
'createEdge',
'updateEdge',
'createDimension',