From 958cf290e278bcb72d500065eefdf6c89e9126e0 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 14 Dec 2025 09:48:02 +0800 Subject: [PATCH] feat: add insights history feature with UI and backend support - Implemented insights history section in the dashboard with cards displaying analysis results. - Added CSS styles for insights cards, detail panel, and empty state. - Enhanced i18n support for insights-related strings in English and Chinese. - Developed backend methods for saving, retrieving, and deleting insights in the CLI history store. - Integrated insights loading and rendering logic in the memory view. - Added functionality to refresh insights and handle detail view interactions. --- .claude/rules/active_memory.md | 13 + .claude/rules/active_memory_config.json | 4 + ccw/src/core/server.ts | 274 ++++++---- ccw/src/templates/dashboard-css/11-memory.css | 473 ++++++++++++++++++ ccw/src/templates/dashboard-js/i18n.js | 20 + .../templates/dashboard-js/views/memory.js | 223 ++++++++- ccw/src/tools/cli-history-store.ts | 119 +++++ 7 files changed, 1034 insertions(+), 92 deletions(-) create mode 100644 .claude/rules/active_memory.md create mode 100644 .claude/rules/active_memory_config.json diff --git a/.claude/rules/active_memory.md b/.claude/rules/active_memory.md new file mode 100644 index 00000000..ad883a3d --- /dev/null +++ b/.claude/rules/active_memory.md @@ -0,0 +1,13 @@ +# Active Memory + +> Auto-generated understanding of frequently accessed files using GEMINI. +> Last updated: 2025-12-13T15:15:52.148Z +> Files analyzed: 10 +> CLI Tool: gemini + +--- + +[object Object] + +--- + diff --git a/.claude/rules/active_memory_config.json b/.claude/rules/active_memory_config.json new file mode 100644 index 00000000..f1b9cd57 --- /dev/null +++ b/.claude/rules/active_memory_config.json @@ -0,0 +1,4 @@ +{ + "interval": "manual", + "tool": "gemini" +} \ No newline at end of file diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 692418b7..e7bc883e 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -1,8 +1,8 @@ // @ts-nocheck import http from 'http'; import { URL } from 'url'; -import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync, promises as fsPromises } from 'fs'; -import { join, dirname } from 'path'; +import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync, unlinkSync, promises as fsPromises } from 'fs'; +import { join, dirname, isAbsolute, extname } from 'path'; import { homedir } from 'os'; import { createHash } from 'crypto'; import { scanSessions } from './session-scanner.js'; @@ -1229,6 +1229,26 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p } } + // Save insight to database + try { + const storeModule = await import('../tools/cli-history-store.js'); + const store = storeModule.getHistoryStore(projectPath); + const insightId = `insight-${Date.now()}`; + store.saveInsight({ + id: insightId, + tool, + promptCount: prompts.length, + patterns: insights.patterns, + suggestions: insights.suggestions, + rawOutput: result.stdout || '', + executionId: result.execution?.id, + lang + }); + console.log('[Insights] Saved insight:', insightId); + } catch (saveErr) { + console.warn('[Insights] Failed to save insight:', (saveErr as Error).message); + } + return { success: true, insights, @@ -1242,6 +1262,73 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p return; } + // API: Get insights history + if (pathname === '/api/memory/insights') { + const projectPath = url.searchParams.get('path') || initialPath; + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + const tool = url.searchParams.get('tool') || undefined; + + try { + const storeModule = await import('../tools/cli-history-store.js'); + const store = storeModule.getHistoryStore(projectPath); + const insights = store.getInsights({ limit, tool }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, insights })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Get single insight detail + if (pathname.startsWith('/api/memory/insights/') && req.method === 'GET') { + const insightId = pathname.replace('/api/memory/insights/', ''); + const projectPath = url.searchParams.get('path') || initialPath; + + if (!insightId || insightId === 'analyze') { + // Skip - handled by other routes + } else { + try { + const storeModule = await import('../tools/cli-history-store.js'); + const store = storeModule.getHistoryStore(projectPath); + const insight = store.getInsight(insightId); + + if (insight) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, insight })); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Insight not found' })); + } + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + } + + // API: Delete insight + if (pathname.startsWith('/api/memory/insights/') && req.method === 'DELETE') { + const insightId = pathname.replace('/api/memory/insights/', ''); + const projectPath = url.searchParams.get('path') || initialPath; + + try { + const storeModule = await import('../tools/cli-history-store.js'); + const store = storeModule.getHistoryStore(projectPath); + const deleted = store.deleteInsight(insightId); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: deleted })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + // API: Memory Module - Get hotspot statistics if (pathname === '/api/memory/stats') { const projectPath = url.searchParams.get('path') || initialPath; @@ -1469,26 +1556,32 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p if (pathname === '/api/memory/active/status') { const projectPath = url.searchParams.get('path') || initialPath; + if (!projectPath) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ enabled: false, status: null, config: { interval: 'manual', tool: 'gemini' } })); + return; + } + try { - const configPath = path.join(projectPath, '.claude', 'rules', 'active_memory.md'); - const configJsonPath = path.join(projectPath, '.claude', 'rules', 'active_memory_config.json'); - const enabled = fs.existsSync(configPath); + const configPath = join(projectPath, '.claude', 'rules', 'active_memory.md'); + const configJsonPath = join(projectPath, '.claude', 'rules', 'active_memory_config.json'); + const enabled = existsSync(configPath); let lastSync: string | null = null; let fileCount = 0; let config = { interval: 'manual', tool: 'gemini' }; if (enabled) { - const stats = fs.statSync(configPath); + const stats = statSync(configPath); lastSync = stats.mtime.toISOString(); - const content = fs.readFileSync(configPath, 'utf-8'); + const content = readFileSync(configPath, 'utf-8'); // Count file sections fileCount = (content.match(/^## /gm) || []).length; } // Load config if exists - if (fs.existsSync(configJsonPath)) { + if (existsSync(configJsonPath)) { try { - config = JSON.parse(fs.readFileSync(configJsonPath, 'utf-8')); + config = JSON.parse(readFileSync(configJsonPath, 'utf-8')); } catch (e) { /* ignore parse errors */ } } @@ -1499,6 +1592,7 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p config })); } catch (error: unknown) { + console.error('Active Memory status error:', error); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ enabled: false, status: null, config: { interval: 'manual', tool: 'gemini' } })); } @@ -1513,19 +1607,26 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p try { const { enabled, config } = JSON.parse(body || '{}'); const projectPath = initialPath; - const rulesDir = path.join(projectPath, '.claude', 'rules'); - const configPath = path.join(rulesDir, 'active_memory.md'); - const configJsonPath = path.join(rulesDir, 'active_memory_config.json'); + + if (!projectPath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'No project path configured' })); + return; + } + + const rulesDir = join(projectPath, '.claude', 'rules'); + const configPath = join(rulesDir, 'active_memory.md'); + const configJsonPath = join(rulesDir, 'active_memory_config.json'); if (enabled) { // Enable: Create directory and initial file - if (!fs.existsSync(rulesDir)) { - fs.mkdirSync(rulesDir, { recursive: true }); + if (!existsSync(rulesDir)) { + mkdirSync(rulesDir, { recursive: true }); } // Save config if (config) { - fs.writeFileSync(configJsonPath, JSON.stringify(config, null, 2), 'utf-8'); + writeFileSync(configJsonPath, JSON.stringify(config, null, 2), 'utf-8'); } // Create initial active_memory.md with header @@ -1538,25 +1639,28 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p *No files analyzed yet. Click "Sync Now" to analyze hot files.* `; - fs.writeFileSync(configPath, initialContent, 'utf-8'); + writeFileSync(configPath, initialContent, 'utf-8'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ enabled: true, message: 'Active Memory enabled' })); } else { // Disable: Remove the files - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath); + if (existsSync(configPath)) { + unlinkSync(configPath); } - if (fs.existsSync(configJsonPath)) { - fs.unlinkSync(configJsonPath); + if (existsSync(configJsonPath)) { + unlinkSync(configJsonPath); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ enabled: false, message: 'Active Memory disabled' })); } } catch (error: unknown) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: (error as Error).message })); + console.error('Active Memory toggle error:', error); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } } }); return; @@ -1570,14 +1674,14 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p try { const { config } = JSON.parse(body || '{}'); const projectPath = initialPath; - const rulesDir = path.join(projectPath, '.claude', 'rules'); - const configJsonPath = path.join(rulesDir, 'active_memory_config.json'); + const rulesDir = join(projectPath, '.claude', 'rules'); + const configJsonPath = join(rulesDir, 'active_memory_config.json'); - if (!fs.existsSync(rulesDir)) { - fs.mkdirSync(rulesDir, { recursive: true }); + if (!existsSync(rulesDir)) { + mkdirSync(rulesDir, { recursive: true }); } - fs.writeFileSync(configJsonPath, JSON.stringify(config, null, 2), 'utf-8'); + writeFileSync(configJsonPath, JSON.stringify(config, null, 2), 'utf-8'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, config })); @@ -1597,21 +1701,33 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p try { const { tool = 'gemini' } = JSON.parse(body || '{}'); const projectPath = initialPath; - const rulesDir = path.join(projectPath, '.claude', 'rules'); - const configPath = path.join(rulesDir, 'active_memory.md'); - // Get hot files from memory store - const memoryStore = getMemoryStore(projectPath); - const hotEntities = memoryStore.getHotEntities(20); - const hotFiles = hotEntities - .filter((e: any) => e.type === 'file') - .slice(0, 10); // Limit to top 10 files + if (!projectPath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'No project path configured' })); + return; + } + + const rulesDir = join(projectPath, '.claude', 'rules'); + const configPath = join(rulesDir, 'active_memory.md'); + + // Get hot files from memory store - with fallback + let hotFiles: any[] = []; + try { + const memoryStore = getMemoryStore(projectPath); + const hotEntities = memoryStore.getHotEntities(20); + hotFiles = hotEntities + .filter((e: any) => e.type === 'file') + .slice(0, 10); + } catch (memErr) { + console.warn('[Active Memory] Memory store error, using empty list:', (memErr as Error).message); + } // Build file list for CLI analysis const filePaths = hotFiles.map((f: any) => { const filePath = f.value; - return path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath); - }).filter((p: string) => fs.existsSync(p)); + return isAbsolute(filePath) ? filePath : join(projectPath, filePath); + }).filter((p: string) => existsSync(p)); // Build the active memory content header let content = `# Active Memory @@ -1625,11 +1741,10 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p `; - // Use CLI to analyze files if available - const { spawn } = require('child_process'); + // Use CCW CLI tool to analyze files let cliOutput = ''; - // Build CLI command based on tool + // Build CLI prompt const cliPrompt = `PURPOSE: Analyze the following hot files and provide a concise understanding of each. TASK: For each file, describe its purpose, key exports, dependencies, and how it relates to other files. MODE: analysis @@ -1637,50 +1752,24 @@ CONTEXT: ${filePaths.map((p: string) => '@' + p).join(' ')} EXPECTED: Markdown format with ## headings for each file, bullet points for key information. RULES: Be concise. Focus on practical understanding. Include function signatures for key exports.`; - const cliCmd = tool === 'qwen' ? 'qwen' : 'gemini'; - const cliArgs = ['-p', cliPrompt]; - - // Try to execute CLI + // Try to execute CLI using CCW's built-in executor try { - const cliProcess = spawn(cliCmd, cliArgs, { - cwd: projectPath, - shell: true, - timeout: 120000 // 2 minute timeout + const syncId = `active-memory-${Date.now()}`; + const result = await executeCliTool({ + tool: tool === 'qwen' ? 'qwen' : 'gemini', + prompt: cliPrompt, + mode: 'analysis', + format: 'plain', + cd: projectPath, + timeout: 120000, + stream: false, + category: 'internal', + id: syncId }); - cliOutput = await new Promise((resolve, reject) => { - let output = ''; - let errorOutput = ''; - - cliProcess.stdout?.on('data', (data: Buffer) => { - output += data.toString(); - }); - - cliProcess.stderr?.on('data', (data: Buffer) => { - errorOutput += data.toString(); - }); - - cliProcess.on('close', (code: number) => { - if (code === 0 || output.length > 100) { - resolve(output); - } else { - reject(new Error(errorOutput || 'CLI execution failed')); - } - }); - - cliProcess.on('error', (err: Error) => { - reject(err); - }); - - // Timeout fallback - setTimeout(() => { - if (output.length > 0) { - resolve(output); - } else { - reject(new Error('CLI timeout')); - } - }, 120000); - }); + if (result.success && result.execution?.output) { + cliOutput = result.execution.output; + } // Add CLI output to content content += cliOutput + '\n\n---\n\n'; @@ -1708,18 +1797,18 @@ RULES: Be concise. Focus on practical understanding. Include function signatures // Try to read file and generate summary try { - const fullPath = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath); + const fullPath = isAbsolute(filePath) ? filePath : join(projectPath, filePath); - if (fs.existsSync(fullPath)) { - const stat = fs.statSync(fullPath); - const ext = path.extname(fullPath).toLowerCase(); + if (existsSync(fullPath)) { + const stat = statSync(fullPath); + const ext = extname(fullPath).toLowerCase(); content += `- **Size**: ${(stat.size / 1024).toFixed(1)} KB\n`; content += `- **Type**: ${ext || 'unknown'}\n`; const textExts = ['.ts', '.js', '.tsx', '.jsx', '.md', '.json', '.css', '.html', '.vue', '.svelte', '.py', '.go', '.rs']; if (textExts.includes(ext) && stat.size < 100000) { - const fileContent = fs.readFileSync(fullPath, 'utf-8'); + const fileContent = readFileSync(fullPath, 'utf-8'); const lines = fileContent.split('\n').slice(0, 30); const exports = lines.filter(l => @@ -1741,12 +1830,12 @@ RULES: Be concise. Focus on practical understanding. Include function signatures } // Ensure directory exists - if (!fs.existsSync(rulesDir)) { - fs.mkdirSync(rulesDir, { recursive: true }); + if (!existsSync(rulesDir)) { + mkdirSync(rulesDir, { recursive: true }); } // Write the file - fs.writeFileSync(configPath, content, 'utf-8'); + writeFileSync(configPath, content, 'utf-8'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -1756,8 +1845,11 @@ RULES: Be concise. Focus on practical understanding. Include function signatures usedCli: cliOutput.length > 0 })); } catch (error: unknown) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: (error as Error).message })); + console.error('[Active Memory] Sync error:', error); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } } }); return; diff --git a/ccw/src/templates/dashboard-css/11-memory.css b/ccw/src/templates/dashboard-css/11-memory.css index eb565df0..f41cb50e 100644 --- a/ccw/src/templates/dashboard-css/11-memory.css +++ b/ccw/src/templates/dashboard-css/11-memory.css @@ -1689,6 +1689,471 @@ max-width: 300px; } +/* ======================================== + * Insights History Cards + * ======================================== */ +.memory-insights-section { + margin-top: 1.5rem; +} + +.insights-section { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.insights-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.insights-section-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.insights-section-header h3 i { + color: hsl(var(--primary)); +} + +.insights-section-header .section-count { + font-size: 0.6875rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted)); + padding: 0.125rem 0.5rem; + border-radius: 0.75rem; + margin-left: 0.5rem; +} + +.insights-section-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Insights Cards Container */ +.insights-cards-container { + padding: 1rem; +} + +.insights-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.75rem; +} + +/* Individual Insight Card */ +.insight-card { + display: flex; + flex-direction: column; + padding: 0.875rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + border-left: 3px solid hsl(var(--border)); +} + +.insight-card:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.08); + transform: translateY(-1px); +} + +/* Severity-based colors */ +.insight-card.high { + border-left-color: hsl(0 84% 60%); +} + +.insight-card.high:hover { + border-left-color: hsl(0 84% 50%); +} + +.insight-card.medium { + border-left-color: hsl(38 92% 50%); +} + +.insight-card.medium:hover { + border-left-color: hsl(38 92% 45%); +} + +.insight-card.low { + border-left-color: hsl(142 76% 36%); +} + +.insight-card.low:hover { + border-left-color: hsl(142 76% 30%); +} + +/* Insight Card Header */ +.insight-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.625rem; +} + +.insight-card-tool { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.insight-card-tool i { + color: hsl(var(--primary)); + font-size: 0.875rem; +} + +.insight-card-time { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +/* Insight Card Stats */ +.insight-card-stats { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.625rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.insight-stat { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.insight-stat i { + font-size: 0.75rem; +} + +.insight-stat span { + font-weight: 500; +} + +/* Insight Card Preview */ +.insight-card-preview { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + line-height: 1.5; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.insight-card-preview .preview-item { + display: flex; + align-items: flex-start; + gap: 0.375rem; + margin-bottom: 0.25rem; +} + +.insight-card-preview .preview-item i { + color: hsl(var(--primary)); + font-size: 0.6875rem; + margin-top: 0.25rem; + flex-shrink: 0; +} + +.insight-card-preview .preview-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Insight Detail Panel */ +.insight-detail-panel { + position: fixed; + top: 0; + right: 0; + width: 480px; + max-width: 100vw; + height: 100vh; + background: hsl(var(--card)); + border-left: 1px solid hsl(var(--border)); + box-shadow: -4px 0 24px hsl(var(--foreground) / 0.1); + z-index: 1000; + display: flex; + flex-direction: column; + animation: slideInRight 0.3s ease; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.insight-detail { + display: flex; + flex-direction: column; + height: 100%; +} + +.insight-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.insight-detail-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.insight-detail-header h3 i { + color: hsl(var(--primary)); +} + +.insight-detail-close { + padding: 0.5rem; + background: transparent; + border: none; + color: hsl(var(--muted-foreground)); + cursor: pointer; + border-radius: 0.375rem; + transition: all 0.15s ease; +} + +.insight-detail-close:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} + +.insight-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--background)); +} + +.meta-item { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.meta-item i { + color: hsl(var(--primary)); + font-size: 0.875rem; +} + +.meta-item span { + font-weight: 500; + color: hsl(var(--foreground)); +} + +.insight-detail-content { + flex: 1; + overflow-y: auto; + padding: 1.25rem; +} + +.insight-detail-section { + margin-bottom: 1.5rem; +} + +.insight-detail-section:last-child { + margin-bottom: 0; +} + +.insight-detail-section h4 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0 0 0.75rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.insight-detail-section h4 i { + color: hsl(var(--primary)); +} + +/* Pattern and Suggestion Items */ +.pattern-item, +.suggestion-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + margin-bottom: 0.5rem; + transition: all 0.15s ease; +} + +.pattern-item:hover, +.suggestion-item:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); +} + +.pattern-item:last-child, +.suggestion-item:last-child { + margin-bottom: 0; +} + +.item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + flex-shrink: 0; +} + +.pattern-item .item-icon { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); +} + +.suggestion-item .item-icon { + background: hsl(142 76% 36% / 0.1); + color: hsl(142 76% 36%); +} + +.item-content { + flex: 1; + min-width: 0; +} + +.item-title { + font-size: 0.8125rem; + font-weight: 500; + color: hsl(var(--foreground)); + margin-bottom: 0.25rem; +} + +.item-description { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +} + +.item-example { + margin-top: 0.5rem; + padding: 0.5rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.25rem; + font-family: var(--font-mono); + font-size: 0.6875rem; + color: hsl(var(--foreground)); + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Detail Actions */ +.insight-detail-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.2); +} + +.insight-detail-actions button { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + font-size: 0.8125rem; + font-weight: 500; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-delete-insight { + background: hsl(0 84% 60% / 0.1); + border: 1px solid hsl(0 84% 60% / 0.3); + color: hsl(0 84% 60%); +} + +.btn-delete-insight:hover { + background: hsl(0 84% 60% / 0.2); + border-color: hsl(0 84% 60%); +} + +.btn-close-insight { + background: hsl(var(--muted)); + border: 1px solid hsl(var(--border)); + color: hsl(var(--foreground)); +} + +.btn-close-insight:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); +} + +/* Empty Insights State */ +.insights-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + color: hsl(var(--muted-foreground)); +} + +.insights-empty i { + font-size: 2.5rem; + opacity: 0.3; + margin-bottom: 0.75rem; +} + +.insights-empty p { + font-size: 0.875rem; + margin: 0; +} + /* ======================================== * Responsive Adjustments * ======================================== */ @@ -1704,4 +2169,12 @@ .insights-grid { grid-template-columns: 1fr; } + + .insights-cards { + grid-template-columns: 1fr; + } + + .insight-detail-panel { + width: 100%; + } } diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index f1b64e35..20daf01e 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -613,6 +613,16 @@ const i18n = { 'memory.autoSyncActive': 'Auto-sync', 'memory.configUpdated': 'Configuration updated', 'memory.configError': 'Failed to update configuration', + 'memory.insightsHistory': 'Insights History', + 'memory.insightsEmpty': 'No insights yet. Run an analysis to generate insights.', + 'memory.insightPatterns': 'Patterns', + 'memory.insightSuggestions': 'Suggestions', + 'memory.insightDetail': 'Insight Detail', + 'memory.insightDelete': 'Delete', + 'memory.insightDeleteConfirm': 'Are you sure you want to delete this insight?', + 'memory.insightDeleted': 'Insight deleted', + 'memory.prompts': 'prompts', + 'memory.refreshInsights': 'Refresh', // Common 'common.cancel': 'Cancel', @@ -1237,6 +1247,16 @@ const i18n = { 'memory.autoSyncActive': '自动同步', 'memory.configUpdated': '配置已更新', 'memory.configError': '配置更新失败', + 'memory.insightsHistory': '洞察历史', + 'memory.insightsEmpty': '暂无洞察记录。运行分析以生成洞察。', + 'memory.insightPatterns': '模式', + 'memory.insightSuggestions': '建议', + 'memory.insightDetail': '洞察详情', + 'memory.insightDelete': '删除', + 'memory.insightDeleteConfirm': '确定要删除此洞察吗?', + 'memory.insightDeleted': '洞察已删除', + 'memory.prompts': '提示', + 'memory.refreshInsights': '刷新', // Common 'common.cancel': '取消', diff --git a/ccw/src/templates/dashboard-js/views/memory.js b/ccw/src/templates/dashboard-js/views/memory.js index 887df54f..08559c1c 100644 --- a/ccw/src/templates/dashboard-js/views/memory.js +++ b/ccw/src/templates/dashboard-js/views/memory.js @@ -14,6 +14,8 @@ var activeMemoryConfig = { tool: 'gemini' // gemini, qwen }; var activeMemorySyncTimer = null; // Timer for automatic periodic sync +var insightsHistory = []; // Insights analysis history +var selectedInsight = null; // Currently selected insight for detail view // ========== Main Render Function ========== async function renderMemoryView() { @@ -37,7 +39,8 @@ async function renderMemoryView() { loadMemoryStats(), loadMemoryGraph(), loadRecentContext(), - loadActiveMemoryStatus() + loadActiveMemoryStatus(), + loadInsightsHistory() ]); // Render layout with Active Memory header @@ -55,12 +58,14 @@ async function renderMemoryView() { '
' + '
' + '' + + '
' + ''; // Render each column renderHotspotsColumn(); renderGraphColumn(); renderContextColumn(); + renderInsightsSection(); // Initialize Lucide icons if (window.lucide) lucide.createIcons(); @@ -159,6 +164,20 @@ async function loadRecentContext() { } } +async function loadInsightsHistory() { + try { + var response = await fetch('/api/memory/insights?limit=10'); + if (!response.ok) throw new Error('Failed to load insights history'); + var data = await response.json(); + insightsHistory = data.insights || []; + return insightsHistory; + } catch (err) { + console.error('Failed to load insights history:', err); + insightsHistory = []; + return []; + } +} + // ========== Active Memory Functions ========== // Timer management for automatic sync function startActiveMemorySyncTimer() { @@ -885,6 +904,208 @@ function showNodeDetails(node) { if (window.lucide) lucide.createIcons(); } +// ========== Insights Section ========== +function renderInsightsSection() { + var container = document.getElementById('memory-insights'); + if (!container) return; + + container.innerHTML = '
' + + '
' + + '
' + + '

