mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat: Add core memory clustering visualization and hooks configuration
- Implemented core memory clustering visualization in core-memory-clusters.js - Added functions for loading, rendering, and managing clusters and their members - Created example hooks configuration in hooks-config-example.json for session management - Developed test script for hooks integration in test-hooks.js - Included error handling and notifications for user interactions
This commit is contained in:
@@ -102,6 +102,70 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tab Navigation */
|
||||
.core-memory-tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cm-tab-panel {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.cm-tab-panel .memory-stats {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.core-memory-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -829,3 +893,609 @@
|
||||
[data-theme="dark"] .version-content-preview {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Session Clustering Styles
|
||||
============================================ */
|
||||
|
||||
.clusters-container {
|
||||
margin-top: 0;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.clusters-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.clusters-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Clusters Sidebar */
|
||||
.clusters-sidebar {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clusters-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.clusters-sidebar-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cluster-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Cluster Item */
|
||||
.cluster-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.cluster-item:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.cluster-item.active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.cluster-icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.cluster-icon i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.cluster-item.active .cluster-icon {
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.cluster-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cluster-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cluster-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Cluster Status Badges */
|
||||
.badge-active {
|
||||
background: hsl(142 76% 36% / 0.15);
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.badge-archived {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
color: hsl(38 92% 40%);
|
||||
}
|
||||
|
||||
/* Clusters Detail Panel */
|
||||
.clusters-detail {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cluster-detail-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0; /* Enable flexbox scrolling */
|
||||
}
|
||||
|
||||
.cluster-detail-content .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Cluster Detail View */
|
||||
.cluster-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.cluster-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cluster-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cluster-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cluster-description {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cluster-intent {
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cluster-intent strong {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Cluster Sections */
|
||||
.cluster-timeline,
|
||||
.cluster-relations {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.cluster-timeline h4,
|
||||
.cluster-relations h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cluster-timeline h4 i,
|
||||
.cluster-relations h4 i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Session Timeline */
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Override conflicting timeline-item styles from other CSS files */
|
||||
.cluster-timeline .timeline .timeline-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
/* Reset card-like appearance from other CSS */
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
/* Remove height constraints */
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cluster-timeline .timeline .timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.6875rem;
|
||||
top: 2rem;
|
||||
bottom: -1rem;
|
||||
width: 2px;
|
||||
background: hsl(var(--border));
|
||||
}
|
||||
|
||||
.cluster-timeline .timeline .timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-number {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--primary));
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-content:hover {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.timeline-content.expanded {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
background: hsl(var(--primary) / 0.02);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.session-id {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted));
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Session Type Badges */
|
||||
.badge-core_memory {
|
||||
background: hsl(260 70% 50% / 0.15);
|
||||
color: hsl(260 70% 50%);
|
||||
}
|
||||
|
||||
.badge-workflow {
|
||||
background: hsl(200 80% 50% / 0.15);
|
||||
color: hsl(200 80% 45%);
|
||||
}
|
||||
|
||||
.badge-cli_history {
|
||||
background: hsl(30 80% 50% / 0.15);
|
||||
color: hsl(30 80% 40%);
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-summary {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-tokens {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.session-tokens i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Expandable timeline card - DEPRECATED, use clickable instead */
|
||||
.timeline-content.expandable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Clickable timeline card */
|
||||
.timeline-content.clickable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-content.clickable:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
background: hsl(var(--primary) / 0.02);
|
||||
}
|
||||
|
||||
.timeline-content.clickable:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
/* Timeline card footer with preview hint */
|
||||
.timeline-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.preview-hint i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.timeline-content:hover .preview-hint {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* CSS Arrow for expand hint - DEPRECATED */
|
||||
.expand-arrow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0 0.25rem;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.timeline-content:hover .expand-arrow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-arrow::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-right: 2px solid hsl(var(--primary));
|
||||
border-bottom: 2px solid hsl(var(--primary));
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-content.expanded .expand-arrow::after {
|
||||
transform: rotate(-135deg) translateY(3px);
|
||||
}
|
||||
|
||||
/* Expanded detail section */
|
||||
.session-detail-expand {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.timeline-content.expanded .session-detail-expand {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.full-summary {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.session-detail-expand .session-tokens {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-xs i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost.btn-danger:hover {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive) / 0.3);
|
||||
}
|
||||
|
||||
/* Relations List */
|
||||
.relations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.relation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.relation-item i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.relation-type {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.relation-item a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.relation-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Dark Mode for Clusters */
|
||||
[data-theme="dark"] .clusters-sidebar,
|
||||
[data-theme="dark"] .clusters-detail {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tab-nav {
|
||||
background: rgba(51, 65, 85, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tab-btn.active {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .clusters-sidebar-header {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cluster-item:hover {
|
||||
background: rgba(51, 65, 85, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cluster-item.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-content,
|
||||
[data-theme="dark"] .cluster-intent,
|
||||
[data-theme="dark"] .relation-item {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,26 @@ const HOOK_TEMPLATES = {
|
||||
description: 'Record user prompts for pattern analysis',
|
||||
category: 'memory',
|
||||
timeout: 5000
|
||||
},
|
||||
// Session Context - Progressive Disclosure (session start - recent sessions)
|
||||
'session-context': {
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: '',
|
||||
command: 'bash',
|
||||
args: ['-c', 'curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"session-start\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'],
|
||||
description: 'Load recent sessions at session start (time-sorted)',
|
||||
category: 'context',
|
||||
timeout: 5000
|
||||
},
|
||||
// Session Context - Continuous Disclosure (intent matching on every prompt)
|
||||
'session-context-continuous': {
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: '',
|
||||
command: 'bash',
|
||||
args: ['-c', 'PROMPT=$(cat | jq -r ".prompt // empty"); curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"context\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\",\\"prompt\\":\\"$PROMPT\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'],
|
||||
description: 'Load intent-matched sessions on every prompt (similarity-based)',
|
||||
category: 'context',
|
||||
timeout: 5000
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,18 @@ const i18n = {
|
||||
// App title and brand
|
||||
'app.title': 'CCW Dashboard',
|
||||
'app.brand': 'Claude Code Workflow',
|
||||
|
||||
|
||||
// Common
|
||||
'common.view': 'View',
|
||||
'common.edit': 'Edit',
|
||||
'common.delete': 'Delete',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.close': 'Close',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.success': 'Success',
|
||||
|
||||
// Header
|
||||
'header.project': 'Project:',
|
||||
'header.recentProjects': 'Recent Projects',
|
||||
@@ -685,6 +696,10 @@ const i18n = {
|
||||
'hook.template.gitAddDesc': 'Auto stage written files',
|
||||
|
||||
// Hook Quick Install Templates
|
||||
'hook.tpl.sessionContext': 'Session Context (Start)',
|
||||
'hook.tpl.sessionContextDesc': 'Load recent sessions at session start (time-sorted)',
|
||||
'hook.tpl.sessionContextContinuous': 'Session Context (Continuous)',
|
||||
'hook.tpl.sessionContextContinuousDesc': 'Load intent-matched sessions on every prompt (similarity-based)',
|
||||
'hook.tpl.codexlensSync': 'CodexLens Auto-Sync',
|
||||
'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited',
|
||||
'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify',
|
||||
@@ -704,6 +719,7 @@ const i18n = {
|
||||
'hook.category.git': 'git',
|
||||
'hook.category.memory': 'memory',
|
||||
'hook.category.skill': 'skill',
|
||||
'hook.category.context': 'context',
|
||||
|
||||
// Hook Wizard Templates
|
||||
'hook.wizard.memoryUpdate': 'Memory Update Hook',
|
||||
@@ -1164,6 +1180,8 @@ const i18n = {
|
||||
'common.edit': 'Edit',
|
||||
'common.close': 'Close',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.refreshed': 'Refreshed',
|
||||
'common.refreshing': 'Refreshing...',
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.success': 'Success',
|
||||
@@ -1226,12 +1244,56 @@ const i18n = {
|
||||
'coreMemory.evolutionError': 'Failed to load evolution history',
|
||||
'coreMemory.created': 'Created',
|
||||
'coreMemory.updated': 'Updated',
|
||||
|
||||
// View toggle
|
||||
'coreMemory.memories': 'Memories',
|
||||
'coreMemory.clusters': 'Clusters',
|
||||
'coreMemory.clustersList': 'Cluster List',
|
||||
'coreMemory.selectCluster': 'Select a cluster to view details',
|
||||
'coreMemory.openSession': 'Open Session',
|
||||
'coreMemory.clickToPreview': 'Click to preview',
|
||||
'coreMemory.previewError': 'Failed to load preview',
|
||||
'coreMemory.unknownSessionType': 'Unknown session type',
|
||||
|
||||
// Clustering features
|
||||
'coreMemory.noClusters': 'No clusters yet',
|
||||
'coreMemory.autoCluster': 'Auto Cluster',
|
||||
'coreMemory.clusterLoadError': 'Failed to load clusters',
|
||||
'coreMemory.clusterDetailError': 'Failed to load cluster details',
|
||||
'coreMemory.intent': 'Intent',
|
||||
'coreMemory.sessionTimeline': 'Session Timeline',
|
||||
'coreMemory.relatedClusters': 'Related Clusters',
|
||||
'coreMemory.noSessions': 'No sessions in this cluster',
|
||||
'coreMemory.clusteringInProgress': 'Clustering in progress...',
|
||||
'coreMemory.clusteringComplete': 'Created {created} clusters with {sessions} sessions',
|
||||
'coreMemory.clusteringError': 'Auto-clustering failed',
|
||||
'coreMemory.enterClusterName': 'Enter cluster name:',
|
||||
'coreMemory.clusterCreated': 'Cluster created',
|
||||
'coreMemory.clusterCreateError': 'Failed to create cluster',
|
||||
'coreMemory.confirmDeleteCluster': 'Delete this cluster?',
|
||||
'coreMemory.clusterDeleted': 'Cluster deleted',
|
||||
'coreMemory.clusterDeleteError': 'Failed to delete cluster',
|
||||
'coreMemory.clusterUpdated': 'Cluster updated',
|
||||
'coreMemory.clusterUpdateError': 'Failed to update cluster',
|
||||
'coreMemory.memberRemoved': 'Member removed',
|
||||
'coreMemory.memberRemoveError': 'Failed to remove member',
|
||||
},
|
||||
|
||||
zh: {
|
||||
// App title and brand
|
||||
'app.title': 'CCW 控制面板',
|
||||
'app.brand': 'Claude Code Workflow',
|
||||
|
||||
// Common
|
||||
'common.view': '查看',
|
||||
'common.edit': '编辑',
|
||||
'common.delete': '删除',
|
||||
'common.cancel': '取消',
|
||||
'common.save': '保存',
|
||||
'common.close': '关闭',
|
||||
'common.loading': '加载中...',
|
||||
'common.error': '错误',
|
||||
'common.success': '成功',
|
||||
|
||||
// Header
|
||||
'header.project': '项目:',
|
||||
@@ -1883,6 +1945,10 @@ const i18n = {
|
||||
'hook.template.gitAddDesc': '自动暂存写入的文件',
|
||||
|
||||
// Hook Quick Install Templates
|
||||
'hook.tpl.sessionContext': 'Session 上下文(启动)',
|
||||
'hook.tpl.sessionContextDesc': '会话启动时加载最近会话(按时间排序)',
|
||||
'hook.tpl.sessionContextContinuous': 'Session 上下文(持续)',
|
||||
'hook.tpl.sessionContextContinuousDesc': '每次提示词时加载意图匹配会话(相似度排序)',
|
||||
'hook.tpl.codexlensSync': 'CodexLens 自动同步',
|
||||
'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引',
|
||||
'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知',
|
||||
@@ -1902,6 +1968,7 @@ const i18n = {
|
||||
'hook.category.git': 'Git',
|
||||
'hook.category.memory': '记忆',
|
||||
'hook.category.skill': '技能',
|
||||
'hook.category.context': '上下文',
|
||||
|
||||
// Hook Wizard Templates
|
||||
'hook.wizard.memoryUpdate': '记忆更新钩子',
|
||||
@@ -2393,6 +2460,8 @@ const i18n = {
|
||||
'common.edit': '编辑',
|
||||
'common.close': '关闭',
|
||||
'common.refresh': '刷新',
|
||||
'common.refreshed': '已刷新',
|
||||
'common.refreshing': '刷新中...',
|
||||
'common.loading': '加载中...',
|
||||
'common.error': '错误',
|
||||
'common.success': '成功',
|
||||
@@ -2455,6 +2524,39 @@ const i18n = {
|
||||
'coreMemory.evolutionError': '加载演化历史失败',
|
||||
'coreMemory.created': '创建时间',
|
||||
'coreMemory.updated': '更新时间',
|
||||
|
||||
// View toggle
|
||||
'coreMemory.memories': '记忆',
|
||||
'coreMemory.clusters': '聚类',
|
||||
'coreMemory.clustersList': '聚类列表',
|
||||
'coreMemory.selectCluster': '选择聚类查看详情',
|
||||
'coreMemory.openSession': '打开 Session',
|
||||
'coreMemory.clickToPreview': '点击预览',
|
||||
'coreMemory.previewError': '加载预览失败',
|
||||
'coreMemory.unknownSessionType': '未知的会话类型',
|
||||
|
||||
// Clustering features
|
||||
'coreMemory.noClusters': '暂无聚类',
|
||||
'coreMemory.autoCluster': '自动聚类',
|
||||
'coreMemory.clusterLoadError': '加载聚类失败',
|
||||
'coreMemory.clusterDetailError': '加载聚类详情失败',
|
||||
'coreMemory.intent': '意图',
|
||||
'coreMemory.sessionTimeline': 'Session 时间线',
|
||||
'coreMemory.relatedClusters': '关联聚类',
|
||||
'coreMemory.noSessions': '此聚类暂无 session',
|
||||
'coreMemory.clusteringInProgress': '聚类进行中...',
|
||||
'coreMemory.clusteringComplete': '创建了 {created} 个聚类,包含 {sessions} 个 session',
|
||||
'coreMemory.clusteringError': '自动聚类失败',
|
||||
'coreMemory.enterClusterName': '请输入聚类名称:',
|
||||
'coreMemory.clusterCreated': '聚类已创建',
|
||||
'coreMemory.clusterCreateError': '创建聚类失败',
|
||||
'coreMemory.confirmDeleteCluster': '确定删除此聚类?',
|
||||
'coreMemory.clusterDeleted': '聚类已删除',
|
||||
'coreMemory.clusterDeleteError': '删除聚类失败',
|
||||
'coreMemory.clusterUpdated': '聚类已更新',
|
||||
'coreMemory.clusterUpdateError': '更新聚类失败',
|
||||
'coreMemory.memberRemoved': '成员已移除',
|
||||
'coreMemory.memberRemoveError': '移除成员失败',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
388
ccw/src/templates/dashboard-js/views/core-memory-clusters.js
Normal file
388
ccw/src/templates/dashboard-js/views/core-memory-clusters.js
Normal file
@@ -0,0 +1,388 @@
|
||||
// Session Clustering visualization for Core Memory
|
||||
// Dependencies: This file requires core-memory.js to be loaded first
|
||||
// - Uses: viewMemoryDetail(), fetchMemoryById(), showNotification(), t(), escapeHtml(), projectPath
|
||||
|
||||
// Global state
|
||||
var clusterList = [];
|
||||
var selectedCluster = null;
|
||||
|
||||
/**
|
||||
* Fetch and render cluster list
|
||||
*/
|
||||
async function loadClusters() {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/clusters?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
clusterList = result.clusters || [];
|
||||
renderClusterList();
|
||||
} catch (error) {
|
||||
console.error('Failed to load clusters:', error);
|
||||
showNotification(t('coreMemory.clusterLoadError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cluster list in sidebar
|
||||
*/
|
||||
function renderClusterList() {
|
||||
const container = document.getElementById('clusterListContainer');
|
||||
if (!container) return;
|
||||
|
||||
if (clusterList.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="folder-tree"></i>
|
||||
<p>${t('coreMemory.noClusters')}</p>
|
||||
<button class="btn btn-primary btn-sm" onclick="triggerAutoClustering()">
|
||||
<i data-lucide="sparkles"></i>
|
||||
${t('coreMemory.autoCluster')}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = clusterList.map(cluster => `
|
||||
<div class="cluster-item ${selectedCluster?.id === cluster.id ? 'active' : ''}"
|
||||
onclick="selectCluster('${cluster.id}')">
|
||||
<div class="cluster-icon">
|
||||
<i data-lucide="${cluster.status === 'active' ? 'folder-open' : 'folder'}"></i>
|
||||
</div>
|
||||
<div class="cluster-info">
|
||||
<div class="cluster-name">${escapeHtml(cluster.name)}</div>
|
||||
<div class="cluster-meta">
|
||||
<span>${cluster.memberCount} sessions</span>
|
||||
<span>${formatDate(cluster.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-${cluster.status}">${cluster.status}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select and load cluster details
|
||||
*/
|
||||
async function selectCluster(clusterId) {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/clusters/${clusterId}?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
selectedCluster = result.cluster;
|
||||
renderClusterDetail(result.cluster, result.members, result.relations);
|
||||
|
||||
// Update list to show selection
|
||||
renderClusterList();
|
||||
} catch (error) {
|
||||
console.error('Failed to load cluster:', error);
|
||||
showNotification(t('coreMemory.clusterDetailError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cluster detail view
|
||||
*/
|
||||
function renderClusterDetail(cluster, members, relations) {
|
||||
const container = document.getElementById('clusterDetailContainer');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="cluster-detail">
|
||||
<div class="cluster-header">
|
||||
<h3>${escapeHtml(cluster.name)}</h3>
|
||||
<div class="cluster-actions">
|
||||
<button class="btn btn-sm" onclick="editCluster('${cluster.id}')" title="${t('common.edit')}">
|
||||
<i data-lucide="edit-2"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteCluster('${cluster.id}')" title="${t('common.delete')}">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${cluster.description ? `<p class="cluster-description">${escapeHtml(cluster.description)}</p>` : ''}
|
||||
${cluster.intent ? `<div class="cluster-intent"><strong>${t('coreMemory.intent')}:</strong> ${escapeHtml(cluster.intent)}</div>` : ''}
|
||||
|
||||
<div class="cluster-timeline">
|
||||
<h4><i data-lucide="git-branch"></i> ${t('coreMemory.sessionTimeline')}</h4>
|
||||
${renderTimeline(members)}
|
||||
</div>
|
||||
|
||||
${relations && relations.length > 0 ? `
|
||||
<div class="cluster-relations">
|
||||
<h4><i data-lucide="link"></i> ${t('coreMemory.relatedClusters')}</h4>
|
||||
${renderRelations(relations)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render session timeline
|
||||
*/
|
||||
function renderTimeline(members) {
|
||||
if (!members || members.length === 0) {
|
||||
return `<p class="text-muted">${t('coreMemory.noSessions')}</p>`;
|
||||
}
|
||||
|
||||
// Sort by sequence order
|
||||
const sorted = [...members].sort((a, b) => a.sequence_order - b.sequence_order);
|
||||
|
||||
return `
|
||||
<div class="timeline">
|
||||
${sorted.map((member, index) => {
|
||||
const meta = member.metadata || {};
|
||||
// Get display text - prefer title, fallback to summary
|
||||
const displayTitle = meta.title || meta.summary || '';
|
||||
// Truncate for display
|
||||
const truncatedTitle = displayTitle.length > 120
|
||||
? displayTitle.substring(0, 120) + '...'
|
||||
: displayTitle;
|
||||
|
||||
return `
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker">
|
||||
<span class="timeline-number">${index + 1}</span>
|
||||
</div>
|
||||
<div class="timeline-content clickable" onclick="previewSession('${member.session_id}', '${member.session_type}')">
|
||||
<div class="timeline-header">
|
||||
<span class="session-id">${escapeHtml(member.session_id)}</span>
|
||||
<span class="badge badge-${member.session_type}">${member.session_type}</span>
|
||||
</div>
|
||||
${truncatedTitle ? `<div class="session-title">${escapeHtml(truncatedTitle)}</div>` : ''}
|
||||
${meta.token_estimate ? `<div class="session-tokens">~${meta.token_estimate} tokens</div>` : ''}
|
||||
<div class="timeline-card-footer">
|
||||
<span class="preview-hint"><i data-lucide="eye"></i> ${t('coreMemory.clickToPreview')}</span>
|
||||
<button class="btn btn-xs btn-ghost btn-danger" onclick="event.stopPropagation(); removeMember('${selectedCluster.id}', '${member.session_id}')" title="${t('common.delete')}">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview session in modal based on type
|
||||
*/
|
||||
async function previewSession(sessionId, sessionType) {
|
||||
try {
|
||||
if (sessionType === 'cli_history') {
|
||||
// Use CLI history preview modal
|
||||
if (typeof showExecutionDetail === 'function') {
|
||||
await showExecutionDetail(sessionId);
|
||||
} else {
|
||||
console.error('showExecutionDetail is not available. Make sure cli-history.js is loaded.');
|
||||
showNotification(t('coreMemory.previewError'), 'error');
|
||||
}
|
||||
} else if (sessionType === 'core_memory') {
|
||||
// Use memory preview modal
|
||||
await viewMemoryContent(sessionId);
|
||||
} else if (sessionType === 'workflow') {
|
||||
// Navigate to workflow view for now
|
||||
window.location.hash = `#workflow/${sessionId}`;
|
||||
} else {
|
||||
showNotification(t('coreMemory.unknownSessionType'), 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to preview session:', error);
|
||||
showNotification(t('coreMemory.previewError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cluster relations
|
||||
*/
|
||||
function renderRelations(relations) {
|
||||
return `
|
||||
<div class="relations-list">
|
||||
${relations.map(rel => `
|
||||
<div class="relation-item">
|
||||
<i data-lucide="arrow-right"></i>
|
||||
<span class="relation-type">${rel.relation_type}</span>
|
||||
<a href="#" onclick="selectCluster('${rel.target_cluster_id}'); return false;">
|
||||
${rel.target_cluster_id}
|
||||
</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger auto-clustering
|
||||
*/
|
||||
async function triggerAutoClustering(scope = 'recent') {
|
||||
try {
|
||||
showNotification(t('coreMemory.clusteringInProgress'), 'info');
|
||||
|
||||
const response = await fetch(`/api/core-memory/clusters/auto?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scope })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
showNotification(
|
||||
t('coreMemory.clusteringComplete', {
|
||||
created: result.clustersCreated,
|
||||
sessions: result.sessionsClustered
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
|
||||
// Reload clusters
|
||||
await loadClusters();
|
||||
} catch (error) {
|
||||
console.error('Auto-clustering failed:', error);
|
||||
showNotification(t('coreMemory.clusteringError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new cluster
|
||||
*/
|
||||
async function createCluster() {
|
||||
const name = prompt(t('coreMemory.enterClusterName'));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/clusters?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
showNotification(t('coreMemory.clusterCreated'), 'success');
|
||||
await loadClusters();
|
||||
} catch (error) {
|
||||
console.error('Failed to create cluster:', error);
|
||||
showNotification(t('coreMemory.clusterCreateError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit cluster (placeholder)
|
||||
*/
|
||||
function editCluster(clusterId) {
|
||||
const cluster = selectedCluster;
|
||||
if (!cluster) return;
|
||||
|
||||
const newName = prompt(t('coreMemory.enterClusterName'), cluster.name);
|
||||
if (!newName || newName === cluster.name) return;
|
||||
|
||||
updateCluster(clusterId, { name: newName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cluster
|
||||
*/
|
||||
async function updateCluster(clusterId, updates) {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/clusters/${clusterId}?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
showNotification(t('coreMemory.clusterUpdated'), 'success');
|
||||
await loadClusters();
|
||||
if (selectedCluster?.id === clusterId) {
|
||||
await selectCluster(clusterId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update cluster:', error);
|
||||
showNotification(t('coreMemory.clusterUpdateError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cluster
|
||||
*/
|
||||
async function deleteCluster(clusterId) {
|
||||
if (!confirm(t('coreMemory.confirmDeleteCluster'))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/clusters/${clusterId}?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
showNotification(t('coreMemory.clusterDeleted'), 'success');
|
||||
selectedCluster = null;
|
||||
await loadClusters();
|
||||
|
||||
// Clear detail view
|
||||
const container = document.getElementById('clusterDetailContainer');
|
||||
if (container) container.innerHTML = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to delete cluster:', error);
|
||||
showNotification(t('coreMemory.clusterDeleteError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member from cluster
|
||||
*/
|
||||
async function removeMember(clusterId, sessionId) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/core-memory/clusters/${clusterId}/members/${sessionId}?path=${encodeURIComponent(projectPath)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
showNotification(t('coreMemory.memberRemoved'), 'success');
|
||||
await selectCluster(clusterId); // Refresh detail
|
||||
} catch (error) {
|
||||
console.error('Failed to remove member:', error);
|
||||
showNotification(t('coreMemory.memberRemoveError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View memory content in modal
|
||||
* Requires: viewMemoryDetail from core-memory.js
|
||||
*/
|
||||
async function viewMemoryContent(memoryId) {
|
||||
try {
|
||||
// Check if required functions exist (from core-memory.js)
|
||||
if (typeof viewMemoryDetail === 'function') {
|
||||
await viewMemoryDetail(memoryId);
|
||||
} else {
|
||||
console.error('viewMemoryDetail is not available. Make sure core-memory.js is loaded.');
|
||||
showNotification(t('coreMemory.fetchError'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load memory content:', error);
|
||||
showNotification(t('coreMemory.fetchError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
// Knowledge Graph and Evolution visualization functions for Core Memory
|
||||
|
||||
async function viewKnowledgeGraph(memoryId) {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/memories/${memoryId}/knowledge-graph?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const graph = await response.json();
|
||||
|
||||
const modal = document.getElementById('memoryDetailModal');
|
||||
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.knowledgeGraph')} - ${memoryId}`;
|
||||
|
||||
const body = document.getElementById('memoryDetailBody');
|
||||
body.innerHTML = `
|
||||
<div class="knowledge-graph">
|
||||
<div id="knowledgeGraphContainer" class="knowledge-graph-container"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
|
||||
// Render D3 graph after modal is visible
|
||||
setTimeout(() => {
|
||||
renderKnowledgeGraphD3(graph);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge graph:', error);
|
||||
showNotification(t('coreMemory.graphError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderKnowledgeGraphD3(graph) {
|
||||
// Check if D3 is available
|
||||
if (typeof d3 === 'undefined') {
|
||||
const container = document.getElementById('knowledgeGraphContainer');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="graph-error">
|
||||
<i data-lucide="alert-triangle"></i>
|
||||
<p>D3.js not loaded</p>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!graph || !graph.entities || graph.entities.length === 0) {
|
||||
const container = document.getElementById('knowledgeGraphContainer');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="graph-empty-state">
|
||||
<i data-lucide="network"></i>
|
||||
<p>${t('coreMemory.noEntities')}</p>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('knowledgeGraphContainer');
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth || 800;
|
||||
const height = 400;
|
||||
|
||||
// Clear existing
|
||||
container.innerHTML = '';
|
||||
|
||||
// Transform data to D3 format
|
||||
const nodes = graph.entities.map(entity => ({
|
||||
id: entity.name,
|
||||
name: entity.name,
|
||||
type: entity.type || 'entity',
|
||||
displayName: entity.name.length > 25 ? entity.name.substring(0, 22) + '...' : entity.name
|
||||
}));
|
||||
|
||||
const nodeIds = new Set(nodes.map(n => n.id));
|
||||
const edges = (graph.relationships || []).filter(rel =>
|
||||
nodeIds.has(rel.source) && nodeIds.has(rel.target)
|
||||
).map(rel => ({
|
||||
source: rel.source,
|
||||
target: rel.target,
|
||||
type: rel.type || 'related'
|
||||
}));
|
||||
|
||||
// Create SVG with zoom support
|
||||
coreMemGraphSvg = d3.select('#knowledgeGraphContainer')
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('class', 'knowledge-graph-svg')
|
||||
.attr('viewBox', [0, 0, width, height]);
|
||||
|
||||
// Create a group for zoom/pan transformations
|
||||
coreMemGraphGroup = coreMemGraphSvg.append('g').attr('class', 'graph-content');
|
||||
|
||||
// Setup zoom behavior
|
||||
coreMemGraphZoom = d3.zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
coreMemGraphGroup.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
coreMemGraphSvg.call(coreMemGraphZoom);
|
||||
|
||||
// Add arrowhead marker
|
||||
coreMemGraphSvg.append('defs').append('marker')
|
||||
.attr('id', 'arrowhead-core')
|
||||
.attr('viewBox', '-0 -5 10 10')
|
||||
.attr('refX', 20)
|
||||
.attr('refY', 0)
|
||||
.attr('orient', 'auto')
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('xoverflow', 'visible')
|
||||
.append('svg:path')
|
||||
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
|
||||
.attr('fill', '#999')
|
||||
.style('stroke', 'none');
|
||||
|
||||
// Create force simulation
|
||||
coreMemGraphSimulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(edges).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(20))
|
||||
.force('x', d3.forceX(width / 2).strength(0.05))
|
||||
.force('y', d3.forceY(height / 2).strength(0.05));
|
||||
|
||||
// Draw edges
|
||||
const link = coreMemGraphGroup.append('g')
|
||||
.attr('class', 'graph-links')
|
||||
.selectAll('line')
|
||||
.data(edges)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'graph-edge')
|
||||
.attr('stroke', '#999')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', 'url(#arrowhead-core)');
|
||||
|
||||
// Draw nodes
|
||||
const node = coreMemGraphGroup.append('g')
|
||||
.attr('class', 'graph-nodes')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', d => 'graph-node-group ' + (d.type || 'entity'))
|
||||
.call(d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended))
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
showNodeDetail(d);
|
||||
});
|
||||
|
||||
// Add circles to nodes (color by type)
|
||||
node.append('circle')
|
||||
.attr('class', d => 'graph-node ' + (d.type || 'entity'))
|
||||
.attr('r', 10)
|
||||
.attr('fill', d => {
|
||||
if (d.type === 'file') return '#3b82f6'; // blue
|
||||
if (d.type === 'function') return '#10b981'; // green
|
||||
if (d.type === 'module') return '#8b5cf6'; // purple
|
||||
return '#6b7280'; // gray
|
||||
})
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('data-id', d => d.id);
|
||||
|
||||
// Add labels to nodes
|
||||
node.append('text')
|
||||
.attr('class', 'graph-label')
|
||||
.text(d => d.displayName)
|
||||
.attr('x', 14)
|
||||
.attr('y', 4)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', '#333');
|
||||
|
||||
// Update positions on simulation tick
|
||||
coreMemGraphSimulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
node.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
|
||||
});
|
||||
|
||||
// Drag functions
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) coreMemGraphSimulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) coreMemGraphSimulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showNodeDetail(node) {
|
||||
showNotification(`${node.name} (${node.type})`, 'info');
|
||||
}
|
||||
|
||||
async function viewEvolutionHistory(memoryId) {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/memories/${memoryId}/evolution?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const versions = await response.json();
|
||||
|
||||
const modal = document.getElementById('memoryDetailModal');
|
||||
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.evolutionHistory')} - ${memoryId}`;
|
||||
|
||||
const body = document.getElementById('memoryDetailBody');
|
||||
body.innerHTML = `
|
||||
<div class="evolution-timeline">
|
||||
${versions && versions.length > 0
|
||||
? versions.map((version, index) => renderEvolutionVersion(version, index)).join('')
|
||||
: `<div class="evolution-empty-state">
|
||||
<i data-lucide="git-branch"></i>
|
||||
<p>${t('coreMemory.noHistory')}</p>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch evolution history:', error);
|
||||
showNotification(t('coreMemory.evolutionError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvolutionVersion(version, index) {
|
||||
const timestamp = new Date(version.timestamp).toLocaleString();
|
||||
const contentPreview = version.content
|
||||
? (version.content.substring(0, 150) + (version.content.length > 150 ? '...' : ''))
|
||||
: '';
|
||||
|
||||
// Parse diff stats
|
||||
const diffStats = version.diff_stats || {};
|
||||
const added = diffStats.added || 0;
|
||||
const modified = diffStats.modified || 0;
|
||||
const deleted = diffStats.deleted || 0;
|
||||
|
||||
return `
|
||||
<div class="version-card">
|
||||
<div class="version-header">
|
||||
<div class="version-info">
|
||||
<span class="version-number">v${version.version}</span>
|
||||
<span class="version-date">${timestamp}</span>
|
||||
${index === 0 ? `<span class="badge badge-current">${t('coreMemory.current')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${contentPreview ? `
|
||||
<div class="version-content-preview">
|
||||
${escapeHtml(contentPreview)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${(added > 0 || modified > 0 || deleted > 0) ? `
|
||||
<div class="version-diff-stats">
|
||||
${added > 0 ? `<span class="diff-stat diff-added"><i data-lucide="plus"></i> ${added} added</span>` : ''}
|
||||
${modified > 0 ? `<span class="diff-stat diff-modified"><i data-lucide="edit-3"></i> ${modified} modified</span>` : ''}
|
||||
${deleted > 0 ? `<span class="diff-stat diff-deleted"><i data-lucide="minus"></i> ${deleted} deleted</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${version.reason ? `
|
||||
<div class="version-reason">
|
||||
<strong>Reason:</strong> ${escapeHtml(version.reason)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -49,12 +49,6 @@ function showNotification(message, type = 'info') {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// State for visualization (prefixed to avoid collision with memory.js)
|
||||
var coreMemGraphSvg = null;
|
||||
var coreMemGraphGroup = null;
|
||||
var coreMemGraphZoom = null;
|
||||
var coreMemGraphSimulation = null;
|
||||
|
||||
async function renderCoreMemoryView() {
|
||||
const content = document.getElementById('mainContent');
|
||||
hideStatsAndCarousel();
|
||||
@@ -65,9 +59,19 @@ async function renderCoreMemoryView() {
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="core-memory-container">
|
||||
<!-- Header Actions -->
|
||||
<div class="core-memory-header">
|
||||
<div class="header-actions">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="core-memory-tabs">
|
||||
<div class="tab-nav">
|
||||
<button class="tab-btn active" id="memoriesViewBtn" onclick="showMemoriesView()">
|
||||
<i data-lucide="brain"></i>
|
||||
${t('coreMemory.memories')}
|
||||
</button>
|
||||
<button class="tab-btn" id="clustersViewBtn" onclick="showClustersView()">
|
||||
<i data-lucide="folder-tree"></i>
|
||||
${t('coreMemory.clusters')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-actions">
|
||||
<button class="btn btn-primary" onclick="showCreateMemoryModal()">
|
||||
<i data-lucide="plus"></i>
|
||||
${t('coreMemory.createNew')}
|
||||
@@ -81,23 +85,51 @@ async function renderCoreMemoryView() {
|
||||
${t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memories Tab Content (default view) -->
|
||||
<div class="cm-tab-panel" id="memoriesGrid">
|
||||
<div class="memory-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">${t('coreMemory.totalMemories')}</span>
|
||||
<span class="stat-value" id="totalMemoriesCount">${memories.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memories-grid">
|
||||
${memories.length === 0
|
||||
? `<div class="empty-state">
|
||||
<i data-lucide="brain"></i>
|
||||
<p>${t('coreMemory.noMemories')}</p>
|
||||
</div>`
|
||||
: memories.map(memory => renderMemoryCard(memory)).join('')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memories Grid -->
|
||||
<div class="memories-grid" id="memoriesGrid">
|
||||
${memories.length === 0
|
||||
? `<div class="empty-state">
|
||||
<i data-lucide="brain"></i>
|
||||
<p>${t('coreMemory.noMemories')}</p>
|
||||
</div>`
|
||||
: memories.map(memory => renderMemoryCard(memory)).join('')
|
||||
}
|
||||
<!-- Clusters Tab Content (hidden by default) -->
|
||||
<div class="cm-tab-panel clusters-container" id="clustersContainer" style="display: none;">
|
||||
<div class="clusters-layout">
|
||||
<div class="clusters-sidebar">
|
||||
<div class="clusters-sidebar-header">
|
||||
<h4>${t('coreMemory.clustersList')}</h4>
|
||||
<button class="btn btn-sm btn-primary" onclick="triggerAutoClustering()">
|
||||
<i data-lucide="sparkles"></i>
|
||||
${t('coreMemory.autoCluster')}
|
||||
</button>
|
||||
</div>
|
||||
<div id="clusterListContainer" class="cluster-list">
|
||||
<!-- Clusters will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="clusters-detail">
|
||||
<div id="clusterDetailContainer" class="cluster-detail-content">
|
||||
<div class="empty-state">
|
||||
<i data-lucide="folder-tree"></i>
|
||||
<p>${t('coreMemory.selectCluster')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -243,14 +275,6 @@ function renderMemoryCard(memory) {
|
||||
<i data-lucide="sparkles"></i>
|
||||
${t('coreMemory.summary')}
|
||||
</button>
|
||||
<button class="feature-btn" onclick="viewKnowledgeGraph('${memory.id}')" title="${t('coreMemory.knowledgeGraph')}">
|
||||
<i data-lucide="network"></i>
|
||||
${t('coreMemory.graph')}
|
||||
</button>
|
||||
<button class="feature-btn" onclick="viewEvolutionHistory('${memory.id}')" title="${t('coreMemory.evolution')}">
|
||||
<i data-lucide="git-branch"></i>
|
||||
${t('coreMemory.evolution')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,97 +472,6 @@ async function generateMemorySummary(memoryId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function viewKnowledgeGraph(memoryId) {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/memories/${memoryId}/knowledge-graph?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const graph = await response.json();
|
||||
|
||||
const modal = document.getElementById('memoryDetailModal');
|
||||
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.knowledgeGraph')} - ${memoryId}`;
|
||||
|
||||
const body = document.getElementById('memoryDetailBody');
|
||||
body.innerHTML = `
|
||||
<div class="knowledge-graph">
|
||||
<div class="graph-section">
|
||||
<h3>${t('coreMemory.entities')}</h3>
|
||||
<div class="entities-list">
|
||||
${graph.entities && graph.entities.length > 0
|
||||
? graph.entities.map(entity => `
|
||||
<div class="entity-item">
|
||||
<span class="entity-name">${escapeHtml(entity.name)}</span>
|
||||
<span class="entity-type">${escapeHtml(entity.type)}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: `<p class="empty-text">${t('coreMemory.noEntities')}</p>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-section">
|
||||
<h3>${t('coreMemory.relationships')}</h3>
|
||||
<div class="relationships-list">
|
||||
${graph.relationships && graph.relationships.length > 0
|
||||
? graph.relationships.map(rel => `
|
||||
<div class="relationship-item">
|
||||
<span class="rel-source">${escapeHtml(rel.source)}</span>
|
||||
<span class="rel-type">${escapeHtml(rel.type)}</span>
|
||||
<span class="rel-target">${escapeHtml(rel.target)}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: `<p class="empty-text">${t('coreMemory.noRelationships')}</p>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge graph:', error);
|
||||
showNotification(t('coreMemory.graphError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewEvolutionHistory(memoryId) {
|
||||
try {
|
||||
const response = await fetch(`/api/core-memory/memories/${memoryId}/evolution?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const versions = await response.json();
|
||||
|
||||
const modal = document.getElementById('memoryDetailModal');
|
||||
document.getElementById('memoryDetailTitle').textContent = `${t('coreMemory.evolutionHistory')} - ${memoryId}`;
|
||||
|
||||
const body = document.getElementById('memoryDetailBody');
|
||||
body.innerHTML = `
|
||||
<div class="evolution-timeline">
|
||||
${versions && versions.length > 0
|
||||
? versions.map((version, index) => `
|
||||
<div class="evolution-version">
|
||||
<div class="version-header">
|
||||
<span class="version-number">v${version.version}</span>
|
||||
<span class="version-date">${new Date(version.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="version-reason">${escapeHtml(version.reason || t('coreMemory.noReason'))}</div>
|
||||
${index === 0 ? `<span class="badge badge-current">${t('coreMemory.current')}</span>` : ''}
|
||||
</div>
|
||||
`).join('')
|
||||
: `<p class="empty-text">${t('coreMemory.noHistory')}</p>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
lucide.createIcons();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch evolution history:', error);
|
||||
showNotification(t('coreMemory.evolutionError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewMemoryDetail(memoryId) {
|
||||
const memory = await fetchMemoryById(memoryId);
|
||||
if (!memory) return;
|
||||
@@ -603,20 +536,23 @@ async function toggleArchivedMemories() {
|
||||
async function refreshCoreMemories() {
|
||||
const memories = await fetchCoreMemories(showingArchivedMemories);
|
||||
|
||||
const grid = document.getElementById('memoriesGrid');
|
||||
const container = document.getElementById('memoriesGrid');
|
||||
const grid = container.querySelector('.memories-grid');
|
||||
const countEl = document.getElementById('totalMemoriesCount');
|
||||
|
||||
if (countEl) countEl.textContent = memories.length;
|
||||
|
||||
if (memories.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="brain"></i>
|
||||
<p>${showingArchivedMemories ? t('coreMemory.noArchivedMemories') : t('coreMemory.noMemories')}</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
grid.innerHTML = memories.map(memory => renderMemoryCard(memory)).join('');
|
||||
if (grid) {
|
||||
if (memories.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="brain"></i>
|
||||
<p>${showingArchivedMemories ? t('coreMemory.noArchivedMemories') : t('coreMemory.noMemories')}</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
grid.innerHTML = memories.map(memory => renderMemoryCard(memory)).join('');
|
||||
}
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
@@ -628,3 +564,25 @@ function escapeHtml(text) {
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// View Toggle Functions
|
||||
function showMemoriesView() {
|
||||
document.getElementById('memoriesGrid').style.display = '';
|
||||
document.getElementById('clustersContainer').style.display = 'none';
|
||||
document.getElementById('memoriesViewBtn').classList.add('active');
|
||||
document.getElementById('clustersViewBtn').classList.remove('active');
|
||||
}
|
||||
|
||||
function showClustersView() {
|
||||
document.getElementById('memoriesGrid').style.display = 'none';
|
||||
document.getElementById('clustersContainer').style.display = '';
|
||||
document.getElementById('memoriesViewBtn').classList.remove('active');
|
||||
document.getElementById('clustersViewBtn').classList.add('active');
|
||||
|
||||
// Load clusters from core-memory-clusters.js
|
||||
if (typeof loadClusters === 'function') {
|
||||
loadClusters();
|
||||
} else {
|
||||
console.error('loadClusters is not available. Make sure core-memory-clusters.js is loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ var nodeFilters = {
|
||||
CLASS: true,
|
||||
FUNCTION: true,
|
||||
METHOD: true,
|
||||
VARIABLE: false
|
||||
VARIABLE: true
|
||||
};
|
||||
var edgeFilters = {
|
||||
CALLS: true,
|
||||
@@ -85,8 +85,17 @@ async function loadGraphData() {
|
||||
queryParams.set('module', selectedModule);
|
||||
}
|
||||
|
||||
var nodesUrl = '/api/graph/nodes' + (queryParams.toString() ? '?' + queryParams.toString() : '');
|
||||
var edgesUrl = '/api/graph/edges' + (queryParams.toString() ? '?' + queryParams.toString() : '');
|
||||
var queryString = queryParams.toString();
|
||||
var nodesUrl = '/api/graph/nodes' + (queryString ? '?' + queryString : '');
|
||||
var edgesUrl = '/api/graph/edges' + (queryString ? '?' + queryString : '');
|
||||
|
||||
console.log('[Graph] Loading data with filter:', {
|
||||
mode: filterMode,
|
||||
file: selectedFile,
|
||||
module: selectedModule,
|
||||
nodesUrl: nodesUrl,
|
||||
edgesUrl: edgesUrl
|
||||
});
|
||||
|
||||
var nodesResp = await fetch(nodesUrl);
|
||||
if (!nodesResp.ok) throw new Error('Failed to load graph nodes');
|
||||
@@ -100,6 +109,13 @@ async function loadGraphData() {
|
||||
nodes: nodesData.nodes || [],
|
||||
edges: edgesData.edges || []
|
||||
};
|
||||
|
||||
console.log('[Graph] Loaded data:', {
|
||||
nodes: graphData.nodes.length,
|
||||
edges: graphData.edges.length,
|
||||
filters: nodesData.filters
|
||||
});
|
||||
|
||||
return graphData;
|
||||
} catch (err) {
|
||||
console.error('Failed to load graph data:', err);
|
||||
@@ -449,6 +465,38 @@ function initializeCytoscape() {
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse hover events for nodes
|
||||
cyInstance.on('mouseover', 'node', function(evt) {
|
||||
var node = evt.target;
|
||||
node.addClass('hover');
|
||||
// Highlight connected edges
|
||||
node.connectedEdges().addClass('hover');
|
||||
});
|
||||
|
||||
cyInstance.on('mouseout', 'node', function(evt) {
|
||||
var node = evt.target;
|
||||
node.removeClass('hover');
|
||||
// Remove edge highlights (unless they are highlighted due to selection)
|
||||
node.connectedEdges().removeClass('hover');
|
||||
});
|
||||
|
||||
// Mouse hover events for edges
|
||||
cyInstance.on('mouseover', 'edge', function(evt) {
|
||||
var edge = evt.target;
|
||||
edge.addClass('hover');
|
||||
// Also highlight connected nodes
|
||||
edge.source().addClass('hover');
|
||||
edge.target().addClass('hover');
|
||||
});
|
||||
|
||||
cyInstance.on('mouseout', 'edge', function(evt) {
|
||||
var edge = evt.target;
|
||||
edge.removeClass('hover');
|
||||
// Remove node highlights
|
||||
edge.source().removeClass('hover');
|
||||
edge.target().removeClass('hover');
|
||||
});
|
||||
|
||||
// Fit view after layout
|
||||
setTimeout(function() {
|
||||
fitCytoscape();
|
||||
@@ -464,6 +512,22 @@ function transformDataForCytoscape() {
|
||||
return nodeFilters[type];
|
||||
});
|
||||
|
||||
// Create node ID set and name-to-id mapping for edge resolution
|
||||
var nodeIdSet = new Set();
|
||||
var nodeNameToIds = {}; // Map symbol names to their node IDs
|
||||
|
||||
filteredNodes.forEach(function(node) {
|
||||
var nodeId = node.id;
|
||||
nodeIdSet.add(nodeId);
|
||||
|
||||
// Extract symbol name for matching
|
||||
var name = node.name || '';
|
||||
if (!nodeNameToIds[name]) {
|
||||
nodeNameToIds[name] = [];
|
||||
}
|
||||
nodeNameToIds[name].push(nodeId);
|
||||
});
|
||||
|
||||
// Add nodes
|
||||
filteredNodes.forEach(function(node) {
|
||||
elements.push({
|
||||
@@ -473,8 +537,8 @@ function transformDataForCytoscape() {
|
||||
label: node.name || node.id,
|
||||
type: node.type || 'MODULE',
|
||||
symbolType: node.symbolType,
|
||||
path: node.path,
|
||||
lineNumber: node.lineNumber,
|
||||
path: node.path || node.file,
|
||||
lineNumber: node.lineNumber || node.line,
|
||||
imports: node.imports || 0,
|
||||
exports: node.exports || 0,
|
||||
references: node.references || 0
|
||||
@@ -482,29 +546,76 @@ function transformDataForCytoscape() {
|
||||
});
|
||||
});
|
||||
|
||||
// Create node ID set for filtering edges
|
||||
var nodeIdSet = new Set(filteredNodes.map(function(n) { return n.id; }));
|
||||
|
||||
// Filter edges
|
||||
// Filter and resolve edges
|
||||
var filteredEdges = graphData.edges.filter(function(edge) {
|
||||
var type = edge.type || 'CALLS';
|
||||
return edgeFilters[type] &&
|
||||
nodeIdSet.has(edge.source) &&
|
||||
nodeIdSet.has(edge.target);
|
||||
return edgeFilters[type];
|
||||
});
|
||||
|
||||
// Add edges
|
||||
filteredEdges.forEach(function(edge, index) {
|
||||
elements.push({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: 'edge-' + index,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'CALLS',
|
||||
weight: edge.weight || 1
|
||||
// Process edges with target resolution
|
||||
var edgeCount = 0;
|
||||
filteredEdges.forEach(function(edge) {
|
||||
var sourceId = edge.source;
|
||||
var targetId = edge.target;
|
||||
|
||||
// Check if source exists
|
||||
if (!nodeIdSet.has(sourceId)) {
|
||||
return; // Skip if source node doesn't exist
|
||||
}
|
||||
|
||||
// Try to resolve target
|
||||
var resolvedTargetId = null;
|
||||
|
||||
// 1. Direct match
|
||||
if (nodeIdSet.has(targetId)) {
|
||||
resolvedTargetId = targetId;
|
||||
}
|
||||
// 2. Try to match by qualified name (extract symbol name)
|
||||
else if (targetId) {
|
||||
// Try to extract symbol name from qualified name
|
||||
var targetName = targetId;
|
||||
|
||||
// Handle qualified names like "module.ClassName.methodName" or "file:name:line"
|
||||
if (targetId.includes('.')) {
|
||||
var parts = targetId.split('.');
|
||||
targetName = parts[parts.length - 1]; // Get last part
|
||||
} else if (targetId.includes(':')) {
|
||||
var colonParts = targetId.split(':');
|
||||
if (colonParts.length >= 2) {
|
||||
targetName = colonParts[1]; // file:name:line format
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look up in name-to-id mapping
|
||||
if (nodeNameToIds[targetName] && nodeNameToIds[targetName].length > 0) {
|
||||
// If multiple matches, prefer one in the same file
|
||||
var sourceFile = edge.sourceFile || '';
|
||||
var matchInSameFile = nodeNameToIds[targetName].find(function(id) {
|
||||
return id.startsWith(sourceFile);
|
||||
});
|
||||
resolvedTargetId = matchInSameFile || nodeNameToIds[targetName][0];
|
||||
}
|
||||
}
|
||||
|
||||
// Only add edge if both source and target are resolved
|
||||
if (resolvedTargetId && sourceId !== resolvedTargetId) {
|
||||
elements.push({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: 'edge-' + edgeCount++,
|
||||
source: sourceId,
|
||||
target: resolvedTargetId,
|
||||
type: edge.type || 'CALLS',
|
||||
weight: edge.weight || 1
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Graph] Transformed elements:', {
|
||||
nodes: filteredNodes.length,
|
||||
edges: edgeCount,
|
||||
totalRawEdges: filteredEdges.length
|
||||
});
|
||||
|
||||
return elements;
|
||||
@@ -512,47 +623,80 @@ function transformDataForCytoscape() {
|
||||
|
||||
function getCytoscapeStyles() {
|
||||
var styles = [
|
||||
// Node styles by type
|
||||
// Node styles by type - no label by default
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': function(ele) {
|
||||
return NODE_COLORS[ele.data('type')] || '#6B7280';
|
||||
},
|
||||
'label': 'data(label)',
|
||||
'label': '', // No label by default
|
||||
'width': function(ele) {
|
||||
var refs = ele.data('references') || 0;
|
||||
return Math.max(16, Math.min(48, 16 + refs * 1.5));
|
||||
return Math.max(20, Math.min(48, 20 + refs * 1.5));
|
||||
},
|
||||
'height': function(ele) {
|
||||
var refs = ele.data('references') || 0;
|
||||
return Math.max(16, Math.min(48, 16 + refs * 1.5));
|
||||
return Math.max(20, Math.min(48, 20 + refs * 1.5));
|
||||
},
|
||||
'border-width': 2,
|
||||
'border-color': function(ele) {
|
||||
var color = NODE_COLORS[ele.data('type')] || '#6B7280';
|
||||
return darkenColor(color, 20);
|
||||
},
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'font-size': '8px',
|
||||
'color': '#000',
|
||||
'text-outline-color': '#fff',
|
||||
'text-outline-width': 1.5,
|
||||
'overlay-padding': 6
|
||||
}
|
||||
},
|
||||
// Selected node
|
||||
// Hovered node - show label
|
||||
{
|
||||
selector: 'node.hover',
|
||||
style: {
|
||||
'label': 'data(label)',
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': -8,
|
||||
'font-size': '11px',
|
||||
'font-weight': 'bold',
|
||||
'color': '#1f2937',
|
||||
'text-outline-color': '#fff',
|
||||
'text-outline-width': 2,
|
||||
'text-background-color': '#fff',
|
||||
'text-background-opacity': 0.9,
|
||||
'text-background-padding': '4px',
|
||||
'text-background-shape': 'roundrectangle',
|
||||
'z-index': 999
|
||||
}
|
||||
},
|
||||
// Selected node - show label
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'label': 'data(label)',
|
||||
'border-width': 4,
|
||||
'border-color': '#000',
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': -8,
|
||||
'font-size': '11px',
|
||||
'font-weight': 'bold',
|
||||
'color': '#1f2937',
|
||||
'text-outline-color': '#fff',
|
||||
'text-outline-width': 2,
|
||||
'text-background-color': '#fff',
|
||||
'text-background-opacity': 0.9,
|
||||
'text-background-padding': '4px',
|
||||
'text-background-shape': 'roundrectangle',
|
||||
'overlay-color': '#000',
|
||||
'overlay-opacity': 0.2
|
||||
'overlay-opacity': 0.2,
|
||||
'z-index': 999
|
||||
}
|
||||
},
|
||||
// Edge styles by type
|
||||
// Edge styles by type - enhanced visibility
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': function(ele) {
|
||||
return Math.max(1, ele.data('weight') || 1);
|
||||
return Math.max(2, (ele.data('weight') || 1) * 1.5);
|
||||
},
|
||||
'line-color': function(ele) {
|
||||
return EDGE_COLORS[ele.data('type')] || '#6B7280';
|
||||
@@ -562,8 +706,27 @@ function getCytoscapeStyles() {
|
||||
},
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1.2,
|
||||
'opacity': 0.6
|
||||
'arrow-scale': 1.5,
|
||||
'opacity': 0.8,
|
||||
'z-index': 1
|
||||
}
|
||||
},
|
||||
// Hovered edge
|
||||
{
|
||||
selector: 'edge.hover',
|
||||
style: {
|
||||
'width': 4,
|
||||
'opacity': 1,
|
||||
'z-index': 100
|
||||
}
|
||||
},
|
||||
// Highlighted edge (connected to selected node)
|
||||
{
|
||||
selector: 'edge.highlighted',
|
||||
style: {
|
||||
'width': 3,
|
||||
'opacity': 1,
|
||||
'z-index': 50
|
||||
}
|
||||
},
|
||||
// Selected edge
|
||||
@@ -572,8 +735,9 @@ function getCytoscapeStyles() {
|
||||
style: {
|
||||
'line-color': '#000',
|
||||
'target-arrow-color': '#000',
|
||||
'width': 3,
|
||||
'opacity': 1
|
||||
'width': 4,
|
||||
'opacity': 1,
|
||||
'z-index': 100
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -581,6 +745,16 @@ function getCytoscapeStyles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
// Helper function to darken a color
|
||||
function darkenColor(hex, percent) {
|
||||
var num = parseInt(hex.replace('#', ''), 16);
|
||||
var amt = Math.round(2.55 * percent);
|
||||
var R = Math.max(0, (num >> 16) - amt);
|
||||
var G = Math.max(0, ((num >> 8) & 0x00FF) - amt);
|
||||
var B = Math.max(0, (num & 0x0000FF) - amt);
|
||||
return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
|
||||
}
|
||||
|
||||
// ========== Node Selection ==========
|
||||
function selectNode(nodeData) {
|
||||
selectedNode = nodeData;
|
||||
@@ -847,21 +1021,14 @@ async function switchDataSource(source) {
|
||||
}
|
||||
|
||||
// Update stats display
|
||||
var statsSpans = document.querySelectorAll('.graph-stats');
|
||||
if (statsSpans.length >= 2) {
|
||||
statsSpans[0].innerHTML = '<i data-lucide="circle" class="w-3 h-3"></i> ' +
|
||||
graphData.nodes.length + ' ' + t('graph.nodes');
|
||||
statsSpans[1].innerHTML = '<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
|
||||
graphData.edges.length + ' ' + t('graph.edges');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
updateGraphStats();
|
||||
|
||||
// Refresh Cytoscape with new data
|
||||
// Reinitialize Cytoscape with new data
|
||||
if (cyInstance) {
|
||||
refreshCytoscape();
|
||||
} else {
|
||||
initializeCytoscape();
|
||||
cyInstance.destroy();
|
||||
cyInstance = null;
|
||||
}
|
||||
initializeCytoscape();
|
||||
|
||||
// Show toast notification
|
||||
if (window.showToast) {
|
||||
@@ -876,14 +1043,34 @@ async function refreshGraphData() {
|
||||
showToast(t('common.refreshing'), 'info');
|
||||
}
|
||||
|
||||
// Show loading state in container
|
||||
var container = document.getElementById('cytoscapeContainer');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="cytoscape-empty">' +
|
||||
'<i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i>' +
|
||||
'<p>' + t('common.loading') + '</p>' +
|
||||
'</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// Load data based on source
|
||||
if (activeDataSource === 'memory') {
|
||||
await loadCoreMemoryGraphData();
|
||||
} else {
|
||||
await loadGraphData();
|
||||
}
|
||||
|
||||
if (activeTab === 'graph' && cyInstance) {
|
||||
refreshCytoscape();
|
||||
// Update stats display
|
||||
updateGraphStats();
|
||||
|
||||
// Reinitialize Cytoscape with new data
|
||||
if (cyInstance) {
|
||||
cyInstance.destroy();
|
||||
cyInstance = null;
|
||||
}
|
||||
|
||||
if (activeTab === 'graph') {
|
||||
initializeCytoscape();
|
||||
}
|
||||
|
||||
if (window.showToast) {
|
||||
@@ -891,6 +1078,18 @@ async function refreshGraphData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update graph statistics display
|
||||
function updateGraphStats() {
|
||||
var statsSpans = document.querySelectorAll('.graph-stats');
|
||||
if (statsSpans.length >= 2) {
|
||||
statsSpans[0].innerHTML = '<i data-lucide="circle" class="w-3 h-3"></i> ' +
|
||||
graphData.nodes.length + ' ' + t('graph.nodes');
|
||||
statsSpans[1].innerHTML = '<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
|
||||
graphData.edges.length + ' ' + t('graph.edges');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
function hideStatsAndCarousel() {
|
||||
var statsGrid = document.getElementById('statsGrid');
|
||||
@@ -915,6 +1114,7 @@ function cleanupGraphExplorer() {
|
||||
|
||||
// ========== Scope Filter Actions ==========
|
||||
async function changeScopeMode(mode) {
|
||||
console.log('[Graph] Changing scope mode to:', mode);
|
||||
filterMode = mode;
|
||||
selectedFile = null;
|
||||
selectedModule = null;
|
||||
@@ -936,6 +1136,7 @@ async function changeScopeMode(mode) {
|
||||
}
|
||||
|
||||
async function selectModule(modulePath) {
|
||||
console.log('[Graph] Selecting module:', modulePath);
|
||||
selectedModule = modulePath;
|
||||
if (modulePath) {
|
||||
await refreshGraphData();
|
||||
@@ -943,6 +1144,7 @@ async function selectModule(modulePath) {
|
||||
}
|
||||
|
||||
async function selectFile(filePath) {
|
||||
console.log('[Graph] Selecting file:', filePath);
|
||||
selectedFile = filePath;
|
||||
if (filePath) {
|
||||
await refreshGraphData();
|
||||
|
||||
@@ -100,6 +100,8 @@ async function renderHookManager() {
|
||||
</div>
|
||||
|
||||
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')}
|
||||
${renderQuickInstallCard('session-context-continuous', t('hook.tpl.sessionContextContinuous'), t('hook.tpl.sessionContextContinuousDesc'), 'UserPromptSubmit', '')}
|
||||
${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')}
|
||||
${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')}
|
||||
${renderQuickInstallCard('log-tool', t('hook.tpl.toolLogger'), t('hook.tpl.toolLoggerDesc'), 'PostToolUse', 'All')}
|
||||
|
||||
60
ccw/src/templates/hooks-config-example.json
Normal file
60
ccw/src/templates/hooks-config-example.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"description": "Example hooks configuration for CCW. Place in .claude/settings.json under 'hooks' key.",
|
||||
"hooks": {
|
||||
"session-start": [
|
||||
{
|
||||
"name": "Progressive Disclosure",
|
||||
"description": "Injects progressive disclosure index at session start",
|
||||
"enabled": true,
|
||||
"handler": "internal:context",
|
||||
"timeout": 5000,
|
||||
"failMode": "silent"
|
||||
}
|
||||
],
|
||||
"session-end": [
|
||||
{
|
||||
"name": "Update Cluster Metadata",
|
||||
"description": "Updates cluster metadata after session ends",
|
||||
"enabled": true,
|
||||
"command": "ccw core-memory update-cluster --session $SESSION_ID",
|
||||
"timeout": 30000,
|
||||
"async": true,
|
||||
"failMode": "log"
|
||||
}
|
||||
],
|
||||
"file-modified": [
|
||||
{
|
||||
"name": "Auto Commit Checkpoint",
|
||||
"description": "Creates git checkpoint on file modifications",
|
||||
"enabled": false,
|
||||
"command": "git add . && git commit -m \"[Auto] Checkpoint: $FILE_PATH\"",
|
||||
"timeout": 10000,
|
||||
"async": true,
|
||||
"failMode": "log"
|
||||
}
|
||||
],
|
||||
"context-request": [
|
||||
{
|
||||
"name": "Dynamic Context",
|
||||
"description": "Provides context based on current session cluster",
|
||||
"enabled": true,
|
||||
"handler": "internal:context",
|
||||
"timeout": 5000,
|
||||
"failMode": "silent"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hookSettings": {
|
||||
"globalTimeout": 60000,
|
||||
"defaultFailMode": "silent",
|
||||
"allowAsync": true,
|
||||
"enableLogging": true
|
||||
},
|
||||
"notes": {
|
||||
"handler": "Use 'internal:context' for built-in context generation, or 'command' for external commands",
|
||||
"failMode": "Options: 'silent' (ignore errors), 'log' (log errors), 'fail' (abort on error)",
|
||||
"variables": "Available: $SESSION_ID, $FILE_PATH, $PROJECT_PATH, $CLUSTER_ID",
|
||||
"async": "Async hooks run in background and don't block the main flow"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user