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:
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
Reference in New Issue
Block a user