' + t('memory.insightsHistory') + '

' + + '' + insightsHistory.length + ' ' + t('memory.analyses') + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + renderInsightsCards() + + '
' + + (selectedInsight ? '
' + renderInsightDetail(selectedInsight) + '
' : '') + + '
'; + + if (window.lucide) lucide.createIcons(); +} + +function renderInsightsCards() { + if (!insightsHistory || insightsHistory.length === 0) { + return '
' + + '' + + '

' + t('memory.noInsightsYet') + '

' + + '

' + t('memory.triggerAnalysis') + '

' + + '
'; + } + + return '
' + + insightsHistory.map(function(insight) { + var patternCount = (insight.patterns || []).length; + var suggestionCount = (insight.suggestions || []).length; + var severity = getInsightSeverity(insight.patterns); + var date = new Date(insight.created_at); + var timeAgo = formatTimestamp(insight.created_at); + + return '
' + + '
' + + '
' + + '' + + '' + insight.tool + '' + + '
' + + '
' + timeAgo + '
' + + '
' + + '
' + + '
' + + '' + patternCount + '' + + '' + t('memory.patterns') + '' + + '
' + + '
' + + '' + suggestionCount + '' + + '' + t('memory.suggestions') + '' + + '
' + + '
' + + '' + insight.prompt_count + '' + + '' + t('memory.prompts') + '' + + '
' + + '
' + + '
' + + (insight.patterns && insight.patterns.length > 0 ? + '
' + + '' + escapeHtml(insight.patterns[0].type || 'pattern') + '' + + '' + escapeHtml((insight.patterns[0].description || '').substring(0, 60)) + '...' + + '
' : '') + + '
' + + '
'; + }).join('') + + '
'; +} + +function getInsightSeverity(patterns) { + if (!patterns || patterns.length === 0) return 'low'; + var hasHigh = patterns.some(function(p) { return p.severity === 'high'; }); + var hasMedium = patterns.some(function(p) { return p.severity === 'medium'; }); + return hasHigh ? 'high' : (hasMedium ? 'medium' : 'low'); +} + +function getToolIcon(tool) { + switch(tool) { + case 'gemini': return 'sparkles'; + case 'qwen': return 'bot'; + case 'codex': return 'code-2'; + default: return 'cpu'; + } +} + +async function showInsightDetail(insightId) { + try { + var response = await fetch('/api/memory/insights/' + insightId); + if (!response.ok) throw new Error('Failed to load insight detail'); + var data = await response.json(); + selectedInsight = data.insight; + renderInsightsSection(); + } catch (err) { + console.error('Failed to load insight detail:', err); + if (window.showToast) { + showToast(t('memory.loadInsightError'), 'error'); + } + } +} + +function closeInsightDetail() { + selectedInsight = null; + renderInsightsSection(); +} + +function renderInsightDetail(insight) { + if (!insight) return ''; + + var html = '
' + + '
' + + '

