mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
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:
@@ -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'
|
||||
|
||||
@@ -61,6 +61,7 @@ const MODULE_FILES = [
|
||||
'views/mcp-manager.js',
|
||||
'views/hook-manager.js',
|
||||
'views/history.js',
|
||||
'views/graph-explorer.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
|
||||
@@ -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)!;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user