feat: Enhance navigation and cleanup for graph explorer view

- Added a cleanup function to reset the state when navigating away from the graph explorer.
- Updated navigation logic to call the cleanup function before switching views.
- Improved internationalization by adding new translations for graph-related terms.
- Adjusted icon sizes for better UI consistency in the graph explorer.
- Implemented impact analysis button functionality in the graph explorer.
- Refactored CLI tool configuration to use updated model names.
- Enhanced CLI executor to handle prompts correctly for codex commands.
- Introduced code relationship storage for better visualization in the index tree.
- Added support for parsing Markdown and plain text files in the symbol parser.
- Updated tests to reflect changes in language detection logic.
This commit is contained in:
catlog22
2025-12-15 23:11:01 +08:00
parent 894b93e08d
commit 35485bbbb1
35 changed files with 3348 additions and 228 deletions

View File

@@ -40,6 +40,7 @@ const MODULE_FILES = [
'dashboard-js/views/mcp-manager.js',
'dashboard-js/views/hook-manager.js',
'dashboard-js/views/history.js',
'dashboard-js/views/graph-explorer.js',
// Navigation & Main
'dashboard-js/components/navigation.js',
'dashboard-js/main.js'

View File

@@ -61,6 +61,7 @@ const MODULE_FILES = [
'views/mcp-manager.js',
'views/hook-manager.js',
'views/history.js',
'views/graph-explorer.js',
'main.js'
];

View File

@@ -6,7 +6,7 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
import { StoragePaths, ensureStorageDir, getProjectId } from '../config/storage-paths.js';
// Types
export interface Entity {
@@ -127,6 +127,7 @@ export class MemoryStore {
this.db.pragma('synchronous = NORMAL');
this.initDatabase();
this.migrateSchema();
}
/**
@@ -283,6 +284,43 @@ export class MemoryStore {
`);
}
/**
* Migrate schema for existing databases
*/
private migrateSchema(): void {
try {
// Check if hierarchical storage columns exist in conversations table
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
const hasProjectRoot = tableInfo.some(col => col.name === 'project_root');
const hasRelativePath = tableInfo.some(col => col.name === 'relative_path');
// Add hierarchical storage support columns
if (!hasProjectRoot) {
console.log('[Memory Store] Migrating database: adding project_root column for hierarchical storage...');
this.db.exec(`
ALTER TABLE conversations ADD COLUMN project_root TEXT;
`);
try {
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_project_root ON conversations(project_root);`);
} catch (indexErr) {
console.warn('[Memory Store] Project root index creation warning:', (indexErr as Error).message);
}
console.log('[Memory Store] Migration complete: project_root column added');
}
if (!hasRelativePath) {
console.log('[Memory Store] Migrating database: adding relative_path column for hierarchical storage...');
this.db.exec(`
ALTER TABLE conversations ADD COLUMN relative_path TEXT;
`);
console.log('[Memory Store] Migration complete: relative_path column added');
}
} catch (err) {
console.error('[Memory Store] Migration error:', (err as Error).message);
// Don't throw - allow the store to continue working with existing schema
}
}
/**
* Upsert an entity
*/
@@ -677,17 +715,21 @@ export class MemoryStore {
}
}
// Singleton instance cache
// Singleton instance cache - keyed by normalized project ID for consistency
const storeCache = new Map<string, MemoryStore>();
/**
* Get or create a store instance for a project
* Uses normalized project ID as cache key to handle path casing differences
*/
export function getMemoryStore(projectPath: string): MemoryStore {
if (!storeCache.has(projectPath)) {
storeCache.set(projectPath, new MemoryStore(projectPath));
// Use getProjectId to normalize path for consistent cache key
const cacheKey = getProjectId(projectPath);
if (!storeCache.has(cacheKey)) {
storeCache.set(cacheKey, new MemoryStore(projectPath));
}
return storeCache.get(projectPath)!;
return storeCache.get(cacheKey)!;
}
/**

View File

@@ -252,6 +252,82 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
return true;
}
// API: CodexLens Search (FTS5 text search)
if (pathname === '/api/codexlens/search') {
const query = url.searchParams.get('query') || '';
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const projectPath = url.searchParams.get('path') || initialPath;
if (!query) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Query parameter is required' }));
return true;
}
try {
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--json'];
const result = await executeCodexLens(args, { cwd: projectPath });
if (result.success) {
try {
const parsed = JSON.parse(result.output);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, ...parsed.result }));
} catch {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, results: [], output: result.output }));
}
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: result.error }));
}
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err.message }));
}
return true;
}
// API: CodexLens Search Files Only (return file paths only)
if (pathname === '/api/codexlens/search_files') {
const query = url.searchParams.get('query') || '';
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const projectPath = url.searchParams.get('path') || initialPath;
if (!query) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Query parameter is required' }));
return true;
}
try {
const args = ['search', query, '--path', projectPath, '--limit', limit.toString(), '--files-only', '--json'];
const result = await executeCodexLens(args, { cwd: projectPath });
if (result.success) {
try {
const parsed = JSON.parse(result.output);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, ...parsed.result }));
} catch {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, files: [], output: result.output }));
}
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: result.error }));
}
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err.message }));
}
return true;
}
// API: CodexLens Semantic Search Install (fastembed, ONNX-based, ~200MB)
if (pathname === '/api/codexlens/semantic/install' && req.method === 'POST') {
handlePostRequest(req, res, async () => {

View File

@@ -1,12 +1,10 @@
// @ts-nocheck
/**
* Graph Routes Module
* Handles graph visualization API endpoints for codex-lens data
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { executeCodexLens } from '../../tools/codex-lens.js';
import { homedir } from 'os';
import { join } from 'path';
import { join, resolve, normalize } from 'path';
import { existsSync } from 'fs';
import Database from 'better-sqlite3';
@@ -82,6 +80,34 @@ interface ImpactAnalysis {
affectedFiles: string[];
}
/**
* Validate and sanitize project path to prevent path traversal attacks
* @returns sanitized absolute path or null if invalid
*/
function validateProjectPath(projectPath: string, initialPath: string): string | null {
if (!projectPath) {
return initialPath;
}
// Resolve to absolute path
const resolved = resolve(projectPath);
const normalized = normalize(resolved);
// Check for path traversal attempts
if (normalized.includes('..') || normalized !== resolved) {
console.error(`[Graph] Path traversal attempt blocked: ${projectPath}`);
return null;
}
// Ensure path exists and is a directory
if (!existsSync(normalized)) {
console.error(`[Graph] Path does not exist: ${normalized}`);
return null;
}
return normalized;
}
/**
* Map codex-lens symbol kinds to graph node types
*/
@@ -151,7 +177,8 @@ async function querySymbols(projectPath: string): Promise<GraphNode[]> {
tokenCount: row.token_count || undefined,
}));
} catch (err) {
console.error(`[Graph] Failed to query symbols: ${err.message}`);
const message = err instanceof Error ? err.message : String(err);
console.error(`[Graph] Failed to query symbols: ${message}`);
return [];
}
}
@@ -194,11 +221,48 @@ async function queryRelationships(projectPath: string): Promise<GraphEdge[]> {
sourceFile: row.source_file,
}));
} catch (err) {
console.error(`[Graph] Failed to query relationships: ${err.message}`);
const message = err instanceof Error ? err.message : String(err);
console.error(`[Graph] Failed to query relationships: ${message}`);
return [];
}
}
/**
* Sanitize a string for use in SQL LIKE patterns
* Escapes special characters: %, _, [, ]
*/
function sanitizeForLike(input: string): string {
return input
.replace(/\[/g, '[[]') // Escape [ first
.replace(/%/g, '[%]') // Escape %
.replace(/_/g, '[_]'); // Escape _
}
/**
* Validate and parse symbol ID format
* Expected format: file:name:line or just symbolName
* @returns sanitized symbol name or null if invalid
*/
function parseSymbolId(symbolId: string): string | null {
if (!symbolId || symbolId.length > 500) {
return null;
}
// Remove any potentially dangerous characters
const sanitized = symbolId.replace(/[<>'";&|`$\\]/g, '');
// Parse the format: file:name:line
const parts = sanitized.split(':');
if (parts.length >= 2) {
// Return the name part (second element)
const name = parts[1].trim();
return name.length > 0 ? name : null;
}
// If no colons, use the whole string as name
return sanitized.trim() || null;
}
/**
* Perform impact analysis for a symbol
* Find all symbols that depend on this symbol (direct and transitive)
@@ -211,12 +275,18 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
return { directDependents: [], affectedFiles: [] };
}
// Parse and validate symbol ID
const symbolName = parseSymbolId(symbolId);
if (!symbolName) {
console.error(`[Graph] Invalid symbol ID format: ${symbolId}`);
return { directDependents: [], affectedFiles: [] };
}
try {
const db = Database(dbPath, { readonly: true });
// Parse symbolId to extract symbol name
const parts = symbolId.split(':');
const symbolName = parts.length >= 2 ? parts[1] : symbolId;
// Sanitize for LIKE query to prevent injection via special characters
const sanitizedName = sanitizeForLike(symbolName);
// Find all symbols that reference this symbol
const rows = db.prepare(`
@@ -228,7 +298,7 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
JOIN symbols s ON r.source_symbol_id = s.id
JOIN files f ON s.file_id = f.id
WHERE r.target_qualified_name LIKE ?
`).all(`%${symbolName}%`);
`).all(`%${sanitizedName}%`);
db.close();
@@ -243,7 +313,8 @@ async function analyzeImpact(projectPath: string, symbolId: string): Promise<Imp
affectedFiles,
};
} catch (err) {
console.error(`[Graph] Failed to analyze impact: ${err.message}`);
const message = err instanceof Error ? err.message : String(err);
console.error(`[Graph] Failed to analyze impact: ${message}`);
return { directDependents: [], affectedFiles: [] };
}
}
@@ -257,42 +328,65 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
// API: Graph Nodes - Get all symbols as graph nodes
if (pathname === '/api/graph/nodes') {
const projectPath = url.searchParams.get('path') || initialPath;
const rawPath = url.searchParams.get('path') || initialPath;
const projectPath = validateProjectPath(rawPath, initialPath);
if (!projectPath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid project path', nodes: [] }));
return true;
}
try {
const nodes = await querySymbols(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ nodes }));
} catch (err) {
console.error(`[Graph] Error fetching nodes:`, err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, nodes: [] }));
res.end(JSON.stringify({ error: 'Failed to fetch graph nodes', nodes: [] }));
}
return true;
}
// API: Graph Edges - Get all relationships as graph edges
if (pathname === '/api/graph/edges') {
const projectPath = url.searchParams.get('path') || initialPath;
const rawPath = url.searchParams.get('path') || initialPath;
const projectPath = validateProjectPath(rawPath, initialPath);
if (!projectPath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid project path', edges: [] }));
return true;
}
try {
const edges = await queryRelationships(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ edges }));
} catch (err) {
console.error(`[Graph] Error fetching edges:`, err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, edges: [] }));
res.end(JSON.stringify({ error: 'Failed to fetch graph edges', edges: [] }));
}
return true;
}
// API: Impact Analysis - Get impact analysis for a symbol
if (pathname === '/api/graph/impact') {
const projectPath = url.searchParams.get('path') || initialPath;
const rawPath = url.searchParams.get('path') || initialPath;
const projectPath = validateProjectPath(rawPath, initialPath);
const symbolId = url.searchParams.get('symbol');
if (!projectPath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid project path', directDependents: [], affectedFiles: [] }));
return true;
}
if (!symbolId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'symbol parameter is required' }));
res.end(JSON.stringify({ error: 'symbol parameter is required', directDependents: [], affectedFiles: [] }));
return true;
}
@@ -301,9 +395,10 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(impact));
} catch (err) {
console.error(`[Graph] Error analyzing impact:`, err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: err.message,
error: 'Failed to analyze impact',
directDependents: [],
affectedFiles: []
}));
@@ -311,5 +406,26 @@ export async function handleGraphRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Search Process - Get search pipeline visualization data (placeholder)
if (pathname === '/api/graph/search-process') {
// This endpoint returns mock data for the search process visualization
// In a real implementation, this would integrate with codex-lens search pipeline
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
stages: [
{ id: 1, name: 'Query Parsing', duration: 0, status: 'pending' },
{ id: 2, name: 'Vector Search', duration: 0, status: 'pending' },
{ id: 3, name: 'Graph Enrichment', duration: 0, status: 'pending' },
{ id: 4, name: 'Chunk Hierarchy', duration: 0, status: 'pending' },
{ id: 5, name: 'Result Ranking', duration: 0, status: 'pending' }
],
chunks: [],
callers: [],
callees: [],
message: 'Search process visualization requires an active search query'
}));
return true;
}
return false;
}

View File

@@ -379,7 +379,9 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
tool,
prompt: analysisPrompt,
mode: 'analysis',
timeout: 120000
timeout: 120000,
cd: projectPath,
category: 'insights'
});
// Try to parse JSON from response
@@ -521,8 +523,9 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
filtered = hotEntities.filter((e: any) => new Date(e.last_seen_at) >= weekAgo);
}
// Separate into mostRead and mostEdited
// Separate into mostRead, mostEdited, and mostMentioned
const fileEntities = filtered.filter((e: any) => e.type === 'file');
const topicEntities = filtered.filter((e: any) => e.type === 'topic');
const mostRead = fileEntities
.filter((e: any) => e.stats.read_count > 0)
@@ -548,11 +551,23 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
lastSeen: e.last_seen_at
}));
const mostMentioned = topicEntities
.filter((e: any) => e.stats.mention_count > 0)
.sort((a: any, b: any) => b.stats.mention_count - a.stats.mention_count)
.slice(0, limit)
.map((e: any) => ({
topic: e.value,
preview: e.value.substring(0, 100) + (e.value.length > 100 ? '...' : ''),
heat: e.stats.mention_count,
count: e.stats.mention_count,
lastSeen: e.last_seen_at
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ stats: { mostRead, mostEdited } }));
res.end(JSON.stringify({ stats: { mostRead, mostEdited, mostMentioned } }));
} catch (error: unknown) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ stats: { mostRead: [], mostEdited: [] } }));
res.end(JSON.stringify({ stats: { mostRead: [], mostEdited: [], mostMentioned: [] } }));
}
return true;
}

View File

@@ -62,7 +62,8 @@ const MODULE_CSS_FILES = [
'11-memory.css',
'11-prompt-history.css',
'12-skills-rules.css',
'13-claude-manager.css'
'13-claude-manager.css',
'14-graph-explorer.css'
];
// Modular JS files in dependency order
@@ -109,6 +110,7 @@ const MODULE_FILES = [
'views/skills-manager.js',
'views/rules-manager.js',
'views/claude-manager.js',
'views/graph-explorer.js',
'main.js'
];