' + t('memory.insightDetail') + '

' + + '' + + '
' + + '
' + + ' ' + insight.tool + '' + + ' ' + formatTimestamp(insight.created_at) + '' + + ' ' + insight.prompt_count + ' ' + t('memory.promptsAnalyzed') + '' + + '
'; + + // Patterns + if (insight.patterns && insight.patterns.length > 0) { + html += '
' + + '
' + t('memory.patternsFound') + ' (' + insight.patterns.length + ')
' + + '
' + + insight.patterns.map(function(p) { + return '
' + + '
' + + '' + escapeHtml(p.type || 'pattern') + '' + + '' + (p.severity || 'low') + '' + + (p.occurrences ? '' + p.occurrences + 'x' : '') + + '
' + + '
' + escapeHtml(p.description || '') + '
' + + (p.suggestion ? '
' + escapeHtml(p.suggestion) + '
' : '') + + '
'; + }).join('') + + '
' + + '
'; + } + + // Suggestions + if (insight.suggestions && insight.suggestions.length > 0) { + html += '
' + + '
' + t('memory.suggestionsProvided') + ' (' + insight.suggestions.length + ')
' + + '
' + + insight.suggestions.map(function(s) { + return '
' + + '
' + escapeHtml(s.title || '') + '
' + + '
' + escapeHtml(s.description || '') + '
' + + (s.example ? '
' + escapeHtml(s.example) + '
' : '') + + '
'; + }).join('') + + '
' + + '
'; + } + + html += '
' + + '' + + '
' + + '
'; + + return html; +} + +async function deleteInsight(insightId) { + if (!confirm(t('memory.confirmDeleteInsight'))) return; + + try { + var response = await fetch('/api/memory/insights/' + insightId, { method: 'DELETE' }); + if (!response.ok) throw new Error('Failed to delete insight'); + + selectedInsight = null; + await loadInsightsHistory(); + renderInsightsSection(); + + if (window.showToast) { + showToast(t('memory.insightDeleted'), 'success'); + } + } catch (err) { + console.error('Failed to delete insight:', err); + if (window.showToast) { + showToast(t('memory.deleteInsightError'), 'error'); + } + } +} + +async function refreshInsightsHistory() { + await loadInsightsHistory(); + renderInsightsSection(); +} + // ========== Actions ========== async function setMemoryTimeFilter(filter) { memoryTimeFilter = filter; diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index 8e2bc54a..8738a612 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -177,6 +177,22 @@ export class CliHistoryStore { -- Indexes for native session lookups CREATE INDEX IF NOT EXISTS idx_native_tool_session ON native_session_mapping(tool, native_session_id); CREATE INDEX IF NOT EXISTS idx_native_session_id ON native_session_mapping(native_session_id); + + -- Insights analysis history table + CREATE TABLE IF NOT EXISTS insights ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + tool TEXT NOT NULL, + prompt_count INTEGER DEFAULT 0, + patterns TEXT, + suggestions TEXT, + raw_output TEXT, + execution_id TEXT, + lang TEXT DEFAULT 'en' + ); + + CREATE INDEX IF NOT EXISTS idx_insights_created ON insights(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_insights_tool ON insights(tool); `); // Migration: Add category column if not exists (for existing databases) @@ -816,6 +832,109 @@ export class CliHistoryStore { }; } + // ========== Insights Methods ========== + + /** + * Save an insights analysis result + */ + saveInsight(insight: { + id: string; + tool: string; + promptCount: number; + patterns: any[]; + suggestions: any[]; + rawOutput?: string; + executionId?: string; + lang?: string; + }): void { + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO insights (id, created_at, tool, prompt_count, patterns, suggestions, raw_output, execution_id, lang) + VALUES (@id, @created_at, @tool, @prompt_count, @patterns, @suggestions, @raw_output, @execution_id, @lang) + `); + + stmt.run({ + id: insight.id, + created_at: new Date().toISOString(), + tool: insight.tool, + prompt_count: insight.promptCount, + patterns: JSON.stringify(insight.patterns || []), + suggestions: JSON.stringify(insight.suggestions || []), + raw_output: insight.rawOutput || null, + execution_id: insight.executionId || null, + lang: insight.lang || 'en' + }); + } + + /** + * Get insights history + */ + getInsights(options: { limit?: number; tool?: string } = {}): { + id: string; + created_at: string; + tool: string; + prompt_count: number; + patterns: any[]; + suggestions: any[]; + execution_id: string | null; + lang: string; + }[] { + const { limit = 20, tool } = options; + + let sql = 'SELECT id, created_at, tool, prompt_count, patterns, suggestions, execution_id, lang FROM insights'; + const params: any = {}; + + if (tool) { + sql += ' WHERE tool = @tool'; + params.tool = tool; + } + + sql += ' ORDER BY created_at DESC LIMIT @limit'; + params.limit = limit; + + const rows = this.db.prepare(sql).all(params) as any[]; + + return rows.map(row => ({ + ...row, + patterns: JSON.parse(row.patterns || '[]'), + suggestions: JSON.parse(row.suggestions || '[]') + })); + } + + /** + * Get a single insight by ID + */ + getInsight(id: string): { + id: string; + created_at: string; + tool: string; + prompt_count: number; + patterns: any[]; + suggestions: any[]; + raw_output: string | null; + execution_id: string | null; + lang: string; + } | null { + const row = this.db.prepare( + 'SELECT * FROM insights WHERE id = ?' + ).get(id) as any; + + if (!row) return null; + + return { + ...row, + patterns: JSON.parse(row.patterns || '[]'), + suggestions: JSON.parse(row.suggestions || '[]') + }; + } + + /** + * Delete an insight + */ + deleteInsight(id: string): boolean { + const result = this.db.prepare('DELETE FROM insights WHERE id = ?').run(id); + return result.changes > 0; + } + /** * Close database connection */