feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+135 -133
View File
@@ -4,9 +4,7 @@ import { useEffect, useRef, useState, type DragEvent } from 'react';
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
import { Node, NodeConnection } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source';
import SourceEditor from './source/SourceEditor';
@@ -39,7 +37,6 @@ export default function FocusPanel({
onTextSelect,
highlightedPassage,
}: FocusPanelProps) {
const { dimensionIcons } = useDimensionIcons();
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
const [edgesData, setEdgesData] = useState<Record<number, NodeConnection[]>>({});
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
@@ -837,7 +834,7 @@ export default function FocusPanel({
{isOutgoing ? '↗' : '↙'}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
{getNodeIcon(connection.connected_node, 12)}
</span>
<button
type="button"
@@ -1150,7 +1147,7 @@ export default function FocusPanel({
<div className="prop-label">node</div>
<div className="prop-value">
<span className="node-meta-value">
<span className="node-meta-icon">{getNodeIcon(currentNode, dimensionIcons, 12)}</span>
<span className="node-meta-icon">{getNodeIcon(currentNode, 12)}</span>
<span className="node-id-pill">#{currentNode.id}</span>
</span>
</div>
@@ -1235,21 +1232,24 @@ export default function FocusPanel({
</div>
</div>
<div className="prop-row prop-row-top">
<div className="prop-label">ctx</div>
<div className="prop-value" style={{ overflow: 'visible' }}>
<div ref={contextMenuRef} style={{ position: 'relative', width: '100%' }}>
<div className="prop-row">
<div className="prop-label">context</div>
<div className="prop-value context-prop-value">
<div className="context-select-shell" ref={contextMenuRef}>
<button
type="button"
onClick={() => setContextMenuOpen((prev) => !prev)}
disabled={contextSaving}
className="context-select-trigger"
disabled={contextSaving}
onClick={(event) => {
event.stopPropagation();
setContextMenuOpen((prev) => !prev);
}}
>
<span className="context-select-main">
<span className="context-select-icon">
<span className="context-select-current">
<span className="context-select-badge">
<Folder size={13} />
</span>
<span className="context-select-text">
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
{currentNode.context?.name || 'No context'}
</span>
</span>
@@ -1261,7 +1261,8 @@ export default function FocusPanel({
<button
type="button"
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
void saveContext('');
}}
>
@@ -1272,7 +1273,8 @@ export default function FocusPanel({
key={context.id}
type="button"
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
void saveContext(String(context.id));
}}
>
@@ -1286,20 +1288,6 @@ export default function FocusPanel({
</div>
</div>
{/* Dimensions */}
<div className="prop-row prop-row-top">
<div className="prop-label">dime</div>
<div className="prop-value">
<DimensionTags
dimensions={currentNode.dimensions || []}
onUpdate={async (newDimensions) => {
try { await updateNode(activeTab, { dimensions: newDimensions }); }
catch (error) { console.error('Error saving dimensions:', error); window.alert('Failed to save dimensions. Please try again.'); }
}}
/>
</div>
</div>
{/* Connections */}
<div className="prop-row prop-row-top">
<div className="prop-label">edge</div>
@@ -1322,7 +1310,7 @@ export default function FocusPanel({
onMouseLeave={() => setHoveredConnectionId(null)}
>
<span className={`conn-dir ${isOut ? 'out' : 'in'}`}>{isOut ? '↗' : '↙'}</span>
<span className="conn-icon">{getNodeIcon(connection.connected_node, dimensionIcons, 12)}</span>
<span className="conn-icon">{getNodeIcon(connection.connected_node, 12)}</span>
<button
type="button"
className="conn-title-btn"
@@ -1424,7 +1412,7 @@ export default function FocusPanel({
const title = currentNode.title || 'Untitled';
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: currentNode.dimensions || [] }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title }));
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
}}
className="node-drag-handle"
@@ -1703,7 +1691,7 @@ export default function FocusPanel({
}
.prop-label {
width: 44px;
width: 58px;
flex-shrink: 0;
padding: 7px 8px 7px 0;
color: var(--rah-text-muted);
@@ -1720,6 +1708,10 @@ export default function FocusPanel({
line-height: 1.5;
}
.context-prop-value {
overflow: visible;
}
.props-toggle {
display: inline-flex;
align-items: center;
@@ -1769,6 +1761,116 @@ export default function FocusPanel({
font-family: inherit;
}
.context-select-shell {
position: relative;
z-index: 4;
}
.context-select-trigger {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px 0 8px;
border: 1px solid var(--rah-border-strong);
border-radius: 10px;
background: linear-gradient(180deg, var(--rah-bg-surface), var(--rah-bg-panel));
color: var(--rah-text-primary);
cursor: pointer;
}
.context-select-current {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.context-select-badge {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
border: 1px solid var(--rah-border);
background: var(--rah-bg-base);
color: var(--rah-text-muted);
flex-shrink: 0;
}
.context-select-name,
.context-select-empty {
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-select-name {
color: var(--rah-text-primary);
font-weight: 600;
}
.context-select-empty {
color: var(--rah-text-muted);
font-style: italic;
}
.context-select-chevron {
color: var(--rah-text-muted);
flex-shrink: 0;
transition: transform 120ms ease;
}
.context-select-chevron.open {
transform: rotate(180deg);
}
.context-select-menu {
position: relative;
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid var(--rah-border);
border-radius: 12px;
background: var(--rah-bg-surface);
box-shadow: var(--rah-shadow-floating);
}
.context-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 32px;
padding: 0 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--rah-text-secondary);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.context-option.active {
background: color-mix(in srgb, var(--rah-accent-green) 10%, var(--rah-bg-panel));
color: var(--rah-text-primary);
}
.context-option-count {
font-size: 10px;
color: var(--rah-text-muted);
flex-shrink: 0;
}
.prop-empty-btn {
background: transparent;
border: none;
@@ -1780,106 +1882,6 @@ export default function FocusPanel({
font-family: inherit;
}
.context-select-trigger {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 10px;
border-radius: 9px;
border: 1px solid var(--rah-border);
background: var(--rah-bg-panel);
color: var(--rah-text-base);
cursor: pointer;
font-family: inherit;
transition: border-color 120ms ease, background 120ms ease;
}
.context-select-trigger:hover {
border-color: var(--rah-border-strong);
background: var(--rah-bg-hover);
}
.context-select-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.context-select-icon {
display: inline-flex;
align-items: center;
color: var(--rah-text-muted);
flex-shrink: 0;
}
.context-select-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.context-select-chevron {
color: var(--rah-text-muted);
transition: transform 120ms ease;
flex-shrink: 0;
}
.context-select-chevron.open {
transform: rotate(180deg);
}
.context-select-menu {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--rah-border);
background: var(--rah-bg-panel);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.22);
}
.context-option {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--rah-text-secondary);
cursor: pointer;
font-size: 12px;
font-family: inherit;
text-align: left;
}
.context-option:hover,
.context-option.active {
background: var(--rah-bg-hover);
color: var(--rah-text-active);
}
.context-option-count {
color: var(--rah-text-muted);
font-size: 10px;
background: var(--rah-bg-active);
padding: 1px 6px;
border-radius: 999px;
flex-shrink: 0;
}
.prop-muted {
color: var(--rah-text-muted);
font-size: 12px;
@@ -1,560 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface DimensionSearchModalProps {
isOpen: boolean;
onClose: () => void;
onDimensionSelect: (dimension: string, description?: string) => void;
existingDimensions: string[];
}
interface DimensionSuggestion {
dimension: string;
count: number;
isPriority: boolean;
}
export default function DimensionSearchModal({
isOpen,
onClose,
onDimensionSelect,
existingDimensions
}: DimensionSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [newDimensionDescription, setNewDimensionDescription] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
// Store the element that triggered the modal for return focus
useEffect(() => {
if (isOpen && document.activeElement instanceof HTMLElement) {
returnFocusRef.current = document.activeElement;
}
}, [isOpen]);
// Focus trap and accessibility
useEffect(() => {
if (!isOpen) return;
// Autofocus input
inputRef.current?.focus();
// Lock body scroll
document.body.style.overflow = 'hidden';
// Handle Escape key
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
// Focus trap: keep focus within modal
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', handleTab);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleTab);
// Return focus to trigger element
if (returnFocusRef.current) {
returnFocusRef.current.focus();
}
};
}, [isOpen, onClose]);
// Track if we're in "create new" mode to avoid re-fetching
const isCreatingNew = searchQuery.trim() &&
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
// Fetch dimension suggestions
useEffect(() => {
const fetchSuggestions = async () => {
try {
const response = await fetch('/api/dimensions/popular?limit=50');
const result = await response.json();
if (result.success) {
const allDimensions: DimensionSuggestion[] = result.data;
// Filter based on search query and exclude existing dimensions
const filtered = allDimensions.filter(dim => {
const matchesQuery = !searchQuery.trim() ||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
const notExisting = !existingDimensions.includes(dim.dimension);
return matchesQuery && notExisting;
});
// Sort: priority first, then by count
const sorted = filtered.sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return b.count - a.count;
});
setSuggestions(sorted.slice(0, 20));
setSelectedIndex(0);
}
} catch (error) {
console.error('Error fetching dimension suggestions:', error);
setSuggestions([]);
}
};
// Only fetch when modal is open and we're not actively creating a new dimension
// (user has typed description means they're committing to create)
if (isOpen && !newDimensionDescription.trim()) {
const timeoutId = setTimeout(fetchSuggestions, 100);
return () => clearTimeout(timeoutId);
}
}, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (suggestions[selectedIndex]) {
// Select existing dimension
handleSelectDimension(suggestions[selectedIndex].dimension);
} else if (searchQuery.trim()) {
// Create new dimension with description
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
}
}
};
const handleSelectDimension = (dimension: string, description?: string) => {
onDimensionSelect(dimension, description);
setSearchQuery('');
setNewDimensionDescription('');
setSuggestions([]);
onClose();
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
const canCreateNew = searchQuery.trim() &&
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
const modalContent = (
<div
className="search-backdrop"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label="Search dimensions"
>
<div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
{/* Search Input */}
<div className="search-input-wrapper">
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search or create dimension..."
className="search-input"
/>
<div className="search-shortcut">
<kbd>esc</kbd>
</div>
</div>
{/* Results */}
{suggestions.length > 0 && (
<div className="search-results">
{suggestions.map((suggestion, index) => (
<button
key={suggestion.dimension}
onClick={() => handleSelectDimension(suggestion.dimension)}
onMouseEnter={() => setSelectedIndex(index)}
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
>
<span className={`result-name ${suggestion.isPriority ? 'priority' : ''}`}>
{suggestion.dimension}
</span>
<span className="result-count">{suggestion.count}</span>
{index === selectedIndex && (
<span className="result-hint"></span>
)}
</button>
))}
</div>
)}
{/* Create New Option */}
{canCreateNew && (
<div
className="search-create"
onClick={(e) => e.stopPropagation()}
>
<div className="create-header">
<span className="create-icon">+</span>
<span className="create-title">Create &quot;{searchQuery.trim()}&quot;</span>
</div>
<div className="description-input-wrapper">
<textarea
ref={textareaRef}
value={newDimensionDescription}
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Describe what belongs in this dimension..."
className="description-input"
rows={2}
/>
<span className="description-counter">{newDimensionDescription.length}/500</span>
</div>
{!newDimensionDescription.trim() && (
<div className="description-warning">
Dimensions without descriptions may not auto-assign correctly
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
}}
onMouseEnter={() => setSelectedIndex(suggestions.length)}
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
>
Create Dimension
</button>
</div>
)}
{/* Empty state */}
{!searchQuery && suggestions.length === 0 && (
<div className="search-empty">
Start typing to search dimensions
</div>
)}
</div>
<style jsx>{`
.search-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 15vh;
z-index: 9999;
animation: backdropIn 200ms ease-out;
}
.search-container {
width: 100%;
max-width: 640px;
max-height: 70vh;
animation: containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.search-input-wrapper {
display: flex;
align-items: center;
gap: 16px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
padding: 20px 24px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
}
.search-icon {
width: 22px;
height: 22px;
color: #525252;
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: #fafafa;
font-size: 18px;
font-family: inherit;
font-weight: 400;
}
.search-input::placeholder {
color: #525252;
}
.search-shortcut {
display: flex;
align-items: center;
gap: 4px;
}
.search-shortcut kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: #262626;
border-radius: 6px;
font-size: 11px;
font-family: inherit;
color: #737373;
border: 1px solid #333;
}
.search-results {
margin-top: 8px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
animation: resultsIn 150ms ease-out;
}
.search-result-item {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: transparent;
border: none;
border-bottom: 1px solid #1f1f1f;
cursor: pointer;
transition: background 100ms ease;
text-align: left;
font-family: inherit;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover,
.search-result-item.selected {
background: #1a1a1a;
}
.result-name {
flex: 1;
color: #e5e5e5;
font-size: 15px;
}
.result-name.priority {
color: #22c55e;
}
.result-count {
color: #525252;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.result-hint {
color: #525252;
font-size: 13px;
}
.search-create {
margin-top: 8px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
overflow: hidden;
padding: 16px 20px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
}
.create-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.create-title {
color: #22c55e;
font-size: 15px;
font-weight: 500;
}
.description-input-wrapper {
position: relative;
margin-bottom: 8px;
}
.description-input {
width: 100%;
padding: 12px;
background: #0a0a0a;
border: 1px solid #333;
border-radius: 8px;
color: #e5e5e5;
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
transition: border-color 150ms ease;
}
.description-input:focus {
border-color: #525252;
}
.description-input::placeholder {
color: #525252;
}
.description-counter {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 11px;
color: #525252;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.description-warning {
margin-bottom: 12px;
padding: 8px 12px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
border-radius: 6px;
color: #eab308;
font-size: 12px;
}
.create-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
cursor: pointer;
transition: background 100ms ease;
font-family: inherit;
color: #22c55e;
font-size: 14px;
font-weight: 500;
}
.create-button:hover,
.create-button.selected {
background: #262626;
}
.create-icon {
font-size: 18px;
font-weight: 300;
color: #22c55e;
}
.search-empty {
margin-top: 8px;
padding: 32px 24px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
color: #525252;
font-size: 14px;
text-align: center;
}
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes containerIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes resultsIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
}
@@ -1,374 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import DimensionSearchModal from './DimensionSearchModal';
interface DimensionTagsProps {
dimensions: string[];
onUpdate: (dimensions: string[]) => Promise<void>;
disabled?: boolean;
}
interface DimensionSuggestion {
dimension: string;
count: number;
}
export default function DimensionTags({
dimensions,
onUpdate,
disabled = false
}: DimensionTagsProps) {
const [isAdding, setIsAdding] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const sortedDimensions = [...dimensions];
useEffect(() => {
if (isAdding && inputRef.current) {
inputRef.current.focus();
}
}, [isAdding]);
useEffect(() => {
if (searchQuery.length > 0) {
fetchSuggestions(searchQuery);
} else if (isAdding) {
// Load popular dimensions when field is empty
fetchPopularDimensions();
} else {
setSuggestions([]);
}
}, [searchQuery, isAdding]);
const fetchSuggestions = async (query: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data);
}
} catch (error) {
console.error('Error fetching dimension suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const fetchPopularDimensions = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/dimensions/popular');
const data = await response.json();
if (data.success) {
setSuggestions(data.data);
}
} catch (error) {
console.error('Error fetching popular dimensions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const addDimension = async (dimension: string, description?: string) => {
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
return;
}
// If description is provided, create/update the dimension in the database first
if (description) {
try {
await fetch('/api/dimensions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: dimension.trim(),
description: description.trim(),
isPriority: false
})
});
} catch (error) {
console.error('Error creating dimension with description:', error);
}
}
const newDimensions = [...dimensions, dimension.trim()];
await onUpdate(newDimensions);
setSearchQuery('');
setSuggestions([]);
setIsAdding(false);
};
const removeDimension = async (index: number) => {
const dimension = sortedDimensions[index];
const newDimensions = dimensions.filter(d => d !== dimension);
await onUpdate(newDimensions);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const draggedDimension = sortedDimensions[draggedIndex];
const targetDimension = sortedDimensions[index];
// Find original positions in unsorted array
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
const targetOrigIndex = dimensions.indexOf(targetDimension);
const newDimensions = [...dimensions];
newDimensions.splice(draggedOrigIndex, 1);
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
onUpdate(newDimensions);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
const fromDimension = sortedDimensions[fromIndex];
const toDimension = sortedDimensions[toIndex];
// Find original positions in unsorted array
const fromOrigIndex = dimensions.indexOf(fromDimension);
const toOrigIndex = dimensions.indexOf(toDimension);
const newDimensions = [...dimensions];
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
await onUpdate(newDimensions);
};
// Check if dimensions overflow 2 lines (approximate)
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
? sortedDimensions.slice(0, 6)
: sortedDimensions;
const hiddenCount = sortedDimensions.length - 6;
return (
<div>
<div
ref={containerRef}
onClick={() => {
if (shouldShowExpandButton && !isExpanded) {
setIsExpanded(true);
}
}}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '8px',
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
position: 'relative',
minHeight: dimensions.length === 0 ? '24px' : 'auto'
}}
>
{/* Show placeholder when no dimensions */}
{dimensions.length === 0 && !disabled && (
<span style={{
fontSize: '11px',
color: 'var(--rah-text-muted)',
fontStyle: 'italic',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
No dimensions
</span>
)}
{displayedDimensions.map((dimension, index) => {
return (
<div
key={`${dimension}-${index}`}
draggable={!disabled}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
fontSize: '10px',
color: 'var(--rah-text-base)',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
padding: '2px 6px',
cursor: disabled ? 'default' : 'grab',
opacity: draggedIndex === index ? 0.5 : 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
>
<span>{dimension}</span>
{/* Reorder buttons removed - no longer needed */}
{/* Remove button */}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation();
removeDimension(index);
}}
style={{
padding: '0 2px',
fontSize: '14px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
marginLeft: '2px'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
×
</button>
)}
</div>
);
})}
{/* Show "+X more" indicator */}
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
<div
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '11px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed var(--rah-border-strong)',
borderRadius: '12px',
padding: '2px 8px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
>
+{hiddenCount} more
</div>
)}
{/* Collapse button when expanded */}
{isExpanded && shouldShowExpandButton && (
<div
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '11px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed var(--rah-border-strong)',
borderRadius: '12px',
padding: '2px 8px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
>
show less
</div>
)}
{/* Add dimension button */}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation();
setIsAdding(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
fontSize: '14px',
lineHeight: 1,
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed var(--rah-border-strong)',
borderRadius: '4px',
cursor: 'pointer',
transition: 'color 120ms ease, border-color 120ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
title="Add dimension"
>
+
</button>
)}
</div>
{/* Dimension Search Modal */}
<DimensionSearchModal
isOpen={isAdding}
onClose={() => setIsAdding(false)}
onDimensionSelect={(dim, description) => {
addDimension(dim, description);
}}
existingDimensions={dimensions}
/>
</div>
);
}
@@ -13,7 +13,6 @@ interface NodeSearchModalProps {
interface NodeSuggestion {
id: number;
title: string;
dimensions?: string[];
}
export default function NodeSearchModal({
@@ -96,7 +95,6 @@ export default function NodeSearchModal({
.map((node) => ({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
}));
setSuggestions(nextSuggestions);
setSelectedIndex(0);
@@ -258,17 +256,6 @@ export default function NodeSearchModal({
<div style={{ color: '#e0e0e0', fontSize: '13px', marginBottom: '2px' }}>
{suggestion.title}
</div>
{suggestion.dimensions && suggestion.dimensions.length > 0 && (
<div style={{
color: '#666',
fontSize: '11px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{suggestion.dimensions.join(' · ')}
</div>
)}
</div>
<span style={{ color: '#444', fontSize: '11px', fontFamily: 'monospace', flexShrink: 0 }}>
#{suggestion.id}
+2 -6
View File
@@ -6,11 +6,10 @@ import { FileText } from 'lucide-react';
interface NodeLabelProps {
id: string;
title: string;
dimensions: string[];
onNodeClick?: (nodeId: number) => void;
}
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
function NodeLabel({ id, title, onNodeClick }: NodeLabelProps) {
const handleClick = (e: React.MouseEvent) => {
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
e.stopPropagation();
@@ -62,7 +61,7 @@ function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
if (!content) return [content];
// Pattern to match [NODE:id:"title"] (dimensions removed)
// Pattern to match [NODE:id:"title"]
// Be tolerant of spaces and curly quotes
// Use non-greedy match (.+?) to handle quotes inside titles
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
@@ -80,15 +79,12 @@ export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: nu
// Parse the node data
const id = match[1];
const title = match[2];
const dimensions: string[] = []; // No dimensions in new format
// Add the node label
parts.push(
<NodeLabel
key={`node-${id}-${match.index}`}
id={id}
title={title}
dimensions={dimensions}
onNodeClick={onNodeClick}
/>
);
+35 -25
View File
@@ -18,7 +18,8 @@ import {
Sun,
Moon,
} from 'lucide-react';
import type { PaneType, NavigablePaneType } from '../panes/types';
import type { PaneType } from '../panes/types';
import type { FocusedSkill } from '@/types/skills';
import type { Theme } from '@/hooks/useTheme';
import type { ContextSummary } from '@/types/database';
@@ -27,11 +28,12 @@ interface LeftToolbarProps {
onAddStuffClick: () => void;
onRefreshClick: () => void;
onSettingsClick: () => void;
onPaneTypeClick: (paneType: NavigablePaneType) => void;
onPaneTypeClick: (paneType: PaneType) => void;
isExpanded: boolean;
onToggleExpanded: () => void;
openTabTypes: Set<PaneType>;
activeTabType: PaneType | null;
focusedSkill?: FocusedSkill | null;
theme: Theme;
onThemeToggle: () => void;
contexts?: ContextSummary[];
@@ -41,11 +43,10 @@ interface LeftToolbarProps {
const NAV_WIDTH_COLLAPSED = 50;
const NAV_WIDTH_EXPANDED = 280;
const VIEW_ITEMS: Array<{ paneType: NavigablePaneType; label: string; icon: typeof LayoutList }> = [
const PRIMARY_VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [
{ paneType: 'views', label: 'Nodes', icon: LayoutList },
{ paneType: 'skills', label: 'Skills', icon: BookOpen },
{ paneType: 'map', label: 'Map', icon: Map },
{ paneType: 'dimensions', label: 'Dimension', icon: Folder },
{ paneType: 'table', label: 'Table', icon: Table2 },
];
@@ -116,6 +117,7 @@ export default function LeftToolbar({
onToggleExpanded,
openTabTypes,
activeTabType,
focusedSkill,
theme,
onThemeToggle,
contexts = [],
@@ -123,6 +125,14 @@ export default function LeftToolbar({
}: LeftToolbarProps) {
const [contextsExpanded, setContextsExpanded] = useState(false);
const renderActionButtons = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
</div>
);
return (
<div
style={{
@@ -131,6 +141,7 @@ export default function LeftToolbar({
background: 'transparent',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '12px 8px',
flexShrink: 0,
transition: 'width 0.2s ease',
@@ -138,7 +149,6 @@ export default function LeftToolbar({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', height: '100%' }}>
{/* Top: collapse + actions */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<NavButton
icon={isExpanded ? PanelLeftClose : PanelLeftOpen}
@@ -146,18 +156,30 @@ export default function LeftToolbar({
expanded={isExpanded}
onClick={onToggleExpanded}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
</div>
{renderActionButtons()}
</div>
{/* Middle: view items, vertically centered */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}>
{PRIMARY_VIEW_ITEMS.map((item) => (
<NavButton
key={`${item.paneType}-${item.label}`}
icon={item.icon}
label={item.label}
expanded={isExpanded}
active={
item.paneType === 'skills'
? focusedSkill != null || activeTabType === 'skills' || openTabTypes.has('skills')
: activeTabType === item.paneType || openTabTypes.has(item.paneType)
}
onClick={() => onPaneTypeClick(item.paneType)}
activeTone="green"
/>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<NavButton
@@ -227,18 +249,6 @@ export default function LeftToolbar({
</div>
) : null}
</div>
{VIEW_ITEMS.map((item) => (
<NavButton
key={`${item.paneType}-${item.label}`}
icon={item.icon}
label={item.label}
expanded={isExpanded}
active={activeTabType === item.paneType || openTabTypes.has(item.paneType)}
onClick={() => onPaneTypeClick(item.paneType)}
activeTone="green"
/>
))}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -23
View File
@@ -4,24 +4,21 @@ import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import Chip from '../common/Chip';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeId: number) => void;
existingFilters: {type: 'dimension' | 'title', value: string}[];
existingFilters: {type: 'context' | 'title' | 'tag', value: string}[];
}
interface NodeSuggestion {
id: number;
title: string;
dimensions?: string[];
link?: string;
}
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
const { dimensionIcons } = useDimensionIcons();
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -111,7 +108,6 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
link: node.link || undefined,
}));
@@ -208,7 +204,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
>
<span className="result-id">{suggestion.id}</span>
<span className="result-icon">{getNodeIcon(suggestion as any, dimensionIcons, 14)}</span>
<span className="result-icon">{getNodeIcon(suggestion as any, 14)}</span>
<span className="result-title">{suggestion.title}</span>
{index === selectedIndex && (
<span className="result-hint"></span>
@@ -250,7 +246,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
display: flex;
align-items: center;
gap: 16px;
background: var(--rah-bg-panel);
background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
border-radius: 16px;
padding: 20px 24px;
@@ -262,7 +258,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
.search-icon {
width: 22px;
height: 22px;
color: #525252;
color: var(--rah-text-muted);
flex-shrink: 0;
}
@@ -271,14 +267,14 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
background: none;
border: none;
outline: none;
color: #fafafa;
color: var(--rah-text-active);
font-size: 18px;
font-family: inherit;
font-weight: 400;
}
.search-input::placeholder {
color: #525252;
color: var(--rah-text-muted);
}
.search-shortcut {
@@ -292,17 +288,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
align-items: center;
justify-content: center;
padding: 4px 8px;
background: var(--rah-border-strong);
background: var(--rah-bg-active);
border-radius: 6px;
font-size: 11px;
font-family: inherit;
color: #737373;
border: 1px solid var(--rah-border-stronger);
color: var(--rah-text-muted);
border: 1px solid var(--rah-border-strong);
}
.search-results {
margin-top: 8px;
background: var(--rah-bg-panel);
background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
border-radius: 16px;
overflow-x: hidden;
@@ -322,7 +318,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
padding: 16px 20px;
background: transparent;
border: none;
border-bottom: 1px solid var(--rah-bg-active);
border-bottom: 1px solid var(--rah-border);
cursor: pointer;
transition: background 100ms ease;
text-align: left;
@@ -345,8 +341,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
font-size: 10px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--rah-bg-base);
background: #22c55e;
color: var(--rah-text-inverse);
background: var(--rah-accent-green);
padding: 4px 8px;
border-radius: 6px;
min-width: 28px;
@@ -369,17 +365,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
}
.result-hint {
color: #525252;
color: var(--rah-text-muted);
font-size: 13px;
}
.search-empty {
margin-top: 8px;
padding: 32px 24px;
background: var(--rah-bg-panel);
background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
border-radius: 16px;
color: #525252;
color: var(--rah-text-muted);
font-size: 14px;
text-align: center;
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { useState } from 'react';
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
import PaneHeader from './PaneHeader';
import { DimensionsPaneProps, PaneType } from './types';
export default function DimensionsPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
onNodeOpen,
refreshToken,
onDataChanged,
onDimensionSelect,
}: DimensionsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
// When used as a pane, "close" means switch back to node view
const handleClose = () => {
onPaneAction?.({ type: 'switch-pane-type', paneType: 'node' });
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader
slot={slot}
onCollapse={onCollapse}
onSwapPanes={onSwapPanes}
tabBar={tabBar}
toolbarHostRef={setToolbarHost}
/>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
<div style={{
position: 'absolute',
inset: 0,
background: 'transparent',
}}>
<FolderViewOverlay
onClose={handleClose}
onNodeOpen={onNodeOpen}
refreshToken={refreshToken}
onDataChanged={onDataChanged}
onDimensionSelect={onDimensionSelect}
replaceWithViewsOnDimensionSelect={true}
toolbarHost={toolbarHost}
/>
</div>
</div>
</div>
);
}
+173 -322
View File
@@ -6,40 +6,29 @@ import {
Background,
useNodesState,
useEdgesState,
addEdge as rfAddEdge,
type Connection,
type NodeMouseHandler,
type Node as RFNode,
type Edge as RFEdge,
ReactFlowProvider,
useReactFlow,
MiniMap,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { Edge as DbEdge, Node as DbNode } from '@/types/database';
import PaneHeader from './PaneHeader';
import type { MapPaneProps } from './types';
import { ChevronDown } from 'lucide-react';
import { MiniMap } from '@xyflow/react';
import { RahNode } from './map/RahNode';
import { RahEdge } from './map/RahEdge';
import EdgeExplanationModal from './map/EdgeExplanationModal';
import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
import { useTheme } from '@/hooks/useTheme';
import './map/map-styles.css';
interface DimensionInfo {
dimension: string;
count: number;
isPriority: boolean;
description: string | null;
}
const nodeTypes = { rahNode: RahNode };
const edgeTypes = { rahEdge: RahEdge };
// Debounce helper
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout>;
@@ -52,8 +41,6 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
function MapPaneInner({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
@@ -61,111 +48,75 @@ function MapPaneInner({
activeTabId,
}: MapPaneProps) {
const reactFlowInstance = useReactFlow();
const { dimensionIcons } = useDimensionIcons();
const [theme] = useTheme();
// --- Data state (DB-level) ---
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]);
const [dbEdges, setDbEdges] = useState<DbEdge[]>([]);
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- UI state ---
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<MapViewMode>('dimension');
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [viewMode, setViewMode] = useState<MapViewMode>('context');
// --- React Flow state ---
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<RFNode<RahNodeData>>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
// --- Edge creation modal ---
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null);
// Track current RF positions so we can preserve them across data refreshes
const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const hasInitialFitRef = useRef(false);
// Combine base + expanded
const allDbNodes = useMemo(() => {
const baseIds = new Set(baseNodes.map(n => n.id));
return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))];
const baseIds = new Set(baseNodes.map((node) => node.id));
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
}, [baseNodes, expandedNodes]);
// Selected DB node
const selectedDbNode = useMemo(
() => allDbNodes.find(n => n.id === selectedNodeId) ?? null,
() => allDbNodes.find((node) => node.id === selectedNodeId) ?? null,
[allDbNodes, selectedNodeId],
);
// Connected node IDs for info panel
const connectedNodeIds = useMemo(() => {
if (!selectedNodeId) return new Set<number>();
const connected = new Set<number>();
dbEdges.forEach(e => {
if (e.from_node_id === selectedNodeId) connected.add(e.to_node_id);
if (e.to_node_id === selectedNodeId) connected.add(e.from_node_id);
dbEdges.forEach((edge) => {
if (edge.from_node_id === selectedNodeId) connected.add(edge.to_node_id);
if (edge.to_node_id === selectedNodeId) connected.add(edge.from_node_id);
});
return connected;
}, [selectedNodeId, dbEdges]);
const lockedDimensionNames = useMemo(
() => new Set(lockedDimensions.map(d => d.dimension)),
[lockedDimensions],
);
const clusterLabels = useMemo(() => {
if (viewMode !== 'dimension') {
return [];
}
if (viewMode !== 'context') return [];
const grouped = new Map<string, { x: number; y: number; count: number }>();
for (const node of rfNodes) {
const dimension = getPrimaryDimension((node.data as RahNodeData).dimensions);
const current = grouped.get(dimension) || { x: 0, y: 0, count: 0 };
const clusterLabel = (node.data as RahNodeData).clusterLabel;
const current = grouped.get(clusterLabel) || { x: 0, y: 0, count: 0 };
current.x += node.position.x;
current.y += node.position.y;
current.count += 1;
grouped.set(dimension, current);
grouped.set(clusterLabel, current);
}
return [...grouped.entries()].map(([dimension, totals]) => ({
dimension,
return [...grouped.entries()].map(([clusterLabel, totals]) => ({
clusterLabel,
x: totals.x / totals.count,
y: totals.y / totals.count - 84,
}));
}, [rfNodes, viewMode]);
// ----- Close dropdown on outside click -----
useEffect(() => {
if (!dimensionDropdownOpen) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as HTMLElement)) {
setDimensionDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [dimensionDropdownOpen]);
// ----- Fetch base data -----
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const nodesUrl = selectedDimension
? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}`
: `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`;
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
fetch(nodesUrl),
const [nodesRes, edgesRes] = await Promise.all([
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
fetch('/api/edges'),
fetch('/api/dimensions/popular'),
]);
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data');
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load map data');
const nodesPayload = await nodesRes.json();
const edgesPayload = await edgesRes.json();
@@ -175,23 +126,16 @@ function MapPaneInner({
setExpandedNodes([]);
setSelectedNodeId(null);
rfPositionsRef.current.clear();
if (dimsRes.ok) {
const dimsPayload = await dimsRes.json();
if (dimsPayload.success && dimsPayload.data) {
setLockedDimensions(dimsPayload.data as DimensionInfo[]);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedDimension]);
// ----- Sync DB data → React Flow nodes/edges -----
void fetchData();
}, []);
useEffect(() => {
if (allDbNodes.length === 0) {
setRfNodes([]);
@@ -199,9 +143,8 @@ function MapPaneInner({
return;
}
// Capture current RF positions before rebuild
rfNodes.forEach(n => {
rfPositionsRef.current.set(n.id, n.position);
rfNodes.forEach((node) => {
rfPositionsRef.current.set(node.id, node.position);
});
const centerX = 600;
@@ -215,29 +158,30 @@ function MapPaneInner({
selectedNodeId,
connectedNodeIds,
rfPositionsRef.current,
dimensionIcons,
viewMode,
dbEdges,
);
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
const nodeIdSet = new Set(newRfNodes.map((node) => node.id));
const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId);
setRfNodes(newRfNodes);
setRfEdges(newRfEdges);
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]);
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, viewMode, rfNodes, setRfEdges, setRfNodes]);
useEffect(() => {
if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return;
hasInitialFitRef.current = true;
const hubNodeIds = baseNodes
const hubNodeIds = [...baseNodes]
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
.slice(0, 25)
.map(n => String(n.id));
.map((node) => String(node.id));
setTimeout(() => {
if (hubNodeIds.length > 0) {
reactFlowInstance.fitView({
nodes: hubNodeIds.map(id => ({ id })),
nodes: hubNodeIds.map((id) => ({ id })),
padding: 0.3,
duration: 300,
});
@@ -247,27 +191,26 @@ function MapPaneInner({
useEffect(() => {
hasInitialFitRef.current = false;
}, [selectedDimension, viewMode]);
}, [viewMode]);
const fitAllNodes = useCallback(() => {
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
}, [reactFlowInstance]);
const fitHubNodes = useCallback(() => {
const hubNodeIds = baseNodes
const hubNodeIds = [...baseNodes]
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
.slice(0, 25)
.map(n => String(n.id));
.map((node) => String(node.id));
if (hubNodeIds.length > 0) {
reactFlowInstance.fitView({
nodes: hubNodeIds.map(id => ({ id })),
nodes: hubNodeIds.map((id) => ({ id })),
padding: 0.3,
duration: 300,
});
}
}, [baseNodes, reactFlowInstance]);
// ----- Node traversal: fetch connected nodes -----
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
try {
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
@@ -278,48 +221,49 @@ function MapPaneInner({
nodeEdges = edgesData.data || [];
if (nodeEdges.length > 0) {
setDbEdges(prev => {
const existing = new Set(prev.map(e => e.id));
const fresh = nodeEdges.filter(e => !existing.has(e.id));
setDbEdges((prev) => {
const existing = new Set(prev.map((edge) => edge.id));
const fresh = nodeEdges.filter((edge) => !existing.has(edge.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
}
}
// Find missing connected node IDs
const connectedIds = new Set<number>();
dbEdges.forEach(e => {
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
dbEdges.forEach((edge) => {
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
});
nodeEdges.forEach(e => {
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
nodeEdges.forEach((edge) => {
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
});
const existingIds = new Set(allDbNodes.map(n => n.id));
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
const existingIds = new Set(allDbNodes.map((node) => node.id));
const missingIds = Array.from(connectedIds).filter((id) => !existingIds.has(id));
if (missingIds.length === 0) return;
const fetched = (
await Promise.all(
missingIds.slice(0, 50).map(async id => {
missingIds.slice(0, 50).map(async (id) => {
try {
const res = await fetch(`/api/nodes/${id}`);
if (res.ok) {
const data = await res.json();
return data.node as DbNode;
}
} catch { /* ignore */ }
} catch {
return null;
}
return null;
}),
)
).filter((n): n is DbNode => n !== null);
).filter((node): node is DbNode => node !== null);
if (fetched.length > 0) {
setExpandedNodes(prev => {
const ids = new Set(prev.map(n => n.id));
const fresh = fetched.filter(n => !ids.has(n.id));
setExpandedNodes((prev) => {
const ids = new Set(prev.map((node) => node.id));
const fresh = fetched.filter((node) => !ids.has(node.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
}
@@ -329,44 +273,41 @@ function MapPaneInner({
}, [dbEdges, allDbNodes]);
useEffect(() => {
if (selectedNodeId) fetchConnectedNodes(selectedNodeId);
if (selectedNodeId) {
void fetchConnectedNodes(selectedNodeId);
}
}, [selectedNodeId, fetchConnectedNodes]);
// ----- Focused node awareness -----
useEffect(() => {
if (!activeTabId) return;
const existing = allDbNodes.find(n => n.id === activeTabId);
const existing = allDbNodes.find((node) => node.id === activeTabId);
if (existing) {
setSelectedNodeId(activeTabId);
// Pan + zoom closer to the focused node
const rfNode = rfNodes.find(n => n.id === String(activeTabId));
const rfNode = rfNodes.find((node) => node.id === String(activeTabId));
if (rfNode) {
reactFlowInstance.setCenter(rfNode.position.x, rfNode.position.y, { duration: 400, zoom: 1.5 });
}
} else {
(async () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (res.ok) {
const data = await res.json();
const node = data.node as DbNode;
if (node) {
setExpandedNodes(prev => prev.some(n => n.id === node.id) ? prev : [...prev, node]);
setSelectedNodeId(node.id);
// After the next render cycle, zoom to the newly added node
setTimeout(() => {
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
}, 100);
}
}
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
})();
return;
}
}, [activeTabId]);
// ----- SSE real-time sync -----
void (async () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (!res.ok) return;
const data = await res.json();
const node = data.node as DbNode;
if (!node) return;
setExpandedNodes((prev) => (prev.some((existingNode) => existingNode.id === node.id) ? prev : [...prev, node]));
setSelectedNodeId(node.id);
setTimeout(() => {
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
}, 100);
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
})();
}, [activeTabId, allDbNodes, rfNodes, reactFlowInstance]);
useEffect(() => {
let eventSource: EventSource | null = null;
@@ -382,7 +323,7 @@ function MapPaneInner({
const node = payload.data?.node as DbNode | undefined;
if (node?.id) {
const updater = (prev: DbNode[]) =>
prev.map(n => n.id === node.id ? { ...n, ...node } : n);
prev.map((existingNode) => (existingNode.id === node.id ? { ...existingNode, ...node } : existingNode));
setBaseNodes(updater);
setExpandedNodes(updater);
}
@@ -391,43 +332,36 @@ function MapPaneInner({
case 'NODE_DELETED': {
const deletedId = payload.data?.nodeId;
if (deletedId) {
setBaseNodes(prev => prev.filter(n => n.id !== deletedId));
setExpandedNodes(prev => prev.filter(n => n.id !== deletedId));
setDbEdges(prev => prev.filter(e => e.from_node_id !== deletedId && e.to_node_id !== deletedId));
setSelectedNodeId(prev => prev === deletedId ? null : prev);
setBaseNodes((prev) => prev.filter((node) => node.id !== deletedId));
setExpandedNodes((prev) => prev.filter((node) => node.id !== deletedId));
setDbEdges((prev) => prev.filter((edge) => edge.from_node_id !== deletedId && edge.to_node_id !== deletedId));
setSelectedNodeId((prev) => (prev === deletedId ? null : prev));
}
break;
}
case 'NODE_CREATED': {
// If filtering by dimension and new node matches, could add it
// For now, just note it happened — user can refresh
break;
}
case 'EDGE_CREATED': {
const edge = payload.data?.edge as DbEdge | undefined;
if (edge?.id) {
setDbEdges(prev => {
if (prev.some(e => e.id === edge.id)) return prev;
return [...prev, edge];
});
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
}
break;
}
case 'EDGE_DELETED': {
const edgeId = payload.data?.edgeId;
if (edgeId) {
setDbEdges(prev => prev.filter(e => e.id !== edgeId));
setDbEdges((prev) => prev.filter((edge) => edge.id !== edgeId));
}
break;
}
default:
break;
}
} catch {
// Ignore parse errors (keep-alive pings, etc.)
// Ignore keep-alives and malformed payloads.
}
};
eventSource.onerror = () => {
// EventSource auto-reconnects, just log
console.error('Map SSE connection error');
};
} catch {
@@ -439,7 +373,6 @@ function MapPaneInner({
};
}, []);
// ----- Node drag → save position to metadata (debounced) -----
const savePositionRef = useRef(
debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
try {
@@ -448,7 +381,13 @@ function MapPaneInner({
const { node: existing } = await res.json();
const existingMetadata = existing?.metadata ?? {};
const mergedMeta = typeof existingMetadata === 'string'
? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })()
? (() => {
try {
return JSON.parse(existingMetadata);
} catch {
return {};
}
})()
: existingMetadata;
await fetch(`/api/nodes/${nodeId}`, {
@@ -472,8 +411,8 @@ function MapPaneInner({
);
const onNodeDragStop: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (!isNaN(nodeId)) {
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
rfPositionsRef.current.set(node.id, node.position);
savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode);
}
@@ -485,36 +424,38 @@ function MapPaneInner({
useEffect(() => {
if (loading || rfNodes.length === 0) return;
const timeout = setTimeout(() => {
reactFlowInstance.fitView({ padding: 0.22, duration: 300 });
}, 80);
return () => clearTimeout(timeout);
}, [viewMode, selectedDimension, loading, rfNodes, reactFlowInstance]);
// ----- Node click → select + traverse -----
return () => clearTimeout(timeout);
}, [viewMode, loading, rfNodes, reactFlowInstance]);
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (isNaN(nodeId)) return;
setSelectedNodeId(prev => prev === nodeId ? null : nodeId);
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
}
}, []);
// ----- Node double-click → open in other pane -----
const onNodeDoubleClick: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (!isNaN(nodeId)) onNodeClick?.(nodeId);
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
onNodeClick?.(nodeId);
}
}, [onNodeClick]);
// ----- Edge creation via drag -----
const onConnect = useCallback((connection: Connection) => {
if (connection.source === connection.target) return; // No self-connections
if (connection.source === connection.target) return;
setPendingConnection(connection);
}, []);
const handleEdgeCreate = useCallback(async (explanation: string) => {
if (!pendingConnection?.source || !pendingConnection?.target) return;
const fromId = parseInt(pendingConnection.source);
const toId = parseInt(pendingConnection.target);
const fromId = parseInt(pendingConnection.source, 10);
const toId = parseInt(pendingConnection.target, 10);
try {
const res = await fetch('/api/edges', {
@@ -533,11 +474,7 @@ function MapPaneInner({
const payload = await res.json();
const edge = payload.data;
if (edge?.id) {
// Add to DB edges (SSE may also add it, dedup in handler)
setDbEdges(prev => {
if (prev.some(e => e.id === edge.id)) return prev;
return [...prev, edge];
});
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
}
}
} catch (err) {
@@ -551,12 +488,11 @@ function MapPaneInner({
setPendingConnection(null);
}, []);
// Get source/target titles for modal
const pendingSourceTitle = pendingConnection?.source
? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown'
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.source, 10))?.title || 'Unknown'
: '';
const pendingTargetTitle = pendingConnection?.target
? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown'
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.target, 10))?.title || 'Unknown'
: '';
return (
@@ -564,29 +500,29 @@ function MapPaneInner({
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => setViewMode('dimension')}
onClick={() => setViewMode('context')}
style={{
padding: '6px 10px',
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
background: viewMode === 'context' ? 'var(--rah-accent-green-soft)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
borderColor: viewMode === 'context' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: viewMode === 'dimension' ? '#a7f3b8' : 'var(--rah-text-muted)',
color: viewMode === 'context' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
>
Dimension View
Context View
</button>
<button
onClick={() => setViewMode('hub')}
style={{
padding: '6px 10px',
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
background: viewMode === 'hub' ? 'var(--rah-accent-green-soft)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
borderColor: viewMode === 'hub' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: viewMode === 'hub' ? '#a7f3b8' : 'var(--rah-text-muted)',
color: viewMode === 'hub' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
@@ -594,97 +530,8 @@ function MapPaneInner({
Hub View
</button>
</div>
{/* Dimension filter dropdown */}
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={() => setDimensionDropdownOpen(!dimensionDropdownOpen)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 10px',
background: selectedDimension ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
border: '1px solid',
borderColor: selectedDimension ? 'rgba(34, 197, 94, 0.3)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: selectedDimension ? '#22c55e' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
<span>{selectedDimension || 'All dimensions'}</span>
<ChevronDown size={12} style={{
transform: dimensionDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.15s ease',
}} />
</button>
{dimensionDropdownOpen && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
padding: '4px',
minWidth: '180px',
maxHeight: '300px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}>
<button
onClick={() => { setSelectedDimension(null); setDimensionDropdownOpen(false); }}
style={{
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
background: !selectedDimension ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', borderRadius: '4px',
color: !selectedDimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
>
All dimensions
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>&#10003;</span>}
</button>
{lockedDimensions.map(dim => (
<button
key={dim.dimension}
onClick={() => { setSelectedDimension(dim.dimension); setDimensionDropdownOpen(false); }}
style={{
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
background: selectedDimension === dim.dimension ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', borderRadius: '4px',
color: selectedDimension === dim.dimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={e => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
}
}}
onMouseLeave={e => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-muted)';
}
}}
>
{dim.dimension}
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>&#10003;</span>}
</button>
))}
</div>
)}
</div>
</PaneHeader>
{/* Map content */}
<div style={{ position: 'relative', flex: 1, background: 'var(--rah-bg-base)' }}>
{loading ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rah-text-muted)' }}>
@@ -715,24 +562,24 @@ function MapPaneInner({
maxZoom={3}
defaultEdgeOptions={{ type: 'rahEdge' }}
proOptions={{ hideAttribution: true }}
colorMode="dark"
colorMode={theme}
>
<Background color="var(--rah-border)" gap={40} size={1} />
<MiniMap
style={{ background: 'var(--rah-bg-base)', border: '1px solid var(--rah-bg-active)', borderRadius: 6 }}
maskColor="rgba(0, 0, 0, 0.7)"
style={{ background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: 6 }}
maskColor={theme === 'light' ? 'rgba(255, 255, 255, 0.72)' : 'rgba(0, 0, 0, 0.7)'}
nodeColor={(node) => {
const data = node.data as RahNodeData | undefined;
return data?.primaryDimensionColor || '#2a2a2a';
return data?.clusterColor || '#2a2a2a';
}}
pannable
zoomable
/>
</ReactFlow>
{viewMode === 'dimension' && clusterLabels.map((label) => (
{viewMode === 'context' && clusterLabels.map((label) => (
<div
key={label.dimension}
key={label.clusterLabel}
style={{
position: 'absolute',
transform: `translate(${label.x}px, ${label.y}px)`,
@@ -741,27 +588,29 @@ function MapPaneInner({
fontSize: '11px',
letterSpacing: '0.05em',
textTransform: 'uppercase',
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
textShadow: theme === 'light' ? '0 1px 3px rgba(255,255,255,0.95)' : '0 1px 6px rgba(0,0,0,0.45)',
}}
>
{label.dimension}
{label.clusterLabel}
</div>
))}
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}
>
<button
onClick={fitAllNodes}
style={{
padding: '4px 8px',
fontSize: 10,
background: 'var(--rah-bg-active)',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)',
borderRadius: 4,
color: 'var(--rah-text-muted)',
@@ -777,7 +626,7 @@ function MapPaneInner({
style={{
padding: '4px 8px',
fontSize: 10,
background: 'var(--rah-bg-active)',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)',
borderRadius: 4,
color: 'var(--rah-text-muted)',
@@ -790,7 +639,6 @@ function MapPaneInner({
)}
</div>
{/* Selected node info panel */}
{selectedDbNode && (
<div style={infoPanel}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
@@ -807,31 +655,35 @@ function MapPaneInner({
<div style={{ fontSize: 12, color: 'var(--rah-text-muted)', marginBottom: 8 }}>
{connectedNodeIds.size} connected nodes
</div>
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
<div style={{ fontSize: 11, color: 'var(--rah-accent-green)', marginBottom: 8 }}>
Click a connected node to traverse &middot; Double-click to open
</div>
{selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{selectedDbNode.dimensions.slice(0, 5).map(dim => (
<span
key={dim}
style={{
padding: '2px 8px', borderRadius: 999, fontSize: 11,
background: lockedDimensionNames.has(dim) ? '#132018' : 'var(--rah-bg-active)',
color: lockedDimensionNames.has(dim) ? '#86efac' : 'var(--rah-text-muted)',
}}
>
{dim}
</span>
))}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
background: selectedDbNode.context?.name ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
color: selectedDbNode.context?.name ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
}}
>
{selectedDbNode.context?.name || 'Unscoped'}
</span>
</div>
<button
onClick={() => onNodeClick?.(selectedDbNode.id)}
style={{
marginTop: 4, padding: '8px 12px', background: '#22c55e', color: '#052e16',
border: 'none', borderRadius: '6px', fontSize: 12, fontWeight: 500,
cursor: 'pointer', width: '100%',
marginTop: 4,
padding: '8px 12px',
background: 'var(--rah-accent-green)',
color: 'var(--rah-text-inverse)',
border: 'none',
borderRadius: '6px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
width: '100%',
}}
>
Open Node
@@ -842,7 +694,6 @@ function MapPaneInner({
)}
</div>
{/* Edge creation explanation modal */}
{pendingConnection && (
<EdgeExplanationModal
sourceTitle={pendingSourceTitle}
@@ -855,7 +706,6 @@ function MapPaneInner({
);
}
// Wrap with ReactFlowProvider
export default function MapPane(props: MapPaneProps) {
return (
<ReactFlowProvider>
@@ -869,9 +719,10 @@ const infoPanel: CSSProperties = {
bottom: 16,
left: 16,
width: 260,
background: 'var(--rah-bg-base)',
border: '1px solid var(--rah-bg-active)',
background: 'var(--rah-bg-modal)',
border: '1px solid var(--rah-border)',
borderRadius: 8,
padding: 14,
zIndex: 10,
boxShadow: 'var(--rah-shadow-floating)',
};
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useRef, useState, useCallback } from 'react';
import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react';
import type { SlotTab, PaneType, SlotId } from './types';
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
views: LayoutList,
contexts: PanelsTopLeft,
map: Map,
node: FileText,
table: Table2,
skills: BookOpen,
};
const TAB_TYPE_LABELS: Record<PaneType, string> = {
views: 'Feed',
contexts: 'Contexts',
map: 'Map',
node: 'Node',
table: 'Table',
skills: 'Skills',
};
interface SlotTabBarProps {
tabs: SlotTab[];
activeTabId: string;
slot: SlotId;
onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
onCrossSlotDrop?: (tab: SlotTab, fromSlot: SlotId) => void;
}
function truncateTitle(title: string, maxLength = 18): string {
if (title.length <= maxLength) return title;
return `${title.slice(0, maxLength - 1)}\u2026`;
}
export default function SlotTabBar({
tabs,
activeTabId,
slot,
onTabSelect,
onTabClose,
onReorderTabs,
onCrossSlotDrop,
}: SlotTabBarProps) {
const [nodeTitles, setNodeTitles] = useState<Record<string, string>>({});
const fetchedRef = useRef<Set<string>>(new Set());
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
useEffect(() => {
const nodeTabs = tabs.filter((tab) => tab.type === 'node' && tab.nodeId != null);
for (const tab of nodeTabs) {
if (fetchedRef.current.has(tab.id)) continue;
fetchedRef.current.add(tab.id);
fetch(`/api/nodes/${tab.nodeId}`)
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (payload?.success && payload.node) {
setNodeTitles((prev) => ({ ...prev, [tab.id]: payload.node.title || 'Untitled' }));
}
})
.catch(() => {
fetchedRef.current.delete(tab.id);
});
}
}, [tabs]);
useEffect(() => {
const currentIds = new Set(tabs.map((tab) => tab.id));
fetchedRef.current.forEach((id) => {
if (!currentIds.has(id)) {
fetchedRef.current.delete(id);
}
});
}, [tabs]);
const handleDragStart = useCallback((event: React.DragEvent, index: number, tab: SlotTab) => {
event.dataTransfer.setData(
'application/x-rah-tab',
JSON.stringify({
sourceSlot: slot,
sourceIndex: index,
tab,
})
);
event.dataTransfer.effectAllowed = 'move';
}, [slot]);
const handleDrop = useCallback((event: React.DragEvent, targetIndex: number) => {
event.preventDefault();
setDragOverIndex(null);
const raw = event.dataTransfer.getData('application/x-rah-tab');
if (!raw) return;
try {
const parsed = JSON.parse(raw) as {
sourceSlot: SlotId;
sourceIndex: number;
tab: SlotTab;
};
if (parsed.sourceSlot === slot) {
onReorderTabs?.(parsed.sourceIndex, targetIndex);
return;
}
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
} catch {
// Ignore malformed drag payloads.
}
}, [onCrossSlotDrop, onReorderTabs, slot]);
const handleBarDrop = useCallback((event: React.DragEvent) => {
event.preventDefault();
setDragOverIndex(null);
const raw = event.dataTransfer.getData('application/x-rah-tab');
if (!raw) return;
try {
const parsed = JSON.parse(raw) as {
sourceSlot: SlotId;
tab: SlotTab;
};
if (parsed.sourceSlot !== slot) {
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
}
} catch {
// Ignore malformed drag payloads.
}
}, [onCrossSlotDrop, slot]);
if (tabs.length === 0) return null;
return (
<div
onDragOver={(event) => {
if (event.dataTransfer.types.includes('application/x-rah-tab')) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}}
onDrop={handleBarDrop}
style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden' }}
>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const Icon = TAB_TYPE_ICONS[tab.type];
const label = tab.type === 'node'
? truncateTitle(nodeTitles[tab.id] || 'Loading...')
: TAB_TYPE_LABELS[tab.type];
const isDragOver = dragOverIndex === index;
return (
<div
key={tab.id}
draggable={tab.type === 'node'}
onDragStart={(event) => handleDragStart(event, index, tab)}
onDragOver={(event) => {
if (!event.dataTransfer.types.includes('application/x-rah-tab')) return;
event.preventDefault();
setDragOverIndex(index);
}}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(event) => handleDrop(event, index)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
padding: '6px 10px',
borderRadius: '10px',
border: isDragOver ? '1px dashed var(--rah-accent-green)' : '1px solid var(--rah-border)',
background: isActive ? 'var(--rah-bg-hover)' : 'var(--rah-bg-panel)',
color: isActive ? 'var(--rah-text-primary)' : 'var(--rah-text-secondary)',
cursor: tab.type === 'node' ? 'grab' : 'pointer',
}}
>
<button
type="button"
onClick={() => onTabSelect(tab.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
background: 'transparent',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: 0,
}}
>
<Icon size={14} />
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{label}
</span>
</button>
{tabs.length > 1 ? (
<button
type="button"
onClick={() => onTabClose(tab.id)}
style={{
width: '16px',
height: '16px',
border: 'none',
borderRadius: '999px',
background: 'transparent',
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
>
<X size={12} />
</button>
) : null}
</div>
);
})}
</div>
);
}
+1 -6
View File
@@ -13,10 +13,8 @@ export interface ViewsPaneProps extends BasePaneProps {
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
export default function ViewsPane({
@@ -32,12 +30,11 @@ export default function ViewsPane({
pendingNodes,
onDismissPending,
externalContextFilterId,
externalDimensionFilter,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
}: ViewsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
@@ -65,10 +62,8 @@ export default function ViewsPane({
pendingNodes={pendingNodes}
onDismissPending={onDismissPending}
externalContextFilterId={externalContextFilterId}
externalDimensionFilter={externalDimensionFilter}
onContextFilterSelect={onContextFilterSelect}
onClearExternalContextFilter={onClearExternalContextFilter}
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
toolbarHost={toolbarHost}
/>
</div>
+1 -1
View File
@@ -1,9 +1,9 @@
export { default as NodePane } from './NodePane';
export { default as ContextsPane } from './ContextsPane';
export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
export { default as TablePane } from './TablePane';
export { default as SkillsPane } from './SkillsPane';
export { default as PaneHeader } from './PaneHeader';
export { default as SlotTabBar } from './SlotTabBar';
export * from './types';
+5 -6
View File
@@ -3,13 +3,12 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import type { RahNodeData } from './utils';
import { LABEL_THRESHOLD } from './utils';
import { getNodeIcon } from '@/utils/nodeIcons';
type RahNodeType = Node<RahNodeData, 'rahNode'>;
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data;
const { label, clusterLabel, edgeCount, isExpanded, dbNode, clusterColor } = data;
const isTop = !isExpanded && edgeCount > 3;
return (
@@ -20,7 +19,7 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
isTop && 'rah-map-node--top',
selected && 'rah-map-node--selected',
].filter(Boolean).join(' ')}
style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined}
style={clusterColor ? { borderLeftColor: clusterColor, borderLeftWidth: 3 } : undefined}
>
<Handle
type="target"
@@ -29,13 +28,13 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
/>
<div className="rah-map-node__title">
<span className="rah-map-node__icon">
{getNodeIcon(dbNode, dimensionIcons, 14)}
{getNodeIcon(dbNode, 14)}
</span>
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
</div>
{(isTop || isExpanded) && dimensions.length > 0 && (
{(isTop || isExpanded) && clusterLabel && (
<div className="rah-map-node__dims">
{dimensions.slice(0, 3).map(d => d.length > 12 ? d.slice(0, 11) + '\u2026' : d).join(' \u00b7 ')}
{clusterLabel.length > 24 ? `${clusterLabel.slice(0, 23)}\u2026` : clusterLabel}
</div>
)}
<Handle
+50 -59
View File
@@ -1,55 +1,44 @@
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
// Fixed palette for dimension border colors (muted, dark-theme-friendly)
const DIMENSION_COLORS = [
'#22c55e', // green
'#3b82f6', // blue
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
'#a855f7', // purple
const CLUSTER_COLORS = [
'#22c55e',
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#f97316',
'#14b8a6',
'#a855f7',
];
export type MapViewMode = 'dimension' | 'hub';
export type MapViewMode = 'context' | 'hub';
function hashDimensionColor(dimension: string): string {
function hashClusterColor(label: string): string {
let hash = 0;
for (let i = 0; i < dimension.length; i++) {
hash = ((hash << 5) - hash + dimension.charCodeAt(i)) | 0;
for (let i = 0; i < label.length; i++) {
hash = ((hash << 5) - hash + label.charCodeAt(i)) | 0;
}
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
return CLUSTER_COLORS[Math.abs(hash) % CLUSTER_COLORS.length];
}
export function getOrderedDimensions(dimensions: string[] | undefined): string[] {
if (!dimensions || dimensions.length === 0) {
return [];
}
return [...dimensions].sort((a, b) => a.localeCompare(b));
export function getNodeClusterLabel(node: DbNode): string {
return node.context?.name?.trim() || 'Unscoped';
}
export function getPrimaryDimension(dimensions: string[] | undefined): string {
const ordered = getOrderedDimensions(dimensions);
return ordered[0] || 'Unsorted';
}
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
const primary = getPrimaryDimension(dimensions);
return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary);
export function getClusterColor(label: string): string {
return label === 'Unscoped' ? '#4b5563' : hashClusterColor(label);
}
export interface RahNodeData {
label: string;
dimensions: string[];
clusterLabel: string;
edgeCount: number;
isExpanded: boolean;
dbNode: DbNode;
dimensionIcons?: Record<string, string>;
primaryDimensionColor?: string;
clusterColor?: string;
clusterKey?: string;
[key: string]: unknown;
}
@@ -64,12 +53,15 @@ export function getSavedMapPosition(
const metadata = typeof node.metadata === 'string'
? safeParseJSON(node.metadata)
: node.metadata;
const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined;
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
const saved = nested?.[viewMode] || scoped;
if (saved?.x !== undefined && saved?.y !== undefined) {
return { x: saved.x, y: saved.y };
}
return null;
}
@@ -78,11 +70,12 @@ function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
}
function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
const groups = new Map<string, DbNode[]>();
for (const node of nodes) {
const key = getPrimaryDimension(node.dimensions);
const key = getNodeClusterLabel(node);
const existing = groups.get(key) || [];
existing.push(node);
groups.set(key, existing);
@@ -97,7 +90,10 @@ function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number)
const originY = centerY - ((rows - 1) * clusterGapY) / 2;
clusterKeys.forEach((clusterKey, clusterIndex) => {
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => {
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
});
const clusterColumn = clusterIndex % columns;
const clusterRow = Math.floor(clusterIndex / columns);
const clusterCenterX = originX + clusterColumn * clusterGapX;
@@ -147,6 +143,7 @@ function buildHubLayout(
const orphanNodes: DbNode[] = [];
for (const node of sortedNodes) {
if (hubIds.has(node.id)) continue;
const neighbours = adjacency.get(node.id) || [];
const connectedHub = hubs
.filter((hub) => neighbours.includes(hub.id))
@@ -193,9 +190,10 @@ function buildHubLayout(
}
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
if (viewMode === 'dimension') {
return getPrimaryDimension(node.dimensions);
if (viewMode === 'context') {
return getNodeClusterLabel(node);
}
return `hub:${node.id}`;
}
@@ -207,14 +205,13 @@ export function toRFNodes(
selectedNodeId: number | null,
connectedNodeIds: Set<number>,
existingPositions: Map<string, { x: number; y: number }>,
dimensionIcons: Record<string, string> | undefined,
viewMode: MapViewMode,
dbEdges: DbEdge[],
): RFNode<RahNodeData>[] {
const allNodes = getAllNodes(baseNodes, expandedNodes);
const hasSelection = selectedNodeId !== null;
const clusterLayout = viewMode === 'dimension'
? buildDimensionLayout(allNodes, centerX, centerY)
const clusterLayout = viewMode === 'context'
? buildContextLayout(allNodes, centerX, centerY)
: buildHubLayout(allNodes, dbEdges, centerX, centerY);
const baseNodeIds = new Set(baseNodes.map((node) => node.id));
@@ -225,6 +222,7 @@ export function toRFNodes(
const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY };
const pos = existingPos || savedPos || fallbackPos;
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
const clusterLabel = getNodeClusterLabel(node);
return {
id,
@@ -233,23 +231,17 @@ export function toRFNodes(
className: isDimmed ? 'dimmed' : undefined,
data: {
label: node.title || 'Untitled',
dimensions: getOrderedDimensions(node.dimensions),
clusterLabel,
edgeCount: node.edge_count ?? 0,
isExpanded: !baseNodeIds.has(node.id),
dbNode: node,
dimensionIcons,
primaryDimensionColor: getDimensionColor(node.dimensions),
clusterKey: viewMode === 'dimension' ? getPrimaryDimension(node.dimensions) : undefined,
clusterColor: getClusterColor(clusterLabel),
clusterKey: viewMode === 'context' ? clusterLabel : undefined,
},
};
});
}
/**
* Transform DB edges into React Flow edges, filtering to only those
* connecting nodes currently in the graph.
* When a node is selected, connected edges are highlighted and others dimmed.
*/
export function toRFEdges(
dbEdges: DbEdge[],
nodeIds: Set<string>,
@@ -258,19 +250,18 @@ export function toRFEdges(
const hasSelection = selectedNodeId !== null;
return dbEdges
.filter(e => nodeIds.has(String(e.from_node_id)) && nodeIds.has(String(e.to_node_id)))
.map(e => {
.filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id)))
.map((edge) => {
const isConnected = hasSelection && (
e.from_node_id === selectedNodeId || e.to_node_id === selectedNodeId
edge.from_node_id === selectedNodeId || edge.to_node_id === selectedNodeId
);
const isDimmed = hasSelection && !isConnected;
const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : '';
const explanation = typeof edge.context?.explanation === 'string' ? edge.context.explanation : '';
return {
id: String(e.id),
source: String(e.from_node_id),
target: String(e.to_node_id),
id: String(edge.id),
source: String(edge.from_node_id),
target: String(edge.to_node_id),
type: 'rahEdge',
animated: isConnected,
data: { explanation },
@@ -284,7 +275,7 @@ export function toRFEdges(
});
}
function safeParseJSON(str: string | null | undefined): Record<string, unknown> | null {
function safeParseJSON(str: string | null | undefined): Record<string, any> | null {
if (!str || str === 'null') return null;
try {
return JSON.parse(str);
+45 -43
View File
@@ -1,41 +1,38 @@
import React from 'react';
import { Node } from '@/types/database';
import type { FocusedSkill } from '@/types/skills';
export type SlotId = 'A' | 'B' | 'C';
export type NavigablePaneType = Exclude<PaneType, 'node'>;
// Stub type for delegation (delegation system removed in rah-light)
export type AgentDelegation = {
id: number;
sessionId: string;
task: string;
context: string[];
status: 'queued' | 'in_progress' | 'completed' | 'failed';
summary?: string | null;
agentType: string;
createdAt: string;
updatedAt: string;
};
// The pane types
export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills';
// Pane types (chat removed in rah-light)
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
// State for each slot
export interface SlotState {
// A single tab within a slot
export interface SlotTab {
id: string; // 'views', 'map', or 'node-{nodeId}'
type: PaneType;
// NodePane state
nodeTabs?: number[];
activeNodeTab?: number | null;
// DimensionsPane state
selectedDimension?: string | null;
viewMode?: 'grid' | 'list' | 'kanban';
nodeId?: number; // Only for type === 'node'
}
// State for each slot — persistent tabs
export interface SlotState {
tabs: SlotTab[];
activeTabId: string;
}
// Helper to create a consistent tab ID
export function createTabId(type: PaneType, nodeId?: number): string {
return type === 'node' && nodeId != null ? `node-${nodeId}` : type;
}
// Helper to get the active tab from a slot state
export function getActiveTab(state: SlotState): SlotTab | undefined {
return state.tabs.find(t => t.id === state.activeTabId);
}
// Actions panes can emit to the layout
export type PaneAction =
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
| { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
| { type: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' };
@@ -70,14 +67,24 @@ export interface HighlightedPassage {
selectedText: string;
}
// ChatPaneProps removed in rah-light
// DimensionsPane specific props
export interface DimensionsPaneProps extends BasePaneProps {
onNodeOpen: (nodeId: number) => void;
refreshToken: number;
onDataChanged?: () => void;
onDimensionSelect?: (dimensionName: string | null) => void;
export interface ChatPanelProps {
isOpen: boolean;
onClose: () => void;
onOpen: () => void;
openTabsData: Node[];
activeTabId: number | null;
activeContextId?: number | null;
activeContextName?: string | null;
onClearContext?: () => void;
focusedSkill?: FocusedSkill | null;
onClearFocusedSkill?: () => void;
onNodeClick?: (nodeId: number) => void;
chatMessages?: unknown[];
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
highlightedPassage?: HighlightedPassage | null;
onClearPassage?: () => void;
onboardingHint?: string | null;
onDismissOnboardingHint?: () => void;
}
// MapPane specific props
@@ -92,13 +99,12 @@ export interface ViewsPaneProps extends BasePaneProps {
onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
export interface ContextsPaneProps extends BasePaneProps {
onNodeOpen: (nodeId: number) => void;
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
}
@@ -122,7 +128,6 @@ export interface PaneHeaderProps {
export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes',
contexts: 'Contexts',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
table: 'Table',
@@ -131,11 +136,8 @@ export const PANE_LABELS: Record<PaneType, string> = {
// Default slot states
export const DEFAULT_SLOT_A: SlotState = {
type: 'node',
nodeTabs: [],
activeNodeTab: null,
tabs: [{ id: 'views', type: 'views' }],
activeTabId: 'views',
};
export const DEFAULT_SLOT_B: SlotState = {
type: 'views',
};
export const DEFAULT_SLOT_B: SlotState | null = null;
+1 -1
View File
@@ -23,7 +23,7 @@ export default function ApiKeysViewer() {
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
<ul style={featuresListStyle}>
<li>Auto-generated descriptions for new nodes</li>
<li>Smart dimension assignment</li>
<li>Edge explanation inference</li>
<li>Semantic search via embeddings</li>
</ul>
<div style={noteStyle}>
+123 -110
View File
@@ -3,123 +3,126 @@
import { useEffect, useState, type CSSProperties } from 'react';
import type { Node } from '@/types/database';
interface AutoContextSettings {
autoContextEnabled: boolean;
lastPinnedMigration?: string;
}
interface NodeWithMetrics extends Node {
edge_count?: number;
}
interface CapsuleData {
userProfile: string;
agentProfile: string;
lastUpdatedAt: string;
}
export default function ContextViewer() {
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
const [enabled, setEnabled] = useState(false);
const [capsule, setCapsule] = useState<CapsuleData | null>(null);
const [loadingNodes, setLoadingNodes] = useState(true);
const [loadingSettings, setLoadingSettings] = useState(true);
const [saving, setSaving] = useState(false);
const [loadingCapsule, setLoadingCapsule] = useState(true);
const [resetting, setResetting] = useState(false);
const loadNodes = async () => {
try {
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
const payload = await res.json();
setNodes(payload.data || []);
} catch (error) {
console.error(error);
setNodes([]);
} finally {
setLoadingNodes(false);
}
};
const loadCapsule = async () => {
try {
const res = await fetch('/api/rah/memory');
const payload = await res.json();
setCapsule(payload.data?.capsule ?? null);
} catch (error) {
console.error(error);
setCapsule(null);
} finally {
setLoadingCapsule(false);
}
};
useEffect(() => {
const loadNodes = async () => {
try {
const res = await fetch('/api/nodes?sortBy=edges&limit=10');
const payload = await res.json();
setNodes(payload.data || []);
} catch (e) {
console.error(e);
} finally {
setLoadingNodes(false);
}
};
const loadSettings = async () => {
try {
const res = await fetch('/api/system/auto-context');
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
if (payload.success && payload.data) {
setEnabled(payload.data.autoContextEnabled);
}
} catch (e) {
console.error(e);
} finally {
setLoadingSettings(false);
}
};
loadNodes();
loadSettings();
void loadNodes();
void loadCapsule();
}, []);
const handleToggle = async () => {
if (saving) return;
setSaving(true);
const handleReset = async () => {
if (!confirm('Reset the context capsule to neutral defaults?')) return;
try {
const res = await fetch('/api/system/auto-context', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoContextEnabled: !enabled }),
});
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
if (payload.success && payload.data) {
setEnabled(payload.data.autoContextEnabled);
}
} catch (e) {
console.error(e);
setResetting(true);
await fetch('/api/rah/memory', { method: 'DELETE' });
await loadCapsule();
} finally {
setSaving(false);
setResetting(false);
}
};
return (
<div style={containerStyle}>
<p style={descStyle}>
Context summaries and anchor nodes are used first. Global hub nodes remain secondary diagnostics in background context.
RA-H now carries one compact context capsule into every conversation. It stays neutral by default,
updates only on meaningful changes, and sits alongside hub nodes rather than replacing them.
</p>
{/* Toggle */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={labelStyle}>Auto-Context</div>
<div style={subLabelStyle}>
{loadingSettings ? 'Loading...' : enabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<button
onClick={handleToggle}
disabled={loadingSettings || saving}
style={{
...toggleStyle,
background: enabled ? 'var(--settings-active-bg)' : 'var(--settings-code-bg)',
}}
>
<span style={{
...toggleKnobStyle,
left: enabled ? 26 : 4,
}} />
</button>
<div style={capsuleHeaderStyle}>
<div>
<div style={labelStyle}>Context Capsule</div>
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
</div>
<button
onClick={handleReset}
disabled={resetting}
style={resetButtonStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{resetting ? 'Resetting...' : 'Reset Capsule'}
</button>
</div>
{loadingCapsule ? (
<div style={mutedStyle}>Loading capsule...</div>
) : capsule ? (
<div style={capsuleGridStyle}>
<CapsuleSection
title="User"
value={capsule.userProfile}
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
/>
<CapsuleSection title="Agent" value={capsule.agentProfile} />
</div>
) : (
<div style={mutedStyle}>Capsule unavailable.</div>
)}
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
<div style={subLabelStyle}>
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
</div>
{/* Nodes List */}
<div style={labelStyle}>Top Nodes</div>
{loadingNodes ? (
<div style={mutedStyle}>Loading...</div>
<div style={mutedStyle}>Loading hub nodes...</div>
) : nodes.length === 0 ? (
<div style={mutedStyle}>No connected nodes yet.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
{nodes.map((node) => (
<div key={node.id} style={nodeCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
</div>
{node.dimensions && node.dimensions.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
{node.dimensions.slice(0, 3).map((dim) => (
<span key={dim} style={dimTagStyle}>{dim}</span>
))}
{node.description && (
<div style={nodeDescriptionStyle}>{node.description}</div>
)}
{node.context?.name && (
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
<span style={contextTagStyle}>{node.context.name}</span>
</div>
)}
</div>
@@ -130,52 +133,62 @@ export default function ContextViewer() {
);
}
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5 };
function CapsuleSection({
title,
value,
footer,
}: {
title: string;
value: string;
footer?: string;
}) {
return (
<div style={cardStyle}>
<div style={labelStyle}>{title}</div>
<div style={capsuleBodyStyle}>{value}</div>
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
</div>
);
}
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 };
const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 };
const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 };
const cardStyle: CSSProperties = {
background: 'var(--settings-card-bg)',
border: '1px solid var(--settings-border)',
borderRadius: 8,
padding: 16,
marginBottom: 24,
minHeight: 140,
};
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 };
const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)' };
const toggleStyle: CSSProperties = {
width: 48,
height: 26,
borderRadius: 13,
border: '1px solid var(--settings-border)',
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 };
const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' };
const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 };
const resetButtonStyle: CSSProperties = {
padding: '8px 14px',
background: 'transparent',
border: '1px solid var(--settings-border-strong)',
borderRadius: '6px',
color: 'var(--settings-text)',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
position: 'relative',
transition: 'background 0.15s',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
};
const toggleKnobStyle: CSSProperties = {
position: 'absolute',
top: 4,
width: 18,
height: 18,
borderRadius: '50%',
background: 'var(--settings-text)',
transition: 'left 0.15s',
};
const nodeCardStyle: CSSProperties = {
padding: '12px 14px',
background: 'var(--settings-card-bg)',
border: '1px solid var(--settings-border)',
borderRadius: 6,
};
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' };
const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 };
const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
const dimTagStyle: CSSProperties = {
const contextTagStyle: CSSProperties = {
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
+119 -273
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Folder, Link as LinkIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
import { Link as LinkIcon } from 'lucide-react';
import { Node } from '@/types/database';
import { openExternalUrl } from '@/utils/openExternalUrl';
@@ -11,16 +11,9 @@ interface NodeWithMetrics extends Node {
interface AppliedFilters {
search?: string;
dimensions?: string[];
sortBy: 'updated' | 'edges';
}
interface PopularDimension {
dimension: string;
count: number;
isPriority: boolean;
}
const LIMIT = 50;
export default function DatabaseViewer() {
@@ -30,16 +23,12 @@ export default function DatabaseViewer() {
const [error, setError] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState('');
const [dimensionInput, setDimensionInput] = useState('');
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
const [hubNodeIds, setHubNodeIds] = useState<Set<number>>(new Set());
const filtersActive = useMemo(
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
() => Boolean(appliedFilters.search),
[appliedFilters]
);
@@ -53,9 +42,6 @@ export default function DatabaseViewer() {
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
params.set('sortBy', filters.sortBy);
if (filters.search) params.set('search', filters.search);
if (filters.dimensions && filters.dimensions.length > 0) {
params.set('dimensions', filters.dimensions.join(','));
}
const response = await fetch(`/api/nodes?${params.toString()}`);
if (!response.ok) {
@@ -74,82 +60,39 @@ export default function DatabaseViewer() {
);
useEffect(() => {
fetchNodes(page, appliedFilters);
void fetchNodes(page, appliedFilters);
}, [page, appliedFilters, fetchNodes]);
useEffect(() => {
const loadLockedDimensions = async () => {
try {
const response = await fetch('/api/dimensions/popular');
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const priorityDimensions: PopularDimension[] = result.data;
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
}
} catch (err) {
console.warn('Failed to load locked dimensions', err);
}
};
loadLockedDimensions();
}, []);
useEffect(() => {
const loadContextHubs = async () => {
const loadHubNodes = async () => {
try {
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
if (!response.ok) return;
const payload = await response.json();
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
setContextHubIds(ids);
setHubNodeIds(ids);
} catch (err) {
console.warn('Failed to load auto-context hubs', err);
console.warn('Failed to load graph hubs', err);
}
};
loadContextHubs();
void loadHubNodes();
}, []);
const handleApplyFilters = () => {
const payload: AppliedFilters = {
sortBy,
};
const payload: AppliedFilters = { sortBy };
if (searchInput.trim()) payload.search = searchInput.trim();
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
setAppliedFilters(payload);
setPage(1);
};
const handleClearFilters = () => {
setSearchInput('');
setDimensionInput('');
setDimensionFilters([]);
setSortBy('updated');
setAppliedFilters({ sortBy: 'updated' });
setPage(1);
};
const handleAddDimension = () => {
const next = dimensionInput.trim();
if (!next) return;
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
setDimensionInput('');
};
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddDimension();
}
};
const handleRemoveDimension = (dimension: string) => {
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
};
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value === 'edges' ? 'edges' : 'updated';
setSortBy(value);
@@ -195,27 +138,15 @@ export default function DatabaseViewer() {
};
if (loading) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
Loading database...
</div>
);
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>Loading database...</div>;
}
if (error) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
Error: {error}
</div>
);
return <div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>Error: {error}</div>;
}
if (nodes.length === 0) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
No nodes found
</div>
);
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>No nodes found</div>;
}
return (
@@ -223,71 +154,38 @@ export default function DatabaseViewer() {
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid var(--rah-border-strong)',
borderBottom: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Search
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="title or content"
style={{
background: 'var(--rah-bg-surface)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
minWidth: '220px',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
Dimension
<div style={{ display: 'flex', gap: '8px' }}>
<input
value={dimensionInput}
onChange={(e) => setDimensionInput(e.target.value)}
onKeyDown={handleDimensionKeyDown}
placeholder="e.g. research"
style={{
background: 'var(var(--rah-bg-surface))',
border: '1px solid var(--rah-border-strong)',
color: 'var(var(--rah-text-base))',
padding: '6px 10px',
borderRadius: '4px',
minWidth: '180px',
}}
/>
<button
onClick={handleAddDimension}
style={{
padding: '6px 10px',
background: '#1f3529',
border: '1px solid #264333',
borderRadius: '4px',
color: '#c4f5d2',
fontSize: '12px',
cursor: 'pointer',
}}
>
Add
</button>
</div>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Sort by
<select
value={sortBy}
onChange={handleSortChange}
style={{
background: 'var(--rah-bg-surface)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
}}
@@ -298,46 +196,8 @@ export default function DatabaseViewer() {
</label>
</div>
{dimensionFilters.length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{dimensionFilters.map((dimension) => (
<span
key={dimension}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
<Folder size={12} />
{dimension}
<button
onClick={() => handleRemoveDimension(dimension)}
style={{
marginLeft: '2px',
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
fontSize: '11px',
}}
aria-label={`Remove ${dimension}`}
>
×
</button>
</span>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>{filterStatus}</div>
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleApplyFilters}
@@ -357,10 +217,10 @@ export default function DatabaseViewer() {
onClick={handleClearFilters}
style={{
padding: '8px 16px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
color: 'var(--rah-text-secondary)',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px',
}}
@@ -372,10 +232,10 @@ export default function DatabaseViewer() {
disabled={isFirstPage || filtersActive}
style={{
padding: '8px 12px',
background: isFirstPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isFirstPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
color: isFirstPage || filtersActive ? '#555' : '#ccc',
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
@@ -387,10 +247,10 @@ export default function DatabaseViewer() {
disabled={isLastPage || filtersActive}
style={{
padding: '8px 12px',
background: isLastPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isLastPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
color: isLastPage || filtersActive ? '#555' : '#ccc',
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
@@ -403,20 +263,20 @@ export default function DatabaseViewer() {
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, background: 'var(--rah-bg-active)', zIndex: 1 }}>
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<tr>
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
{['Node', 'Context', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
<th
key={column}
style={{
padding: '12px 16px',
textAlign: 'left',
fontSize: '11px',
color: 'var(--rah-text-muted)',
color: '#888',
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontWeight: 'normal',
borderBottom: '1px solid var(--rah-border-strong)',
borderBottom: '1px solid #2a2a2a',
}}
>
{column}
@@ -425,105 +285,91 @@ export default function DatabaseViewer() {
</tr>
</thead>
<tbody>
{nodes.map((node, index) => {
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
return (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-surface)',
borderBottom: '1px solid var(--rah-bg-surface)',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)', display: 'flex', alignItems: 'center', gap: '8px' }}>
{node.title || 'Untitled node'}
{node.link && (
<button
onClick={() => {
void openExternalUrl(node.link!).catch((error) => {
console.error('[DatabaseViewer] Failed to open node link', error);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</div>
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{node.dimensions && node.dimensions.length > 0 ? (
node.dimensions.slice(0, 3).map((dimension) => (
<span
key={`${node.id}-${dimension}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '999px',
background: '#111914',
border: '1px solid #1f2f24',
fontSize: '11px',
color: '#bbf7d0',
}}
>
<Folder size={11} />
{dimension}
</span>
))
) : (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>No categories</span>
)}
{node.dimensions && node.dimensions.length > 3 && (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>+{node.dimensions.length - 3} more</span>
{nodes.map((node, index) => (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
borderBottom: '1px solid #111',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '34%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
{node.title || 'Untitled node'}
{node.link && (
<button
onClick={() => {
void openExternalUrl(node.link!).catch((openError) => {
console.error('[DatabaseViewer] Failed to open original link', openError);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)' }}>{node.edge_count ?? 0}</div>
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>connections</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{contextHubIds.has(node.id) ? (
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
Auto-context hub
</span>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></span>
)}
{belongsToLocked && (
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.updated_at)}</div>
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.updated_at)}</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.created_at)}</div>
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.created_at)}</div>
</td>
</tr>
);
})}
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
{node.description && <span style={{ fontSize: '12px', color: '#9ca3af' }}>{node.description}</span>}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
{node.context?.name ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
{node.context.name}
</span>
) : (
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
)}
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{hubNodeIds.has(node.id) ? (
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>Graph hub</span>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></span>
)}
{node.metadata?.state && (
<span style={{ fontSize: '11px', color: '#7de8a5' }}>{String(node.metadata.state)}</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -127,7 +127,7 @@ export default function ExternalAgentsPanel() {
<div style={{ display: 'grid', gap: '16px' }}>
<HelperCard
title="Add to RA-H"
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
body={`"Summarize our meeting and add it to RA-H. If a context is obvious, use it. If not, leave context blank."`}
/>
<HelperCard
title="Search RA-H"
@@ -0,0 +1,5 @@
"use client";
export default function AutoUpdateManager() {
return null;
}
+19 -172
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import { ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import type { Node } from '@/types/database';
import { formatRelativeDate } from '@/utils/formatDate';
@@ -17,12 +17,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
const PAGE_SIZE = 50;
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
}
interface DatabaseTableViewProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
@@ -46,41 +40,11 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
const [searchQuery, setSearchQuery] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const filterPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
// Fetch dimensions
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/dimensions');
const data = await res.json();
if (data.success) setDimensions(data.data || []);
} catch (e) {
console.error('Error fetching dimensions:', e);
}
})();
}, [refreshToken]);
// Fetch nodes
const fetchNodes = useCallback(async () => {
setLoading(true);
@@ -91,10 +55,6 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
sortBy: sortOrder,
});
if (activeSearch) params.set('search', activeSearch);
if (selectedFilters.length > 0) {
params.set('dimensions', selectedFilters.join(','));
params.set('dimensionsMatch', 'all');
}
const res = await fetch(`/api/nodes?${params}`);
const data = await res.json();
@@ -107,32 +67,27 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
} finally {
setLoading(false);
}
}, [page, sortOrder, activeSearch, selectedFilters]);
}, [page, sortOrder, activeSearch]);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshToken]);
// Reset to page 1 when filters/sort/search change
const filtersKey = selectedFilters.join(',');
useEffect(() => {
setPage(1);
}, [sortOrder, activeSearch, filtersKey]);
}, [sortOrder, activeSearch]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterPicker, showSortDropdown]);
}, [showSortDropdown]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -189,108 +144,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div>
</form>
{/* Filter chips + button */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, flexWrap: 'wrap' }}>
{selectedFilters.map(f => (
<div key={f} style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '2px 7px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '4px', fontSize: '11px', color: '#5a9'
}}>
{f}
<button
onClick={() => setSelectedFilters(selectedFilters.filter(x => x !== f))}
style={{ background: 'transparent', border: 'none', color: '#5a9', cursor: 'pointer', padding: 0, display: 'flex' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={10} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent',
border: '1px solid var(--rah-border)', borderRadius: '5px',
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<Filter size={11} />
Filter
</button>
{showFilterPicker && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
border: '1px solid transparent', borderRadius: '6px',
color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
}}
/>
{filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
No matching dimensions
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => {
if (!selectedFilters.includes(d.dimension)) {
setSelectedFilters([...selectedFilters, d.dimension]);
}
setShowFilterPicker(false);
setFilterSearchQuery('');
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 10px', background: 'transparent',
border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
{d.count}
</span>
</button>
))
)}
</div>
)}
</div>
{selectedFilters.length > 0 && (
<button
onClick={() => setSelectedFilters([])}
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
Clear
</button>
)}
</div>
<div style={{ flex: 1 }} />
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
@@ -393,7 +247,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
) : nodes.length === 0 ? (
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
</div>
) : (
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
@@ -402,9 +256,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<th style={thStyle({ width: '240px' })}>TITLE</th>
<th style={thStyle({ width: '55px', textAlign: 'right' })}>ID</th>
<th style={thStyle({ width: '200px' })}>DESCRIPTION</th>
<th style={thStyle({ width: '160px' })}>NOTES</th>
<th style={thStyle({ width: '160px' })}>SOURCE</th>
<th style={thStyle({ width: '180px' })}>LINK</th>
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
<th style={thStyle({ width: '160px' })}>CONTEXT</th>
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
<th style={thStyle({ width: '90px' })}>EVENT</th>
<th style={thStyle({ width: '85px' })}>UPDATED</th>
@@ -488,26 +342,19 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div>
</td>
{/* Dimensions */}
{/* Context */}
<td style={tdStyle()}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
{node.dimensions && node.dimensions.length > 0 ? (
{node.context?.name ? (
<>
{node.dimensions.slice(0, 3).map(d => (
<span key={d} style={{
fontSize: '9px', padding: '1px 5px',
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)', borderRadius: '3px',
whiteSpace: 'nowrap',
}}>
{d}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
+{node.dimensions.length - 3}
</span>
)}
<span style={{
fontSize: '9px', padding: '1px 5px',
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)', borderRadius: '3px',
whiteSpace: 'nowrap',
}}>
{node.context.name}
</span>
</>
) : (
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
+12 -26
View File
@@ -2,7 +2,6 @@
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { getNodeProcessedState } from '@/services/nodes/metadata';
interface GridViewProps {
@@ -11,7 +10,6 @@ interface GridViewProps {
}
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 120) => {
if (!content) return '';
if (content.length <= maxLength) return content;
@@ -93,7 +91,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
{getNodeIcon(node, dimensionIcons, 14)}
{getNodeIcon(node, 14)}
</div>
<div style={{
fontSize: '13px',
@@ -135,37 +133,25 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div>
)}
{/* Footer with Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
{/* Footer with Context */}
{node.context?.name && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: 'auto'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
<span
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#555'
}}>
+{node.dimensions.length - 3}
</span>
)}
color: '#888'
}}
>
{node.context.name}
</span>
</div>
)}
</button>
-536
View File
@@ -1,536 +0,0 @@
"use client";
import { useState, DragEvent } from 'react';
import { Plus, GripVertical, X } from 'lucide-react';
import { Node } from '@/types/database';
import { KanbanColumn } from '@/types/views';
interface KanbanViewProps {
nodes: Node[];
columns: KanbanColumn[];
dimensions: string[];
onNodeClick: (nodeId: number) => void;
onColumnChange: (columns: KanbanColumn[]) => void;
onNodeDimensionUpdate: (nodeId: number, newDimension: string, oldDimension?: string) => void;
}
export default function KanbanView({
nodes,
columns,
dimensions,
onNodeClick,
onColumnChange,
onNodeDimensionUpdate
}: KanbanViewProps) {
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [draggedNodeId, setDraggedNodeId] = useState<number | null>(null);
const [draggedFromColumn, setDraggedFromColumn] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
// Get nodes for a specific column (dimension)
const getNodesForColumn = (dimension: string) => {
return nodes.filter(node => node.dimensions?.includes(dimension));
};
// Get uncategorized nodes (not in any column)
const getUncategorizedNodes = () => {
const columnDimensions = columns.map(c => c.dimension);
return nodes.filter(node =>
!node.dimensions || !node.dimensions.some(d => columnDimensions.includes(d))
);
};
const handleAddColumn = (dimension: string) => {
const newColumn: KanbanColumn = {
id: `col-${Date.now()}`,
dimension,
order: columns.length
};
onColumnChange([...columns, newColumn]);
setShowColumnPicker(false);
setSearchQuery('');
};
const handleRemoveColumn = (columnId: string) => {
onColumnChange(columns.filter(c => c.id !== columnId));
};
// Node drag handlers
const handleNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => {
setDraggedNodeId(nodeId);
setDraggedFromColumn(fromColumn);
e.dataTransfer.effectAllowed = 'copyMove';
// Find the node to get its title for chat drop
const node = nodes.find(n => n.id === nodeId);
const title = node?.title || 'Untitled';
// Set MIME types for chat input drops
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: nodeId, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: nodeId, title, dimensions: node?.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${nodeId}:"${title}"]`);
};
const handleNodeDragEnd = () => {
setDraggedNodeId(null);
setDraggedFromColumn(null);
setDragOverColumn(null);
};
const handleColumnDragOver = (e: DragEvent, columnDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null) {
setDragOverColumn(columnDimension);
}
};
const handleColumnDragLeave = () => {
setDragOverColumn(null);
};
const handleColumnDrop = (e: DragEvent, targetDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null && draggedFromColumn !== targetDimension) {
onNodeDimensionUpdate(
draggedNodeId,
targetDimension,
draggedFromColumn || undefined
);
}
handleNodeDragEnd();
};
// Column reorder drag handlers
const handleColumnDragStart = (e: DragEvent, columnId: string) => {
setDraggingColumnId(columnId);
e.dataTransfer.effectAllowed = 'move';
};
const handleColumnDragEnd = () => {
setDraggingColumnId(null);
setDragOverColumnId(null);
};
const handleColumnReorderDragOver = (e: DragEvent, columnId: string) => {
e.preventDefault();
if (draggingColumnId && draggingColumnId !== columnId) {
setDragOverColumnId(columnId);
}
};
const handleColumnReorderDrop = (e: DragEvent, targetColumnId: string) => {
e.preventDefault();
if (!draggingColumnId || draggingColumnId === targetColumnId) return;
const newColumns = [...columns];
const dragIndex = newColumns.findIndex(c => c.id === draggingColumnId);
const dropIndex = newColumns.findIndex(c => c.id === targetColumnId);
if (dragIndex !== -1 && dropIndex !== -1) {
const [removed] = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, removed);
// Update order values
newColumns.forEach((col, idx) => { col.order = idx; });
onColumnChange(newColumns);
}
handleColumnDragEnd();
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!columns.some(c => c.dimension === d)
);
const sortedColumns = [...columns].sort((a, b) => a.order - b.order);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#000'
}}>
{/* Column Setup Bar */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexShrink: 0
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Columns:
</span>
{columns.length === 0 && (
<span style={{ fontSize: '11px', color: '#555', fontStyle: 'italic' }}>
Add dimensions to create columns
</span>
)}
{/* Add Column Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowColumnPicker(!showColumnPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer'
}}
>
<Plus size={12} />
Add Column
</button>
{/* Dimension Picker */}
{showColumnPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions available
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddColumn(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Click outside to close */}
{showColumnPicker && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShowColumnPicker(false)}
/>
)}
</div>
{/* Kanban Board */}
<div style={{
flex: 1,
display: 'flex',
gap: '12px',
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden'
}}>
{sortedColumns.map(column => {
const columnNodes = getNodesForColumn(column.dimension);
const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension;
const isReorderTarget = dragOverColumnId === column.id;
return (
<div
key={column.id}
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: isDropTarget ? '#0f2417' : '#0a0a0a',
border: isReorderTarget ? '2px dashed #22c55e' : '1px solid #1a1a1a',
borderRadius: '8px',
transition: 'all 0.2s'
}}
onDragOver={(e) => handleColumnDragOver(e, column.dimension)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column.dimension)}
>
{/* Column Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a',
cursor: 'grab'
}}
draggable
onDragStart={(e) => handleColumnDragStart(e, column.id)}
onDragEnd={handleColumnDragEnd}
onDragOver={(e) => handleColumnReorderDragOver(e, column.id)}
onDrop={(e) => handleColumnReorderDrop(e, column.id)}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<GripVertical size={14} color="#444" />
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#e5e5e5',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{column.dimension}
</span>
<span style={{
fontSize: '11px',
color: '#666',
background: '#1a1a1a',
padding: '2px 6px',
borderRadius: '10px'
}}>
{columnNodes.length}
</span>
</div>
<button
onClick={() => handleRemoveColumn(column.id)}
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: '#666',
display: 'flex',
alignItems: 'center'
}}
>
<X size={14} />
</button>
</div>
{/* Column Content */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{columnNodes.map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, column.dimension)}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: draggedNodeId === node.id ? '#1a1a1a' : '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer',
opacity: draggedNodeId === node.id ? 0.5 : 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (draggedNodeId !== node.id) {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#333';
}
}}
onMouseLeave={(e) => {
if (draggedNodeId !== node.id) {
e.currentTarget.style.background = '#111';
e.currentTarget.style.borderColor = '#222';
}
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#e5e5e5',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
{node.dimensions && node.dimensions.length > 1 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: '6px'
}}>
{node.dimensions
.filter(d => d !== column.dimension)
.slice(0, 2)
.map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#666'
}}
>
{dim}
</span>
))}
</div>
)}
</div>
))}
{columnNodes.length === 0 && (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#444',
fontSize: '11px'
}}>
Drop nodes here
</div>
)}
</div>
</div>
);
})}
{/* Uncategorized Column (if nodes exist without column dimensions) */}
{columns.length > 0 && getUncategorizedNodes().length > 0 && (
<div
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
border: '1px dashed #333',
borderRadius: '8px'
}}
>
<div style={{
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a'
}}>
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Uncategorized ({getUncategorizedNodes().length})
</span>
</div>
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{getUncategorizedNodes().map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, '__uncategorized__')}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer'
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#888',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{columns.length === 0 && (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '13px'
}}>
Add dimension columns to organize your nodes
</div>
)}
</div>
</div>
);
}
+14 -35
View File
@@ -3,7 +3,6 @@
import { Inbox } from 'lucide-react';
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { formatRelativeDate } from '@/utils/formatDate';
import { getNodeProcessedState } from '@/services/nodes/metadata';
@@ -13,7 +12,6 @@ interface ListViewProps {
}
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 100) => {
if (!content) return '';
@@ -97,7 +95,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
{getNodeIcon(node, dimensionIcons, 16)}
{getNodeIcon(node, 16)}
</div>
{/* Content */}
@@ -148,38 +146,19 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
}}>
{processedState}
</span>
{/* Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
padding: '2px 6px',
fontSize: '11px',
color: 'var(--rah-text-muted)'
}}>
+{node.dimensions.length - 3}
</span>
)}
</div>
{node.context?.name && (
<span
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{node.context.name}
</span>
)}
{/* Date */}
-242
View File
@@ -1,242 +0,0 @@
"use client";
import { useState } from 'react';
import { X, Plus, ChevronDown } from 'lucide-react';
import { ViewFilter } from '@/types/views';
interface ViewFiltersProps {
filters: ViewFilter[];
filterLogic: 'and' | 'or';
dimensions: string[];
onFilterChange: (filters: ViewFilter[]) => void;
onFilterLogicChange: (logic: 'and' | 'or') => void;
}
export default function ViewFilters({
filters,
filterLogic,
dimensions,
onFilterChange,
onFilterLogicChange
}: ViewFiltersProps) {
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleAddFilter = (dimension: string) => {
if (!filters.some(f => f.dimension === dimension)) {
onFilterChange([...filters, { dimension, operator: 'includes' }]);
}
setShowDimensionPicker(false);
setSearchQuery('');
};
const handleRemoveFilter = (dimension: string) => {
onFilterChange(filters.filter(f => f.dimension !== dimension));
};
const handleToggleOperator = (dimension: string) => {
onFilterChange(filters.map(f =>
f.dimension === dimension
? { ...f, operator: f.operator === 'includes' ? 'excludes' : 'includes' }
: f
));
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!filters.some(f => f.dimension === d)
);
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexWrap: 'wrap',
minHeight: '40px'
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Filters:
</span>
{/* Filter Chips */}
{filters.map(filter => (
<div
key={filter.dimension}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: filter.operator === 'includes' ? '#0f2417' : '#2a1515',
border: `1px solid ${filter.operator === 'includes' ? '#166534' : '#7f1d1d'}`,
borderRadius: '4px',
fontSize: '11px',
color: filter.operator === 'includes' ? '#22c55e' : '#ef4444'
}}
>
<button
onClick={() => handleToggleOperator(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0 2px',
cursor: 'pointer',
color: 'inherit',
fontSize: '10px',
opacity: 0.7
}}
title={filter.operator === 'includes' ? 'Click to exclude' : 'Click to include'}
>
{filter.operator === 'includes' ? '+' : '-'}
</button>
<span>{filter.dimension}</span>
<button
onClick={() => handleRemoveFilter(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0',
cursor: 'pointer',
color: 'inherit',
display: 'flex',
alignItems: 'center'
}}
>
<X size={12} />
</button>
</div>
))}
{/* Add Filter Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowDimensionPicker(!showDimensionPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<Plus size={12} />
Add
</button>
{/* Dimension Picker Dropdown */}
{showDimensionPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions found
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddFilter(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Logic Toggle */}
{filters.length > 1 && (
<button
onClick={() => onFilterLogicChange(filterLogic === 'and' ? 'or' : 'and')}
style={{
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
color: filterLogic === 'and' ? '#22c55e' : '#3b82f6',
cursor: 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
title={filterLogic === 'and' ? 'Match ALL filters' : 'Match ANY filter'}
>
{filterLogic}
</button>
)}
{/* Click outside to close */}
{showDimensionPicker && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99
}}
onClick={() => setShowDimensionPicker(false)}
/>
)}
</div>
);
}
+155 -507
View File
@@ -1,11 +1,10 @@
"use client";
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
import type { ContextSummary, Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { usePersistentState } from '@/hooks/usePersistentState';
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
import { getNodeProcessedState } from '@/services/nodes/metadata';
@@ -24,41 +23,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
const DOCUMENT_MAX_WIDTH = '980px';
const pickerRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 10px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: 'var(--rah-text-secondary)',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
};
const pickerCountStyle: React.CSSProperties = {
color: 'var(--rah-text-muted)',
fontSize: '10px',
background: 'var(--rah-bg-active)',
padding: '1px 6px',
borderRadius: '10px',
};
interface ColumnFilter {
id: string;
dimension: string;
}
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
description?: string | null;
}
interface ViewsOverlayProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
@@ -66,10 +30,8 @@ interface ViewsOverlayProps {
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
toolbarHost?: HTMLDivElement | null;
}
@@ -225,6 +187,29 @@ function SkeletonCard() {
);
}
const pickerRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 10px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: 'var(--rah-text-secondary)',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
};
const pickerCountStyle: React.CSSProperties = {
color: 'var(--rah-text-muted)',
fontSize: '10px',
background: 'var(--rah-bg-active)',
padding: '1px 6px',
borderRadius: '10px',
};
export default function ViewsOverlay({
onNodeClick,
onNodeOpenInOtherPane,
@@ -232,17 +217,11 @@ export default function ViewsOverlay({
pendingNodes,
onDismissPending,
externalContextFilterId = null,
externalDimensionFilter = null,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
toolbarHost,
}: ViewsOverlayProps) {
const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true);
const [contexts, setContexts] = useState<ContextSummary[]>([]);
const [contextsLoading, setContextsLoading] = useState(true);
@@ -256,13 +235,8 @@ export default function ViewsOverlay({
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
// Filter system state
const [columns, setColumns] = useState<ColumnFilter[]>([]);
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const processedFilter: ProcessedFilter = sortOrder === 'processed'
@@ -271,41 +245,6 @@ export default function ViewsOverlay({
? 'not_processed'
: 'all';
// Derive selectedFilters for backward compatibility (unique dimensions)
const selectedFilters = useMemo(() => {
if (externalDimensionFilter) {
return [externalDimensionFilter];
}
return [...new Set(columns.map(c => c.dimension))];
}, [columns, externalDimensionFilter]);
// Sorted dimensions (locked first)
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
// Fetch functions
const fetchDimensions = useCallback(async () => {
setDimensionsLoading(true);
try {
const response = await fetch('/api/dimensions');
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch dimensions');
}
setDimensions(data.data || []);
} catch (error) {
console.error('Error fetching dimensions:', error);
} finally {
setDimensionsLoading(false);
}
}, []);
const fetchContexts = useCallback(async () => {
setContextsLoading(true);
try {
@@ -364,135 +303,35 @@ export default function ViewsOverlay({
}
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
if (filters.length === 0) {
fetchAllNodes();
return;
}
setFilteredNodesLoading(true);
try {
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
? 'updated'
: sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes');
}
const nodes: Node[] = data.data || [];
if (sortOrder === 'custom' && customOrder.length > 0) {
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
const ordered: Node[] = [];
const unordered: Node[] = [];
for (const node of nodes) {
if (orderMap.has(node.id)) {
ordered.push(node);
} else {
unordered.push(node);
}
}
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
} else {
setFilteredNodes(applyProcessedFilter(nodes));
}
} catch (error) {
console.error('Error fetching filtered nodes:', error);
} finally {
setFilteredNodesLoading(false);
}
}, [fetchAllNodes, sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
// Stringify filters for stable dependency
const filtersKey = selectedFilters.join(',');
// Fetch dimensions on mount
useEffect(() => {
fetchDimensions();
}, [fetchDimensions]);
// Fetch contexts on mount
useEffect(() => {
fetchContexts();
}, [fetchContexts]);
// Fetch nodes on mount and when filters/sort/refreshToken change
// Fetch nodes on mount and when sort/refreshToken/context filter change
useEffect(() => {
if (refreshToken > 0) {
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
}
if (selectedFilters.length > 0) {
fetchFilteredNodes(selectedFilters);
} else {
fetchAllNodes();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersKey, sortOrder, refreshToken, externalContextFilterId]);
fetchAllNodes();
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
// Also refresh dimensions when data changes (for filter picker counts)
// Refresh contexts when data changes
useEffect(() => {
if (refreshToken > 0) {
fetchDimensions();
fetchContexts();
}
}, [refreshToken, fetchDimensions, fetchContexts]);
// Column management
const addColumn = (dimension: string) => {
if (externalDimensionFilter) {
return;
}
const newColumn: ColumnFilter = {
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
dimension
};
setColumns([...columns, newColumn]);
setShowFilterPicker(false);
setFilterSearchQuery('');
};
const removeFilter = (dimension: string) => {
if (externalDimensionFilter && dimension === externalDimensionFilter) {
onClearExternalDimensionFilter?.();
return;
}
const idx = columns.findIndex(c => c.dimension === dimension);
if (idx !== -1) {
setColumns(columns.filter((_, i) => i !== idx));
}
};
const clearFilters = () => {
if (externalContextFilterId) {
onClearExternalContextFilter?.();
}
if (externalDimensionFilter) {
onClearExternalDimensionFilter?.();
}
setColumns([]);
};
// Filter dimensions for picker
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
}, [refreshToken, fetchContexts]);
// Close dropdowns on outside click
const filterPickerRef = useRef<HTMLDivElement>(null);
const dimensionPickerRef = useRef<HTMLDivElement>(null);
const contextPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showContextPicker, setShowContextPicker] = useState(false);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) {
setShowDimensionPicker(false);
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
setShowContextPicker(false);
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
@@ -500,7 +339,7 @@ export default function ViewsOverlay({
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showDimensionPicker, showFilterPicker, showSortDropdown]);
}, [showContextPicker, showSortDropdown]);
// Reorder handlers
const handleReorderDrop = useCallback((dropIdx: number) => {
@@ -557,17 +396,13 @@ export default function ViewsOverlay({
}
} catch (error) {
console.error('Error updating processed state from feed:', error);
if (selectedFilters.length > 0) {
void fetchFilteredNodes(selectedFilters);
} else {
void fetchAllNodes();
}
void fetchAllNodes();
}
}, [fetchAllNodes, fetchFilteredNodes, selectedFilters]);
}, [fetchAllNodes]);
// Render node card
const renderNodeCard = (node: Node, index: number) => {
const nodeIcon = getNodeIcon(node, dimensionIcons, 14);
const nodeIcon = getNodeIcon(node, 14);
const isCustomSort = sortOrder === 'custom';
const isDragSource = reorderDragIndex === index;
const isDropTarget = reorderDropIndex === index;
@@ -587,7 +422,7 @@ export default function ViewsOverlay({
onDragStart={(e) => {
const title = node.title || 'Untitled';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
}}
onDragOver={(e) => {
@@ -636,8 +471,7 @@ export default function ViewsOverlay({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
{/* Grip handle — only in custom sort mode */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', minWidth: 0 }}>
{isCustomSort && (
<div
draggable
@@ -695,137 +529,112 @@ export default function ViewsOverlay({
</button>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexDirection: 'column',
gap: '4px',
minWidth: 0,
flex: 1,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<span style={{
fontSize: '13px',
fontWeight: 500,
fontWeight: 600,
color: 'var(--rah-text-active)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
minWidth: 0,
flex: 1,
}}>
{node.title || 'Untitled'}
</span>
{node.context?.name ? (
<span style={{
fontSize: '10px',
lineHeight: 1.2,
padding: '3px 7px',
borderRadius: '999px',
background: 'rgba(34, 197, 94, 0.08)',
border: '1px solid rgba(34, 197, 94, 0.18)',
color: 'var(--rah-accent-green)',
flexShrink: 0,
maxWidth: '40%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<span
style={{
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--rah-accent-green)',
background: 'color-mix(in srgb, var(--rah-accent-green) 12%, var(--rah-bg-panel))',
border: '1px solid color-mix(in srgb, var(--rah-accent-green) 32%, var(--rah-border))',
padding: '3px 7px',
borderRadius: '999px',
lineHeight: 1.2,
flexShrink: 0,
}}
>
{node.context.name}
</span>
) : null}
</div>
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 && (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
lineHeight: '1.35',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{descPreview || 'No description'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
minWidth: 0,
overflow: 'hidden',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
minWidth: 0,
}}>
<div style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
lineHeight: '1.35',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{descPreview || 'No description'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '3px',
minWidth: 0,
maxWidth: '46%',
overflow: 'hidden',
flexShrink: 1,
whiteSpace: 'nowrap',
}}>
{node.dimensions && node.dimensions.length > 0 ? (
<>
{node.dimensions.map(d => (
<span
key={d}
style={{
fontSize: '10px',
padding: '2px 7px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
borderRadius: '999px',
flexShrink: 0,
}}
>
{d}
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 ? (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
))}
</>
) : null}
) : null}
</div>
</div>
</div>
</div>
</div>
@@ -843,87 +652,6 @@ export default function ViewsOverlay({
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
title="Context"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
fontSize: '11px',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
<Filter size={11} />
{externalContextFilterId
? (contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context')
: 'Context'}
<ChevronDown size={10} />
</button>
{showFilterPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
borderRadius: '10px',
padding: '6px',
minWidth: '220px',
maxHeight: '320px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading contexts...
</div>
) : (
<>
<button
onClick={() => {
onContextFilterSelect?.(null, null);
setShowFilterPicker(false);
}}
style={pickerRowStyle}
>
<span>All contexts</span>
</button>
{contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowFilterPicker(false);
}}
style={{
...pickerRowStyle,
background: externalContextFilterId === context.id ? 'rgba(255,255,255,0.04)' : 'transparent',
color: externalContextFilterId === context.id ? 'var(--rah-text-active)' : 'var(--rah-text-secondary)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: '8px' }}>
{context.name}
</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</>
)}
</div>
)}
</div>
{externalContextFilterId ? (
<div
style={{
@@ -938,7 +666,7 @@ export default function ViewsOverlay({
color: '#5a9'
}}
>
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
<button
onClick={() => onClearExternalContextFilter?.()}
style={{
@@ -956,53 +684,16 @@ export default function ViewsOverlay({
</div>
) : null}
{selectedFilters.map(filter => (
<div
key={filter}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '5px',
fontSize: '11px',
color: '#5a9'
}}
>
{filter}
<button
onClick={() => removeFilter(filter)}
style={{
background: 'transparent',
border: 'none',
color: '#5a9',
cursor: 'pointer',
padding: '0',
display: 'flex',
alignItems: 'center'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={11} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={dimensionPickerRef}>
<div style={{ position: 'relative' }} ref={contextPickerRef}>
<button
onClick={() => {
setShowDimensionPicker(!showDimensionPicker);
}}
title="Dimensions"
onClick={() => setShowContextPicker(!showContextPicker)}
title="Context filter"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: 'transparent',
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
@@ -1010,21 +701,12 @@ export default function ViewsOverlay({
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.borderColor = 'var(--rah-border)';
}}
>
<Filter size={11} />
Dimension
<ChevronDown size={10} />
Context
</button>
{showDimensionPicker && (
{showContextPicker && (
<div style={{
position: 'absolute',
top: '100%',
@@ -1040,71 +722,37 @@ export default function ViewsOverlay({
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%',
padding: '7px 10px',
background: 'var(--rah-bg-base)',
border: '1px solid transparent',
borderRadius: '6px',
color: 'var(--rah-text-active)',
fontSize: '12px',
marginBottom: '4px',
outline: 'none',
<button
onClick={() => {
onClearExternalContextFilter?.();
setShowContextPicker(false);
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
/>
{dimensionsLoading ? (
style={pickerRowStyle}
>
All contexts
</button>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading dimensions...
Loading contexts...
</div>
) : filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => addColumn(d.dimension)}
style={pickerRowStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={pickerCountStyle}>{d.count}</span>
</button>
))
)}
) : contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowContextPicker(false);
}}
style={pickerRowStyle}
>
<span>{context.name}</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</div>
)}
</div>
</div>
{(selectedFilters.length > 0 || externalContextFilterId) && (
<button
onClick={clearFilters}
style={{
padding: '4px 8px',
background: 'transparent',
border: 'none',
color: 'var(--rah-text-muted)',
fontSize: '11px',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
Clear all
</button>
)}
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
@@ -1234,10 +882,10 @@ export default function ViewsOverlay({
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'}
Nothing here yet
</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>
{selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
Add a node with N to get started
</span>
</div>
</div>
+4 -3
View File
@@ -20,9 +20,10 @@ Tool strategy:
- youtubeExtract, websiteExtract, and paperExtract when outside content is required.
- webSearch only when the knowledge base lacks the answer.
Dimensions:
- Create dimensions to organize content using createDimension, updateDimension, and deleteDimension tools.
- Dimensions are flat. Do not invent lock, unlock, pin, or priority behavior.
Contexts:
- Contexts are optional. Only set one when it is explicit and useful.
- Do not expect automatic context assignment.
- Improve organization through title, description, source, metadata, and edges instead of dimensions.
Response polish:
- Default to minimal reasoning effort for speed.
+5 -5
View File
@@ -24,11 +24,11 @@ Tool strategy:
- Extract content with youtubeExtract, websiteExtract, paperExtract as needed.
- When searchContentEmbeddings highlights a chunk, hydrate the node via getNodesById (or fetch the chunk) before quoting.
Dimension management:
- Create dimensions for new knowledge areas or topics using createDimension.
- Update descriptions to help the AI understand dimension purpose.
- Dimensions are flat; do not invent lock, unlock, pin, or priority behavior.
- Delete unused dimensions to keep the system clean.
Context handling:
- Contexts are optional soft organization, not a required taxonomy.
- Only set a context when it is explicit and genuinely helpful.
- Never rely on inferred dimensions or automatic context assignment.
- Node quality should come from strong title, description, source, metadata, and edges.
Response style:
- Limit to one or two short sentences. Reference nodes as [NODE:id:"title"].
+1 -1
View File
@@ -9,7 +9,7 @@ description: "Use for structured review, QA, cleanup, or governance checks acros
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
2. Edge quality: missing links, weak explanations, wrong directionality.
3. Dimension quality: drift, overlap, low-signal categories.
3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning.
4. Skill quality: trigger clarity, overlap, dead/unused skills.
## Output Format
+48 -8
View File
@@ -8,31 +8,71 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
## Core Rules
1. Search before create to avoid duplicates.
2. Every create/update should aim for a natural description that makes clear what the thing is, why it belongs in the graph, and workflow status.
2. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
3. Use event dates when known (when it happened, not when saved).
4. Apply dimensions deliberately; prefer existing dimensions over creating noisy new ones.
4. Apply context only when it is explicit and useful. One node gets at most one primary context, and leaving context blank is valid.
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
6. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
## Write Quality Contract
- `title`: clear and specific.
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
- `description`: concrete object-level description, not vague summaries.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
- `link`: external source URL only.
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
- `context_id`: the node's primary context. Prefer setting it when the scope is explicit. Leave it null rather than guessing.
- `metadata`: use the canonical node metadata contract when metadata is needed:
- `type`
- `state` (`processed` or `not_processed`)
- `captured_method`
- `captured_by`
- `source_metadata`
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
## Description Standard
Every node description should read like natural prose, not a template or checklist.
It must still make three things clear:
1. What — what the artifact is in simple explicit terms (format + creator + core claim)
2. Why — why it is in the graph; what Brad is interested in; what it connects to
3. Status — where it sits in his workflow (queued, in progress, processed, unknown)
If the agent has graph context (context capsule, focused nodes, recent connected nodes, or an explicit active context), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
If status is unknown, say naturally that it has not been reviewed yet.
Ask a clarification question only when a missing detail would materially change the node being created. If the user has already given enough substance to infer the artifact, title, and likely why, do the work instead of bouncing it back.
For user-authored idea capture, do not treat the inferred description as final if the "why" or status was mostly inferred. Save the node first, then tell the user what description framing you inferred and invite one short correction pass on:
- what this is
- why it belongs here
- where it sits in their workflow
Max 500 characters.
## Metadata Semantics
- Direct user creation, quick add, and user-requested agent capture should default to `captured_by = "human"`.
- Only autonomous/background creation without direct user instruction should use `captured_by = "agent"`.
- Prefer leaving `type` blank over forcing a weak label.
- `state` is the user-visible processed flag. If no state is known, default to `not_processed`.
## Execution Pattern
1. Read context (search + relevant nodes + relevant edges).
2. Decide: create vs update vs connect.
3. Execute minimum required writes.
4. Verify result reflects user intent exactly.
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
4. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
5. Verify result reflects user intent exactly.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Replace a user's raw idea/source with a thin summary.
- Create weak or directionless edges.
+57 -5
View File
@@ -1,6 +1,6 @@
---
name: Node Context Enrichment
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with dimension review and edge suggestions."
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions."
---
# Node Context Enrichment
@@ -17,15 +17,18 @@ Replace weak descriptions with a single clean natural description that captures:
2. Why it is in Brad's graph
3. Status in Brad's workflow
Also review whether the node needs dimension fixes or obvious edge suggestions.
Also review whether the node needs a clearer context assignment or obvious edge suggestions.
## Workflow
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
2. Search for adjacent context before rewriting.
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges.
2. Search for adjacent context before rewriting:
- the node's primary context and its anchor node when present
- recently connected project or belief nodes
- related nodes with overlapping titles, creators, or source themes
3. Infer the best available "why" from that context.
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
5. Review dimensions.
5. Review whether the current context is actually useful. Keep it, clear it, or suggest a better explicit context only when confidence is high.
6. Suggest 1-3 high-signal edges when obvious.
7. Update the node once the description is strong enough to be useful.
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
@@ -34,3 +37,52 @@ Also review whether the node needs dimension fixes or obvious edge suggestions.
- status / current relevance / workflow position
The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source.
## Description Standard
Every rewritten description must naturally cover:
1. What
- explicit artifact type
- creator/author/speaker when known
- core subject, claim, or function
2. Why
- why Brad saved it
- what project, belief, question, or theme it connects to
- if genuinely unknown, say that naturally without inventing context
3. Status
- queued, in progress, processed, not yet reviewed, saved for later, etc.
- if unknown, say naturally that it has not been reviewed yet
Max 500 characters.
## Batch Mode
Use batch enrichment when cleaning up many nodes with the same failure mode.
1. Pull a tight node set first.
2. Group by pattern:
- vague imported links
- thin quick-add captures
- old source nodes missing workflow state
3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
4. Return a compact summary of:
- nodes updated
- contexts to review
- edge suggestions not yet created
## Quality Bar
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
- No generic summaries that only restate the topic.
- No invented certainty. If context is weak, say so explicitly.
- Prefer one compact 3-sentence description over bloated prose.
## Output Pattern
For each node:
- New description
- Context recommendation: keep / clear / set
- Edge suggestions: source -> target with explicit explanation
- One short invitation for user feedback when contextual framing was inferred
+152 -21
View File
@@ -1,35 +1,166 @@
---
name: Onboarding
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
when_to_use: "New user setup or major reset of account context."
when_not_to_use: "User asks for a narrow tactical operation only."
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
description: "Use for new-user setup, empty or near-empty graphs, or major resets to map goals, projects, worldview, and preferences into an initial graph."
---
# Onboarding
## Goal
## Your Job
Understand the user deeply enough to bootstrap a useful externalized context graph.
Three things: help the user understand the basic structure of the system, help them start building useful context in it, and bootstrap the context capsule when durable cross-session facts become clear.
Adapt to the user.
- If they already know what they want to add, help them add it.
- If they want guidance, guide them with simple prompts.
- Do not force a rigid interview if they are already giving you usable context.
## Start With Orientation, Not Setup Friction
For signed-in cloud/mac users, do not start by asking whether the app is open or whether they added API keys. The app is already open, and billing-backed cloud usage does not require the old local setup checklist.
Start with product orientation and goal discovery first.
Only bring up setup details if the user actually needs them:
1. If they are on local/BYO-key mode, point them to Settings → API Keys.
2. If they ask about the database location, tell them the default macOS path is `~/Library/Application Support/RA-H/db/rah.sqlite`.
3. If API keys are relevant, explain them plainly:
- **OpenAI** — powers embeddings, semantic retrieval, and extraction-related AI work.
- **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups.
4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content.
## Capsule Bootstrap
As part of onboarding, also bootstrap the context capsule on the user's behalf.
When the user gives durable identity, preference, worldview, project, or agent-behavior information:
1. Call `readSkill('context-capsule')`
2. Call `readCapsule`
3. When you have enough confidence, call `writeCapsule`
Use the capsule for:
- preferred name
- agent name or greeting preference
- interaction style
- major projects
- major interests
- explicit worldview or belief signals
- what the user is using RA-H for
Do not write the capsule for tentative, one-off, or low-confidence statements.
Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history.
## Explain the System First
Before asking anything, orient the user. Be direct, not salesy:
> "RA-H is a context system built on a simple graph. The goal is to build context that persists and gets more useful over time."
Explain the structure in simple terms:
- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required.
- **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters.
- **Edges** — explicit connections between things. Each edge must clearly explain the relationship.
- **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear.
Then say:
> "If you know specifically how you'd like to create your context corpus, feel free to tell me what you'd like to add and I can help you set things up. Otherwise, I can guide you through bootstrapping your context with a few suggested prompts."
Also explain one practical thing early:
> "You do not need to perfectly design this up front. We want a few concrete nodes, clear contexts when they matter, and a few clean edges so the graph becomes useful quickly."
## Interview Flow
1. Clarify outcomes: goals for this system and success horizon.
2. Map active projects: responsibilities, timelines, decision pressure.
3. Capture worldview: beliefs, principles, working assumptions, decision criteria.
4. Capture identity/context anchors: roles, domains, recurring themes.
5. Capture interaction preferences: style, rigor, speed vs depth, tone.
Keep it conversational. Use these buckets and adapt based on what the user gives you.
## Graph Bootstrap
**1. Projects and active work**
- What are you working on right now?
- What projects, responsibilities, or decisions should be part of your context?
- What keeps coming up enough that it should probably live in the graph?
1. Propose 3-6 primary contexts with clear rationale.
2. Identify one strong anchor candidate per context.
3. Propose starter dimensions as secondary metadata and filters.
4. Create initial edges between anchor nodes and active project nodes.
5. Confirm with user before writing.
**2. Goals, motivations, beliefs, world models**
- What are you trying to achieve?
- What motivations, principles, or beliefs shape how you work and make decisions?
- Are there any mental models or recurring ways you think about things that should be captured?
## Output
**3. Learning, exploration, and research**
- What are you reading, watching, listening to, or researching lately?
- Any podcasts, articles, papers, books, or rabbit holes that matter right now?
- Are there specific people, thinkers, or sources you follow closely?
- Initial context map
- Suggested first write actions
- Suggested weekly maintenance rhythm
**4. Interaction style and preferences**
- How do you want me to work with you?
- Do you want concise answers, deeper exploration, pushback, or straightforward execution?
## First-Run Teaching Points
Work these in naturally when they are relevant:
- **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea.
- **Contexts** — explain that contexts are the primary folders or scopes for the graph.
- **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately.
- **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of:
- connect related nodes with explicit edges
- ingest a source they care about
- add one or two skills/preferences so future conversations stay grounded
## How to Work
Do your best to build the graph as useful context emerges.
- Add nodes when the user mentions concrete things worth keeping.
- Assign a primary context when one is clear. Prefer leaving context empty over low-confidence guessing.
- Add edges when relationships are clear enough to explain well.
- Explain what you're adding in plain language so the user understands the structure as it develops.
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
## Write Standards
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
- Search before creating — avoid duplicates from day one
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
- Contexts should hold the primary scope when clear, otherwise leave them empty
- Every edge needs an explicit explanation sentence
## Propose Before Writing
When there is enough context, summarize the proposed structure before touching the database:
> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?"
Write only after confirmation.
If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal:
> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?"
> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?"
For very early setup, include the first actionable next step too:
> "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
## Completion
After writing, give a brief recap:
- What was created
- How the structure works
- What would be useful to add next
If setup is still incomplete, end with the smallest next action, for example:
- add OpenAI in Settings
- connect Claude Code via MCP
- add one more source node
## Do Not
- Create meta-nodes like "User Profile", "Preferences", or "Goals" — the graph IS the profile
- Write anything before proposing the structure and getting confirmation
- Skip the interview and go straight to writing
- Write vague descriptions ("is about", "explores", "discusses", "touches on")
- Ask one disconnected question at a time when a natural multi-part thread is cleaner
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { createContext, useContext, type ReactNode } from 'react';
interface AuthContextValue {
status: 'unauthenticated';
user: null;
session: null;
supabase: null;
signIn: () => Promise<{ error: Error | null }>;
signUp: () => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
}
const noopError = new Error('Authentication is not enabled in the open-source build.');
const AuthContext = createContext<AuthContextValue>({
status: 'unauthenticated',
user: null,
session: null,
supabase: null,
signIn: async () => ({ error: noopError }),
signUp: async () => ({ error: noopError }),
signOut: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
return <AuthContext.Provider value={useContext(AuthContext)}>{children}</AuthContext.Provider>;
}
export function useAuthContext(): AuthContextValue {
return useContext(AuthContext);
}
-28
View File
@@ -1,28 +0,0 @@
"use client";
import { createContext, useContext, type ReactNode } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
interface DimensionIconsContextValue {
dimensionIcons: Record<string, string>;
setDimensionIcons: React.Dispatch<React.SetStateAction<Record<string, string>>>;
}
const DimensionIconsContext = createContext<DimensionIconsContextValue>({
dimensionIcons: {},
setDimensionIcons: () => {},
});
export function DimensionIconsProvider({ children }: { children: ReactNode }) {
const [dimensionIcons, setDimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {});
return (
<DimensionIconsContext.Provider value={{ dimensionIcons, setDimensionIcons }}>
{children}
</DimensionIconsContext.Provider>
);
}
export function useDimensionIcons() {
return useContext(DimensionIconsContext);
}
+2 -14
View File
@@ -12,9 +12,6 @@
import { nodeService, edgeService } from '@/services/database';
import { Node } from '@/types/database';
// Entity-like dimensions where we expect to find referenceable entities
const ENTITY_DIMENSIONS = ['people', 'companies', 'organizations', 'books', 'papers', 'articles', 'podcasts', 'creators'];
/**
* Clean up a candidate entity string by removing common prefixes/suffixes.
*/
@@ -113,7 +110,7 @@ function isGenericPhrase(phrase: string): boolean {
/**
* Look up existing nodes that match candidate entity strings.
* Uses exact title matching (case-insensitive) with optional dimension filtering.
* Uses exact title matching (case-insensitive).
*/
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
const matches = new Map<string, Node>();
@@ -131,16 +128,7 @@ async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string
const matchingNode = allNodes.find(node => {
const normalizedTitle = (node.title || '').toLowerCase().trim();
if (normalizedTitle !== normalizedCandidate) return false;
// Optionally filter to entity-like dimensions for higher confidence
// But don't require it - some entities might not have dimensions yet
const nodeDimensions = node.dimensions || [];
const hasEntityDimension = nodeDimensions.some(dim =>
ENTITY_DIMENSIONS.includes(dim.toLowerCase())
);
// Accept if it has an entity dimension OR if it's a short title (likely a named entity)
return hasEntityDimension || node.title.length < 50;
return node.title.length < 80;
});
if (matchingNode) {
+18 -10
View File
@@ -15,6 +15,7 @@ export interface QuickAddInput {
rawInput: string;
mode?: QuickAddMode;
description?: string;
contextId?: number | null;
}
export interface QuickAddResult {
@@ -65,7 +66,7 @@ function buildTaskPrompt(type: QuickAddInputType, input: string): string {
case 'pdf':
return `Quick Add: extract PDF and create node → ${input}`;
case 'note':
return `Quick Add note: create a node from this text with no dimensions${input}`;
return `Quick Add note: create a node from this text with optional context${input}`;
case 'chat':
return `Quick Add: import chat transcript and summarize → ${input.slice(0, 120)}${input.length > 120 ? '…' : ''}`;
}
@@ -199,12 +200,11 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
return true;
}
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise<string> {
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
if (!execute) {
throw new Error(`Tool ${toolName} does not have an execute function`);
}
try {
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
@@ -263,6 +263,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
refined_at: new Date().toISOString(),
},
},
context_id: contextId,
}),
});
@@ -289,7 +290,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
}
}
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string, contextId?: number | null): Promise<string> {
const content = rawInput.trim();
if (!content) {
throw new Error('Input is required to create a note');
@@ -299,6 +300,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
const nodePayload: Record<string, unknown> = {
title,
source: content,
context_id: contextId,
metadata: {
type: 'note',
state: 'not_processed',
@@ -310,6 +312,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
},
};
// If user provided a description, use it instead of auto-generating
if (userDescription && userDescription.trim()) {
nodePayload.description = userDescription.trim();
}
@@ -342,7 +345,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
});
}
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
async function handleChatTranscriptQuickAdd(rawInput: string, task: string, contextId?: number | null): Promise<string> {
const transcript = rawInput.trim();
if (!transcript) {
throw new Error('Input is required to import a chat transcript');
@@ -401,8 +404,9 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
source: transcript,
description: nodeDescription,
source: transcript,
context_id: contextId,
metadata,
}),
});
@@ -429,7 +433,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
});
}
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: QuickAddInput): Promise<QuickAddResult> {
const inputType = detectInputType(rawInput, mode);
const task = buildTaskPrompt(inputType, rawInput);
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -441,21 +445,25 @@ export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddI
status: 'queued',
};
// Run async - fire and forget
setImmediate(async () => {
try {
let summary: string;
if (inputType === 'note') {
await handleNoteQuickAdd(rawInput, task, description);
summary = await handleNoteQuickAdd(rawInput, task, description, contextId);
} else if (inputType === 'chat') {
await handleChatTranscriptQuickAdd(rawInput, task);
summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId);
} else {
await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId);
}
console.log(`[QuickAdd] Completed: ${task}`);
// Broadcast completion so ThreePanelLayout can remove the pending placeholder
eventBroadcaster.broadcast({
type: 'QUICK_ADD_COMPLETED',
data: { quickAddId: id, source: 'quick-add' }
});
// Also broadcast NODE_CREATED to refresh the feed
eventBroadcaster.broadcast({
type: 'NODE_CREATED',
data: { node: { title: task }, source: 'quick-add' }
+29 -43
View File
@@ -1,5 +1,4 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
export interface AutoContextSummary {
id: number;
@@ -34,28 +33,30 @@ function truncate(value: string | null | undefined, maxChars: number): string {
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
const db = getSQLiteClient();
const rows = db.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
LIMIT ?
`,
[limit]
).rows;
const rows = db
.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
LIMIT ?
`,
[limit]
)
.rows;
return rows.map((row) => ({
id: row.id,
@@ -150,7 +151,7 @@ export function buildContextsBlock(limit = 12): string | null {
const lines: string[] = [
'User Contexts',
'Use contexts as the primary scope layer. Dimensions are secondary metadata and filters.',
'Contexts are optional soft hints. Use them when they are explicit and useful, but rely primarily on title, description, source, edges, and recency.',
'',
];
@@ -172,7 +173,7 @@ export function buildContextAnchorsBlock(limit = 12): string | null {
const lines: string[] = [
'Context Anchors',
'Each context anchor is the highest-edge node in that context. Use it as the starting graph waypoint for that scope.',
'Each context anchor is the highest-edge node in that context. Use it only as an optional waypoint when that context is already clearly relevant.',
'',
];
@@ -195,7 +196,7 @@ export function buildHubNodesBlock(limit = 5): string | null {
const lines: string[] = [
'Global Hub Diagnostics',
'These are secondary graph diagnostics only. Do not use them as the primary scope layer when contexts are available.',
'These are secondary graph diagnostics only. Do not treat them as the primary grounding mechanism.',
'',
];
@@ -210,24 +211,9 @@ export function buildHubNodesBlock(limit = 5): string | null {
}
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] {
const settings = getAutoContextSettings();
if (!settings.autoContextEnabled) {
return [];
}
return getHubNodes(limit);
}
export function buildAutoContextBlock(limit = 12): string | null {
const settings = getAutoContextSettings();
if (!settings.autoContextEnabled) {
return null;
}
const sections = [
buildContextsBlock(limit),
buildContextAnchorsBlock(limit),
buildHubNodesBlock(5),
].filter(Boolean);
return sections.length > 0 ? sections.join('\n\n') : null;
export function buildAutoContextBlock(limit = 5): string | null {
return buildContextsBlock(limit);
}
-168
View File
@@ -1,168 +0,0 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
type ContextCandidate = {
id: number;
name: string;
description: string | null;
count: number;
anchor_title: string | null;
anchor_description: string | null;
};
type InferContextInput = {
title?: string | null;
description?: string | null;
source?: string | null;
dimensions?: string[] | null;
metadata?: unknown;
};
const STOP_WORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
'to', 'was', 'with', 'you', 'your'
]);
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function tokenize(value: string | null | undefined): string[] {
if (!value) return [];
return normalizeText(value)
.split(' ')
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
}
function uniqueTokens(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(values.flatMap((value) => tokenize(value))));
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value ?? {});
} catch {
return '';
}
}
function fetchContextCandidates(): ContextCandidate[] {
const sqlite = getSQLiteClient();
return sqlite.query<ContextCandidate>(`
WITH context_counts AS (
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
GROUP BY c.id
),
ranked_anchors AS (
SELECT
c.id AS context_id,
n.title AS anchor_title,
n.description AS anchor_description,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
) AS anchor_rank
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY c.id, n.id
)
SELECT
cc.id,
cc.name,
cc.description,
cc.count,
ra.anchor_title,
ra.anchor_description
FROM context_counts cc
LEFT JOIN ranked_anchors ra
ON ra.context_id = cc.id
AND ra.anchor_rank = 1
ORDER BY cc.name COLLATE NOCASE ASC
`).rows.map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description ?? null,
count: Number(row.count ?? 0),
anchor_title: row.anchor_title ?? null,
anchor_description: row.anchor_description ?? null,
}));
}
function scoreContextCandidate(candidate: ContextCandidate, input: InferContextInput): number {
const titleText = normalizeText(input.title || '');
const descriptionText = normalizeText(input.description || '');
const sourceText = normalizeText((input.source || '').slice(0, 4000));
const metadataText = normalizeText(safeStringify(input.metadata));
const dimensionTokens = uniqueTokens(input.dimensions ?? []);
const contextName = normalizeText(candidate.name);
const contextNameTokens = tokenize(candidate.name);
const contextDescriptorTokens = uniqueTokens([
candidate.description,
candidate.anchor_title,
candidate.anchor_description,
]);
let score = 0;
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) {
score += 80;
}
if (contextName && sourceText.includes(contextName)) {
score += 40;
}
for (const token of contextNameTokens) {
if (dimensionTokens.includes(token)) score += 30;
if (titleText.includes(token)) score += 16;
if (descriptionText.includes(token)) score += 12;
if (sourceText.includes(token)) score += 6;
if (metadataText.includes(token)) score += 4;
}
for (const token of contextDescriptorTokens) {
if (dimensionTokens.includes(token)) score += 8;
if (titleText.includes(token)) score += 4;
if (descriptionText.includes(token)) score += 3;
if (sourceText.includes(token)) score += 2;
}
return score;
}
export async function inferBestContextIdForNode(input: InferContextInput): Promise<number | null> {
const contexts = fetchContextCandidates();
if (contexts.length === 0) {
return null;
}
const ranked = contexts
.map((context) => ({
context,
score: scoreContextCandidate(context, input),
}))
.sort((a, b) =>
b.score - a.score ||
(b.context.count - a.context.count) ||
a.context.id - b.context.id
);
const best = ranked[0];
if (!best) {
return null;
}
if (best.score > 0) {
return best.context.id;
}
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
if (research) {
return research.id;
}
return ranked[0].context.id;
}
+3 -1
View File
@@ -177,7 +177,9 @@ export class ContextService {
}
async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise<number | null | undefined> {
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
const hasContextId =
Object.prototype.hasOwnProperty.call(input, 'context_id') &&
input.context_id !== undefined;
const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
if (!hasContextId && !hasContextName) {
+13 -6
View File
@@ -1,5 +1,5 @@
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { openai as openaiProvider } from '@ai-sdk/openai';
import { hasValidOpenAiKey } from '../storage/apiKeys';
import type { CanonicalNodeMetadata } from '@/types/database';
@@ -16,11 +16,12 @@ export interface DescriptionInput {
pages?: number;
text_length?: number;
};
dimensions?: string[];
}
export { hasValidOpenAiKey } from '../storage/apiKeys';
/**
* Generate a context-rich description for a knowledge node.
* The result must cover what the artifact is, why it is in the graph, and workflow status.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
if (!hasValidOpenAiKey()) {
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
@@ -46,10 +47,13 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
return description;
} catch (error) {
console.error('[DescriptionService] Error generating description:', error);
// Fallback: just use the title — more useful than a vague template
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
}
}
export { hasValidOpenAiKey } from '../storage/apiKeys';
function buildDescriptionPrompt(input: DescriptionInput): string {
const sourceMetadata = input.metadata?.source_metadata as Record<string, unknown> | undefined;
const sourceType = typeof input.metadata?.type === 'string'
@@ -60,6 +64,8 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const normalizedSource = sourceType.toLowerCase();
const url = typeof input.link === 'string' ? input.link.trim() : '';
// Best-effort creator hint from structured metadata (when available),
// but never assume a particular extraction source (YouTube vs paper vs website vs note).
const creatorHint =
(typeof sourceMetadata?.author === 'string' ? sourceMetadata.author.trim() : '') ||
(typeof sourceMetadata?.channel_name === 'string' ? sourceMetadata.channel_name.trim() : '') ||
@@ -67,6 +73,7 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
(typeof input.metadata?.channel_name === 'string' ? input.metadata.channel_name.trim() : '') ||
'';
// Best-effort publisher / container hint (less ideal than a true author, but better than nothing).
const publisherHint =
(typeof sourceMetadata?.site_name === 'string' ? sourceMetadata.site_name.trim() : '') ||
(typeof input.metadata?.site_name === 'string' ? input.metadata.site_name.trim() : '') ||
@@ -90,7 +97,6 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const lines: string[] = [`Title: ${input.title}`];
if (input.link) lines.push(`URL: ${input.link}`);
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
if (sourceMetadata?.channel_name || input.metadata?.channel_name) lines.push(`Channel: ${sourceMetadata?.channel_name || input.metadata?.channel_name}`);
if (sourceMetadata?.author || input.metadata?.author) lines.push(`Author: ${sourceMetadata?.author || input.metadata?.author}`);
if (sourceMetadata?.site_name || input.metadata?.site_name) lines.push(`Site: ${sourceMetadata?.site_name || input.metadata?.site_name}`);
@@ -116,7 +122,7 @@ RULES:
1) Name the format only if the context clearly supports it: "Podcast episode where…", "Blog post arguing…", "Personal note capturing…", "Research paper showing…", "Resume/CV for…", "Document likely containing…", "Idea that…"
2) Name people by role channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
3) State the actual claim, finding, or insight from the content not a vague summary of the topic.
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, metadata, or dimensions, say that naturally rather than inventing context.
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, or metadata, say that naturally rather than inventing context.
5) If workflow status is unknown, say that naturally, for example by noting it has not been reviewed yet.
6) Do NOT use labels or headings like "WHAT:", "WHY:", or "STATUS:".
7) ABSOLUTELY FORBIDDEN these words and phrases will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to", "important for", "useful for understanding". State things directly instead.
@@ -144,6 +150,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
}
// Guard against weak generic openings from model drift.
const noGenericPrefix = singleLine.replace(
/^(your note|this note)\s*[—:-]\s*/i,
'Personal note capturing '
-189
View File
@@ -1,189 +0,0 @@
import { getSQLiteClient } from './sqlite-client';
export interface Dimension {
name: string;
description: string | null;
is_priority: boolean;
updated_at: string;
}
export interface LockedDimension {
name: string;
description: string | null;
count: number;
}
export class DimensionService {
/**
* Legacy compatibility shim. Dimensions are now flat, so there is no locked subset.
*/
static async getLockedDimensions(): Promise<LockedDimension[]> {
return [];
}
/**
* Automatic special-dimension assignment has been removed. Callers must provide dimensions explicitly.
*/
static async assignDimensions(nodeData: {
title: string;
content?: string;
link?: string;
description?: string;
}): Promise<{ locked: string[]; keywords: string[] }> {
console.log(`[DimensionAssignment] Skipped for "${nodeData.title}" — flat dimensions require explicit assignment.`);
return { locked: [], keywords: [] };
}
/**
* Legacy method for backwards compatibility
* @deprecated Use assignDimensions() instead
*/
static async assignLockedDimensions(nodeData: {
title: string;
content?: string;
link?: string;
}): Promise<string[]> {
const result = await this.assignDimensions(nodeData);
return result.locked;
}
/**
* Update dimension description
*/
static async updateDimensionDescription(name: string, description: string): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.query(`
INSERT INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = ?,
updated_at = CURRENT_TIMESTAMP
`, [name, description, description]);
}
/**
* Get dimension by name with description
*/
static async getDimensionByName(name: string): Promise<Dimension | null> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT name, description, is_priority, updated_at
FROM dimensions
WHERE name = ?
`, [name]);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0] as any;
return {
name: row.name,
description: row.description,
is_priority: Boolean(row.is_priority),
updated_at: row.updated_at
};
}
/**
* Legacy no-op prompt builder retained only for backward compatibility.
*/
private static buildAssignmentPrompt(
nodeData: { title: string; notes?: string; link?: string; description?: string },
lockedDimensions: LockedDimension[]
): string {
// Use description as primary context, content as fallback
let nodeContextSection: string;
if (nodeData.description) {
const notesPreview = nodeData.notes?.slice(0, 500) || '';
nodeContextSection = `DESCRIPTION: ${nodeData.description}
NOTES PREVIEW: ${notesPreview}${nodeData.notes && nodeData.notes.length > 500 ? '...' : ''}`;
} else {
const notesPreview = nodeData.notes?.slice(0, 2000) || '';
nodeContextSection = `NOTES: ${notesPreview}${nodeData.notes && nodeData.notes.length > 2000 ? '...' : ''}`;
}
// Include ALL locked dimensions, using fallback text for those without descriptions
const dimensionsList = lockedDimensions
.map(d => {
const description = d.description && d.description.trim().length > 0
? d.description
: '(none - infer from name)';
return `DIMENSION: "${d.name}"\nDESCRIPTION: ${description}`;
})
.join('\n---\n');
return `Dimensions are now flat categories with no locked subset.
=== NODE TO CATEGORIZE ===
Title: ${nodeData.title}
${nodeContextSection}
URL: ${nodeData.link || 'none'}
=== LOCKED DIMENSIONS ===
CRITICAL: Read each dimension's DESCRIPTION carefully.
The description defines what belongs in that dimension.
Only assign if the content CLEARLY matches the description.
If unsure, skip it better to miss than assign incorrectly.
AVAILABLE DIMENSIONS:
${dimensionsList}
=== RESPONSE FORMAT ===
LOCKED:
[dimension names from the list above, one per line, or "none"]`;
}
/**
* Legacy no-op parser retained only for backward compatibility.
*/
private static parseAssignmentResponse(
response: string,
availableDimensions: LockedDimension[]
): { locked: string[]; keywords: string[] } {
const lockedDimensions: string[] = [];
// Extract LOCKED section
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)$/i);
if (lockedMatch) {
const lockedLines = lockedMatch[1].trim().split('\n');
for (const line of lockedLines) {
const dimensionName = line.trim().toLowerCase();
if (dimensionName === 'none' || dimensionName === '') {
continue;
}
// Find matching dimension (case-insensitive)
const matchedDimension = availableDimensions.find(
d => d.name.toLowerCase() === dimensionName
);
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
lockedDimensions.push(matchedDimension.name);
}
}
}
return { locked: lockedDimensions, keywords: [] };
}
/**
* Create or get a keyword dimension (unlocked)
*/
static async ensureKeywordDimension(keyword: string): Promise<void> {
const sqlite = getSQLiteClient();
// INSERT OR IGNORE - if dimension exists, do nothing
sqlite.query(`
INSERT OR IGNORE INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
`, [keyword, null]);
}
}
export const dimensionService = new DimensionService();
@@ -1,29 +0,0 @@
import { getSQLiteClient } from './sqlite-client';
import { normalizeDimensionName } from './quality';
export function getUnknownDimensions(values: string[]): string[] {
if (values.length === 0) return [];
const sqlite = getSQLiteClient();
const placeholders = values.map(() => '?').join(', ');
const result = sqlite.query<{ name: string }>(
`SELECT name FROM dimensions WHERE name IN (${placeholders})`,
values
);
const existing = new Set(
result.rows
.map(row => (typeof row.name === 'string' ? normalizeDimensionName(row.name) : ''))
.filter(Boolean)
);
return values.filter(value => !existing.has(normalizeDimensionName(value)));
}
export function formatUnknownDimensionsError(values: string[]): string {
if (values.length === 1) {
return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`;
}
return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`;
}
+20 -41
View File
@@ -2,11 +2,11 @@ import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events';
import { nodeService } from './nodes';
import { getOpenAiKey } from '../storage/apiKeys';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { validateEdgeExplanation } from './quality';
import { hasValidOpenAiKey } from '../storage/apiKeys';
const inferredEdgeContextSchema = z.object({
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
@@ -53,14 +53,6 @@ async function inferEdgeContext(params: {
return { type: 'related_to', confidence: 0.8, swap_direction: false };
}
// If no API key is configured, degrade gracefully.
// We still enforce explanation, but fall back to "related_to" classification.
const apiKey = getOpenAiKey();
if (!apiKey) {
return { type: 'related_to', confidence: 0.0, swap_direction: false };
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given two nodes and an explanation, determine the relationship type and direction.`,
``,
@@ -83,8 +75,12 @@ async function inferEdgeContext(params: {
].join('\n');
try {
if (!hasValidOpenAiKey()) {
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
const { text } = await generateText({
model: provider('gpt-4o-mini'),
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.0,
maxOutputTokens: 120,
@@ -120,18 +116,6 @@ async function autoInferEdge(params: {
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
const { fromNode, toNode } = params;
const apiKey = getOpenAiKey();
if (!apiKey) {
// Fallback without AI
return {
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
type: 'related_to',
confidence: 0.0,
swap_direction: false,
};
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given two knowledge base nodes, determine how they are related.`,
``,
@@ -157,8 +141,17 @@ async function autoInferEdge(params: {
].join('\n');
try {
if (!hasValidOpenAiKey()) {
return {
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
type: 'related_to',
confidence: 0.2,
swap_direction: false,
};
}
const { text } = await generateText({
model: provider('gpt-4o-mini'),
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.0,
maxOutputTokens: 150,
@@ -477,15 +470,14 @@ export class EdgeService {
WHEN e.from_node_id = ? THEN n_to.title
ELSE n_from.title
END as connected_node_title,
CASE
WHEN e.from_node_id = ? THEN n_to.link
CASE WHEN e.from_node_id = ? THEN n_to.link
ELSE n_from.link
END as connected_node_link,
CASE
WHEN e.from_node_id = ? THEN n_to.source
ELSE n_from.source
END as connected_node_source,
CASE
CASE
WHEN e.from_node_id = ? THEN n_to.metadata
ELSE n_from.metadata
END as connected_node_metadata,
@@ -496,17 +488,7 @@ export class EdgeService {
CASE
WHEN e.from_node_id = ? THEN n_to.updated_at
ELSE n_from.updated_at
END as connected_node_updated_at,
CASE
WHEN e.from_node_id = ? THEN (
SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n_to.id
)
ELSE (
SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n_from.id
)
END as connected_node_dimensions_json
END as connected_node_updated_at
FROM edges e
LEFT JOIN nodes n_from ON e.from_node_id = n_from.id
LEFT JOIN nodes n_to ON e.to_node_id = n_to.id
@@ -521,7 +503,6 @@ export class EdgeService {
nodeId,
nodeId,
nodeId,
nodeId,
nodeId
]);
@@ -543,7 +524,6 @@ export class EdgeService {
id: row.connected_node_id,
title: row.connected_node_title,
link: row.connected_node_link,
dimensions: row.connected_node_dimensions,
embedding: undefined, // Not needed for display
source: row.connected_node_source,
metadata: row.connected_node_metadata,
@@ -587,7 +567,6 @@ export class EdgeService {
id: row.connected_node_id,
title: row.connected_node_title,
link: row.connected_node_link,
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
embedding: undefined, // Not needed for display
source: row.connected_node_source,
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
+1 -3
View File
@@ -2,7 +2,6 @@
export { nodeService, NodeService } from './nodes';
export { chunkService, ChunkService } from './chunks';
export { edgeService, EdgeService } from './edges';
export { dimensionService, DimensionService } from './dimensionService';
export { contextService, ContextService } from './contextService';
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
@@ -39,7 +38,6 @@ async function checkSQLiteDatabaseHealth(): Promise<{
const sqlite = getSQLiteClient();
const connected = await sqlite.testConnection();
const vectorCapability = sqlite.getVectorCapability();
if (!connected) {
return {
connected: false,
@@ -49,7 +47,7 @@ async function checkSQLiteDatabaseHealth(): Promise<{
};
}
const vectorExtension = vectorCapability.available;
const vectorExtension = await sqlite.checkVectorExtension();
// Check if main tables exist
const tables = await sqlite.checkTables();
+74 -152
View File
@@ -5,7 +5,9 @@ import { EmbeddingService } from '@/services/embeddings';
import { scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
type NodeRow = Node & { dimensions_json: string; context_json: string | null };
type NodeRow = Node & {
context_json: string | null;
};
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
function sanitizeFtsQuery(input: string): string {
@@ -81,8 +83,15 @@ export class NodeService {
}
async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
const {
search,
createdAfter,
createdBefore,
eventAfter,
eventBefore,
chunkStatus,
contextId,
} = filters;
if (search?.trim()) {
return this.countSearchNodesSQLite(filters);
@@ -93,24 +102,6 @@ export class NodeService {
let query = `SELECT COUNT(*) as total FROM nodes n WHERE 1=1`;
const params: any[] = [];
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
query += ` AND (
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`;
params.push(...dimensions, dimensions.length);
} else {
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
}
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
@@ -130,8 +121,18 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
const {
search,
limit = 100,
offset = 0,
sortBy,
createdAfter,
createdBefore,
eventAfter,
eventBefore,
chunkStatus,
contextId,
} = filters;
if (search?.trim()) {
return this.searchNodesSQLite(filters);
@@ -139,13 +140,10 @@ export class NodeService {
const sqlite = getSQLiteClient();
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
@@ -157,27 +155,6 @@ export class NodeService {
`;
const params: any[] = [];
// Filter by dimensions (SQLite JOIN with node_dimensions)
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
// AND logic: node must have ALL specified dimensions
query += ` AND (
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`;
params.push(...dimensions, dimensions.length);
} else {
// OR logic: node must have at least one of the specified dimensions
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
}
// Text search in title, description, and source (SQLite LIKE with COLLATE NOCASE)
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
@@ -251,7 +228,7 @@ export class NodeService {
const result = sqlite.query<NodeRow>(query, params);
// Parse dimensions_json and metadata back for compatibility
// Parse metadata and normalize deprecated compatibility fields.
return result.rows.map(row => this.mapNodeRow(row));
}
@@ -267,8 +244,6 @@ export class NodeService {
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
@@ -298,7 +273,6 @@ export class NodeService {
source,
link,
event_date,
dimensions = [],
chunk_status,
metadata = {},
context_id,
@@ -327,20 +301,10 @@ export class NodeService {
const id = Number(nodeResult.lastInsertRowid);
// Insert dimensions separately with INSERT OR IGNORE for safety
if (dimensions.length > 0) {
const stmt = sqlite.prepare(
"INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)"
);
for (const dimension of dimensions) {
stmt.run(id, dimension);
}
}
return id; // Returns number directly
});
// Get the created node with dimensions (outside transaction)
// Re-read the created node outside the transaction so callers get the canonical shape.
const createdNode = await this.getNodeByIdSQLite(nodeId);
if (!createdNode) {
throw new Error('Failed to create node');
@@ -363,7 +327,7 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
const { title, description, source, link, event_date, dimensions, metadata } = updates;
const { title, description, source, link, event_date, metadata } = updates;
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
@@ -411,14 +375,6 @@ export class NodeService {
stmt.run(...params);
}
// Handle dimensions separately
if (Array.isArray(dimensions)) {
sqlite.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
const dimStmt = sqlite.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
for (const dim of dimensions) {
dimStmt.run(id, dim);
}
}
});
// Get updated node
@@ -458,28 +414,21 @@ export class NodeService {
});
}
// Dimension-based filtering methods
async getNodesByDimension(dimension: string): Promise<Node[]> {
return this.getNodes({ dimensions: [dimension] });
}
async searchNodes(searchTerm: string, limit = 50): Promise<Node[]> {
return this.getNodes({ search: searchTerm, limit });
}
private mapNodeRow(row: NodeRow): Node {
const { context_json, ...baseRow } = row;
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
context: row.context_json ? JSON.parse(row.context_json) : null,
...baseRow,
metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null,
context: context_json ? JSON.parse(context_json) : null,
};
}
private buildNodeFilterClauses(filters: NodeFilters, alias = 'n'): { clauses: string[]; params: any[] } {
const {
dimensions,
dimensionsMatch = 'any',
createdAfter,
createdBefore,
eventAfter,
@@ -490,24 +439,6 @@ export class NodeService {
const clauses: string[] = [];
const params: any[] = [];
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
clauses.push(`(
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = ${alias}.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`);
params.push(...dimensions, dimensions.length);
} else {
clauses.push(`EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = ${alias}.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`);
params.push(...dimensions);
}
}
if (createdAfter) { clauses.push(`${alias}.created_at >= ?`); params.push(createdAfter); }
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
@@ -567,26 +498,30 @@ export class NodeService {
if (!search) return 0;
const ftsQuery = sanitizeFtsQuery(search);
const ftsExists = sqlite.prepare(
const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
).get();
const { clauses, params } = this.buildNodeFilterClauses(filters);
if (ftsExists && ftsQuery) {
const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const result = sqlite.query<{ total: number }>(`
WITH matched_nodes AS (
SELECT rowid
FROM nodes_fts
WHERE nodes_fts MATCH ?
)
SELECT COUNT(*) as total
FROM matched_nodes mn
JOIN nodes n ON n.id = mn.rowid
${whereClauses}
`, [ftsQuery, ...params]);
try {
const result = sqlite.query<{ total: number }>(`
WITH matched_nodes AS (
SELECT rowid
FROM nodes_fts
WHERE nodes_fts MATCH ?
)
SELECT COUNT(*) as total
FROM matched_nodes mn
JOIN nodes n ON n.id = mn.rowid
${whereClauses}
`, [ftsQuery, ...params]);
return Number(result.rows[0]?.total ?? 0);
return Number(result.rows[0]?.total ?? 0);
} catch (error) {
sqlite.disableNodesFts('nodes_fts query failed during count search', error);
}
}
const words = search.split(/\s+/).filter(Boolean);
@@ -615,7 +550,7 @@ export class NodeService {
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
const ftsExists = sqlite.prepare(
const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
).get();
if (!ftsExists) return [];
@@ -633,12 +568,15 @@ export class NodeService {
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.created_at, n.updated_at, n.context_id,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json,
fm.rank
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
LEFT JOIN contexts c ON c.id = n.context_id
${whereClauses}
ORDER BY fm.rank
LIMIT ?
@@ -646,7 +584,7 @@ export class NodeService {
return result.rows;
} catch (error) {
console.warn('[NodeSearch] FTS search failed, falling back to LIKE:', error);
sqlite.disableNodesFts('nodes_fts query failed during node search', error);
return [];
}
}
@@ -662,10 +600,13 @@ export class NodeService {
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
n.created_at, n.updated_at, n.context_id,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const queryParams = [...params];
@@ -707,10 +648,13 @@ export class NodeService {
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
n.created_at, n.updated_at, n.context_id,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const queryParams = [...params];
@@ -781,12 +725,15 @@ export class NodeService {
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.created_at, n.updated_at, n.context_id,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json,
(1.0 / (1.0 + vm.distance)) AS similarity
FROM vector_matches vm
JOIN nodes n ON n.id = vm.node_id
LEFT JOIN contexts c ON c.id = n.context_id
${whereClauses}
ORDER BY vm.distance
LIMIT ?
@@ -829,31 +776,6 @@ export class NodeService {
return updatedNodes;
}
// Get all unique dimensions for UI filtering
async getAllDimensions(): Promise<string[]> {
const sqlite = getSQLiteClient();
const query = `
SELECT DISTINCT dimension
FROM node_dimensions
ORDER BY dimension
`;
const result = sqlite.query<{dimension: string}>(query);
return result.rows.map(row => row.dimension);
}
// Get dimension usage statistics
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
const sqlite = getSQLiteClient();
const query = `
SELECT dimension, COUNT(*) as count
FROM node_dimensions
GROUP BY dimension
ORDER BY count DESC
`;
const result = sqlite.query<{dimension: string, count: number}>(query);
return result.rows;
}
}
// Export singleton instance
+1 -36
View File
@@ -1,34 +1,10 @@
const WEAK_DESCRIPTION_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
const UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
const WHY_PATTERNS = /(why added:|added (?:after|as|for|because|to)|follow-?on|queued for|saved for|relevant because|connected to|not inferred|belongs in the graph because|belongs here because|captures .* idea|ties directly into|ties into)/i;
const STATUS_PATTERNS = /(status:|queued|not yet reviewed|in progress|processed|reviewed|saved for later|to review|to read|to watch|to listen|draft|not yet published|unpublished)/i;
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
export function normalizeDimensionName(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}
export function normalizeDimensions(values: unknown, max = 5): string[] {
if (!Array.isArray(values)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
if (typeof value !== 'string') continue;
const trimmed = normalizeDimensionName(value);
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(trimmed);
if (normalized.length >= max) break;
}
return normalized;
}
export function validateExplicitDescription(description: string): string | null {
const text = description.trim();
if (text.length > 500) {
@@ -84,14 +60,3 @@ export function validateEdgeExplanation(explanation: string): string | null {
}
return null;
}
export function validateDimensionDescription(description: string): string | null {
const text = description.trim();
if (!text) {
return 'Dimension description is required.';
}
if (text.length > 500) {
return 'Description must be 500 characters or less.';
}
return null;
}
+459 -105
View File
@@ -25,6 +25,8 @@ class SQLiteClient {
private config: SQLiteConfig;
private readonly readOnly: boolean;
private readonly vectorCapability: VectorCapability;
private nodesFtsUsable = true;
private nodesFtsDisabledReason: string | null = null;
private constructor() {
this.config = this.getSQLiteConfig();
@@ -61,6 +63,7 @@ class SQLiteClient {
this.db.pragma('temp_store = memory');
this.db.pragma('busy_timeout = 5000');
this.ensureCoreSchema();
// Ensure vector virtual tables are present and healthy
if (this.vectorCapability.available) {
this.ensureVectorTables();
@@ -162,6 +165,25 @@ class SQLiteClient {
return this.vectorCapability;
}
public isNodesFtsUsable(): boolean {
return this.nodesFtsUsable;
}
public disableNodesFts(reason: string, error?: unknown): void {
this.nodesFtsUsable = false;
if (this.nodesFtsDisabledReason === reason) {
return;
}
this.nodesFtsDisabledReason = reason;
if (error && !this.isSqliteCorruptError(error)) {
console.warn(`[SQLite] nodes_fts disabled: ${reason}`, error);
return;
}
console.warn(`[SQLite] nodes_fts disabled: ${reason}. Falling back to LIKE search for this database session.`);
}
public async checkTables(): Promise<string[]> {
try {
const result = this.query(
@@ -215,17 +237,283 @@ class SQLiteClient {
this.ensureVectorExtensions();
}
private ensureCoreSchema(): void {
if (this.readOnly) {
return;
}
this.db.exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
source TEXT,
link TEXT,
event_date TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked',
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
node_id INTEGER NOT NULL,
chunk_idx INTEGER,
text TEXT NOT NULL,
embedding BLOB,
embedding_type TEXT DEFAULT 'openai',
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_chunks_node_id ON chunks(node_id);
`);
this.ensureEdgesTableSchema();
}
private ensureEdgesTableSchema(): void {
const hasEdgesTable = this.db
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'")
.get();
if (!hasEdgesTable) {
this.db.exec(`
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
`);
} else {
const edgeCols = this.db.prepare('PRAGMA table_info(edges)').all() as Array<{ name: string }>;
const edgeColNames = new Set(edgeCols.map((col) => col.name));
const needsLegacyRewrite =
!edgeColNames.has('from_node_id') ||
!edgeColNames.has('to_node_id') ||
!edgeColNames.has('source') ||
!edgeColNames.has('created_at') ||
!edgeColNames.has('context') ||
edgeColNames.has('from_id') ||
edgeColNames.has('to_id') ||
edgeColNames.has('description') ||
edgeColNames.has('updated_at');
if (needsLegacyRewrite) {
this.rebuildLegacyEdgesTable(edgeColNames);
}
}
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
`);
}
private rebuildLegacyEdgesTable(edgeColNames: Set<string>): void {
const fromExpr = edgeColNames.has('from_node_id')
? 'from_node_id'
: edgeColNames.has('from_id')
? 'from_id'
: 'NULL';
const toExpr = edgeColNames.has('to_node_id')
? 'to_node_id'
: edgeColNames.has('to_id')
? 'to_id'
: 'NULL';
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
const explanationExpr = edgeColNames.has('explanation')
? 'explanation'
: edgeColNames.has('description')
? 'description'
: edgeColNames.has('context')
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
: 'NULL';
console.log('Migrating legacy edges table to canonical schema');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
} catch {}
try {
this.db.exec('BEGIN TRANSACTION;');
this.db.exec(`
DROP INDEX IF EXISTS idx_edges_from;
DROP INDEX IF EXISTS idx_edges_to;
ALTER TABLE edges RENAME TO edges_legacy_migration;
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
SELECT
id,
${fromExpr},
${toExpr},
${sourceExpr},
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
${contextExpr},
${explanationExpr}
FROM edges_legacy_migration
WHERE ${fromExpr} IS NOT NULL
AND ${toExpr} IS NOT NULL;
DROP TABLE edges_legacy_migration;
COMMIT;
`);
} catch (error) {
try {
this.db.exec('ROLLBACK;');
} catch {}
throw error;
} finally {
if (flippedForeignKeys) {
try {
this.db.exec('PRAGMA foreign_keys=ON;');
} catch {}
}
}
}
private rebuildLegacyChatsTable(chatColNames: Set<string>): void {
const chatTypeExpr = chatColNames.has('chat_type') ? 'chat_type' : 'NULL';
const helperNameExpr = chatColNames.has('helper_name')
? 'helper_name'
: chatColNames.has('title')
? 'title'
: 'NULL';
const agentTypeExpr = chatColNames.has('agent_type')
? "COALESCE(agent_type, 'orchestrator')"
: "'orchestrator'";
const delegationIdExpr = chatColNames.has('delegation_id') ? 'delegation_id' : 'NULL';
const userMessageExpr = chatColNames.has('user_message') ? 'user_message' : 'NULL';
const assistantMessageExpr = chatColNames.has('assistant_message') ? 'assistant_message' : 'NULL';
const threadIdExpr = chatColNames.has('thread_id') ? 'thread_id' : 'NULL';
const focusedNodeIdExpr = chatColNames.has('focused_node_id') ? 'focused_node_id' : 'NULL';
const createdAtExpr = chatColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
const metadataExpr = chatColNames.has('metadata') ? 'metadata' : 'NULL';
console.log('Migrating legacy chats table to canonical schema');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
} catch {}
try {
this.db.exec('BEGIN TRANSACTION;');
this.db.exec(`
DROP INDEX IF EXISTS idx_chats_thread;
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
INSERT INTO chats (
id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
)
SELECT
id,
${chatTypeExpr},
${helperNameExpr},
${agentTypeExpr},
${delegationIdExpr},
${userMessageExpr},
${assistantMessageExpr},
${threadIdExpr},
${focusedNodeIdExpr},
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
${metadataExpr}
FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup;
COMMIT;
`);
} catch (error) {
try {
this.db.exec('ROLLBACK;');
} catch {}
throw error;
} finally {
if (flippedForeignKeys) {
try {
this.db.exec('PRAGMA foreign_keys=ON;');
} catch {}
}
}
}
private ensureLoggingAndMemorySchema(): void {
if (this.readOnly) {
return;
}
try {
const hasReadyLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
if (hasReadyLogs) {
return;
}
// 1) If logs table missing but legacy memory table exists, migrate
// Existing installs may already have logs but still need the idempotent schema pass below.
// Only skip the legacy memory rename step when logs already exists.
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
if (!hasLogs && hasLegacyMemory) {
@@ -263,6 +551,11 @@ class SQLiteClient {
}
};
ensureNodeCol('description', "ALTER TABLE nodes ADD COLUMN description TEXT;");
ensureNodeCol('link', 'ALTER TABLE nodes ADD COLUMN link TEXT;');
ensureNodeCol('source', 'ALTER TABLE nodes ADD COLUMN source TEXT;');
ensureNodeCol('metadata', 'ALTER TABLE nodes ADD COLUMN metadata TEXT;');
ensureNodeCol('created_at', "ALTER TABLE nodes ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP;");
ensureNodeCol('updated_at', "ALTER TABLE nodes ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP;");
} catch (nodeErr) {
console.warn('Failed to ensure nodes columns:', nodeErr);
}
@@ -279,6 +572,25 @@ class SQLiteClient {
console.warn('Failed to ensure chats.created_at column:', chatErr);
}
// Normalize legacy chats table before creating chat triggers or views that reference modern columns.
try {
const hasChatsTable = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get();
if (hasChatsTable) {
const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>;
const chatColNames = new Set(chatCols.map((col) => col.name));
const needsChatRewrite =
chatColNames.has('focused_memory_id') ||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
.some((name) => !chatColNames.has(name));
if (needsChatRewrite) {
this.rebuildLegacyChatsTable(chatColNames);
}
}
} catch (chatSchemaErr) {
console.warn('Failed to normalize chats schema before log setup:', chatSchemaErr);
}
// 3) Helpful indexes on logs (clean up old names first)
this.db.exec(`
DROP INDEX IF EXISTS idx_memory_ts;
@@ -494,55 +806,20 @@ class SQLiteClient {
if (hasChats) {
try {
let chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
const hasFocusedMemoryId = chatCols.some((c: any) => c.name === 'focused_memory_id');
if (hasFocusedMemoryId) {
console.log('Removing legacy chats.focused_memory_id column');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
this.db.exec(`
BEGIN TRANSACTION;
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
INSERT INTO chats (
id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
)
SELECT id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup;
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
COMMIT;
`);
} catch (migrationErr) {
console.warn('Failed to migrate chats table (focused_memory_id removal):', migrationErr);
try { this.db.exec('ROLLBACK;'); } catch {}
} finally {
if (flippedForeignKeys) {
try { this.db.exec('PRAGMA foreign_keys=ON;'); } catch {}
}
}
const chatColNames = new Set(chatCols.map((c: any) => c.name));
const needsChatRewrite =
chatColNames.has('focused_memory_id') ||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
.some((name) => !chatColNames.has(name));
if (needsChatRewrite) {
this.rebuildLegacyChatsTable(chatColNames);
chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
}
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
if (chatCols.some((c: any) => c.name === 'thread_id')) {
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
}
const ensureCol = (name: string, ddl: string) => {
if (!chatCols.some((c: any) => c.name === name)) {
@@ -578,51 +855,7 @@ class SQLiteClient {
console.warn('Failed to drop legacy memory pipeline tables:', dropLegacyErr);
}
// 9) Ensure dimensions table exists (v0.1.16+ schema migration)
const hasDimensions = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
if (!hasDimensions) {
console.log('Creating dimensions table for v0.1.16+ features...');
this.db.exec(`
CREATE TABLE dimensions (
name TEXT PRIMARY KEY,
description TEXT,
is_priority INTEGER DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
// Seed default locked dimensions
const defaultDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences'];
const insertDimension = this.db.prepare(`
INSERT INTO dimensions (name, is_priority, updated_at)
VALUES (?, 1, datetime('now'))
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now')
`);
for (const dimension of defaultDimensions) {
try {
insertDimension.run(dimension);
} catch (e) {
console.warn(`Failed to seed dimension '${dimension}':`, e);
}
}
console.log('Dimensions table created and seeded with default locked dimensions');
} else {
// Check if existing dimensions table has description column
const dimensionCols = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
const hasDescription = dimensionCols.some(col => col.name === 'description');
if (!hasDescription) {
console.log('Adding description column to existing dimensions table...');
try {
this.db.exec('ALTER TABLE dimensions ADD COLUMN description TEXT;');
console.log('Description column added to dimensions table');
} catch (e) {
console.warn('Failed to add description column to dimensions table:', e);
}
}
}
// 10) Final schema pass migrations (source canonicalization, event_date, dimensions.icon, drop dead columns)
// 9) Final schema pass migrations (source-first backfill, event_date, soft contexts, drop dimensions)
try {
let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
let nodeColNames = nodeCols2.map(c => c.name);
@@ -634,6 +867,12 @@ class SQLiteClient {
nodeColNames = nodeCols2.map(c => c.name);
}
if (!nodeColNames.includes('chunk_status')) {
this.db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';");
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
nodeColNames = nodeCols2.map(c => c.name);
}
if (nodeColNames.includes('source')) {
if (nodeColNames.includes('content')) {
this.db.exec(`
@@ -691,19 +930,121 @@ class SQLiteClient {
// Add event_date
if (!nodeColNames.includes('event_date')) {
this.db.exec('ALTER TABLE nodes ADD COLUMN event_date TEXT;');
// Backfill from metadata.published_date where available
// Backfill from metadata.published_date or metadata.source_metadata.published_date where available
try {
this.db.exec(`
UPDATE nodes SET event_date = json_extract(metadata, '$.published_date')
WHERE event_date IS NULL AND json_extract(metadata, '$.published_date') IS NOT NULL;
UPDATE nodes
SET event_date = COALESCE(
json_extract(metadata, '$.source_metadata.published_date'),
json_extract(metadata, '$.published_date')
)
WHERE event_date IS NULL
AND COALESCE(
json_extract(metadata, '$.source_metadata.published_date'),
json_extract(metadata, '$.published_date')
) IS NOT NULL;
`);
} catch {}
}
// Add dimensions.icon
const dimCols2 = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
if (!dimCols2.some(c => c.name === 'icon')) {
this.db.exec('ALTER TABLE dimensions ADD COLUMN icon TEXT;');
if (!nodeColNames.includes('context_id')) {
this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
}
const hasContexts = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contexts'").get();
if (!hasContexts) {
this.db.exec(`
CREATE TABLE contexts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>;
const ensureContextCol = (name: string, ddl: string) => {
if (!contextCols.some(col => col.name === name)) {
this.db.exec(ddl);
}
};
ensureContextCol('description', "ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
ensureContextCol('icon', 'ALTER TABLE contexts ADD COLUMN icon TEXT;');
ensureContextCol('created_at', "ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
ensureContextCol('updated_at', "ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
this.db.exec(`
UPDATE contexts
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
`);
this.db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
`);
const hasLegacyDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
const hasLegacyNodeDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
this.db.exec(`
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dimension_count INTEGER NOT NULL,
assignment_count INTEGER NOT NULL,
payload TEXT
);
`);
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
const existingSnapshotCount = Number(
(this.db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() as { count?: number } | undefined)?.count ?? 0
);
if (existingSnapshotCount === 0) {
const dimensionCount = hasLegacyDimensions
? Number((this.db.prepare('SELECT COUNT(*) as count FROM dimensions').get() as { count?: number } | undefined)?.count ?? 0)
: 0;
const assignmentCount = hasLegacyNodeDimensions
? Number((this.db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() as { count?: number } | undefined)?.count ?? 0)
: 0;
const payload = hasLegacyNodeDimensions
? (
this.db.prepare(`
SELECT COALESCE(
json_group_array(
json_object(
'node_id', nd.node_id,
'dimension', nd.dimension,
'description', d.description,
'icon', d.icon,
'is_priority', d.is_priority
)
),
'[]'
) AS payload
FROM node_dimensions nd
LEFT JOIN dimensions d ON d.name = nd.dimension
`).get() as { payload?: string } | undefined
)?.payload ?? '[]'
: '[]';
this.db.prepare(`
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
VALUES (?, ?, ?)
`).run(dimensionCount, assignmentCount, payload);
}
this.db.exec(`
DROP INDEX IF EXISTS idx_dim_by_dimension;
DROP INDEX IF EXISTS idx_dim_by_node;
DROP TABLE IF EXISTS node_dimensions;
DROP TABLE IF EXISTS dimensions;
`);
}
// Drop dead columns (requires SQLite 3.35+)
@@ -742,7 +1083,11 @@ class SQLiteClient {
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
}
} catch (ftsErr) {
console.warn('Failed to rebuild nodes_fts:', ftsErr);
if (this.isSqliteCorruptError(ftsErr)) {
this.disableNodesFts('existing nodes_fts is corrupt and could not be rebuilt', ftsErr);
} else {
console.warn('Failed to rebuild nodes_fts:', ftsErr);
}
}
} catch (schemaErr) {
console.warn('Final schema pass migration error:', schemaErr);
@@ -854,6 +1199,15 @@ class SQLiteClient {
public close(): void {
this.db.close();
}
private isSqliteCorruptError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const sqliteError = error as Error & { code?: string };
return sqliteError.code === 'SQLITE_CORRUPT' || /database disk image is malformed/i.test(sqliteError.message || '');
}
}
// Export singleton instance (similar to PostgreSQL client interface)
@@ -1,124 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export interface AutoContextSettings {
autoContextEnabled: boolean;
lastPinnedMigration?: string;
}
const SETTINGS_FILE = 'settings.json';
const DEFAULT_SETTINGS: AutoContextSettings = {
autoContextEnabled: false,
};
let bootstrapAttempted = false;
function resolveBaseConfigDir(): string {
const override = process.env.RAH_CONFIG_DIR;
if (override && override.trim().length > 0) {
return override;
}
const home = os.homedir();
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'RA-H');
}
if (process.platform === 'win32') {
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
return path.join(roaming, 'RA-H');
}
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
return path.join(xdgConfig, 'ra-h');
}
function getSettingsDir(): string {
return path.join(resolveBaseConfigDir(), 'config');
}
function getSettingsPath(): string {
return path.join(getSettingsDir(), SETTINGS_FILE);
}
function ensureSettingsDirExists(): void {
const dir = getSettingsDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writeSettingsFile(settings: AutoContextSettings): AutoContextSettings {
ensureSettingsDirExists();
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8');
return settings;
}
function bootstrapFromLegacyPins(): void {
if (bootstrapAttempted) return;
bootstrapAttempted = true;
const settingsPath = getSettingsPath();
if (fs.existsSync(settingsPath)) {
return;
}
try {
const db = getSQLiteClient();
const countRow = db
.query<{ count: number }>('SELECT COUNT(*) as count FROM nodes WHERE 1=0 /* /* is_pinned removed */ removed */')
.rows[0];
const pinnedCount = Number(countRow?.count ?? 0);
if (pinnedCount > 0) {
writeSettingsFile({
autoContextEnabled: true,
lastPinnedMigration: new Date().toISOString(),
});
}
} catch (error) {
console.warn('Auto-context pin bootstrap failed:', error);
}
}
export function getAutoContextSettings(): AutoContextSettings {
bootstrapFromLegacyPins();
const settingsPath = getSettingsPath();
try {
if (!fs.existsSync(settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const raw = fs.readFileSync(settingsPath, 'utf-8');
const parsed = JSON.parse(raw);
return {
...DEFAULT_SETTINGS,
...parsed,
autoContextEnabled: Boolean(parsed?.autoContextEnabled),
};
} catch (error) {
console.warn('Failed to read auto-context settings, using defaults:', error);
return { ...DEFAULT_SETTINGS };
}
}
export function updateAutoContextSettings(
partial: Partial<AutoContextSettings>
): AutoContextSettings {
const current = getAutoContextSettings();
const next: AutoContextSettings = {
...current,
...partial,
autoContextEnabled:
typeof partial.autoContextEnabled === 'boolean'
? partial.autoContextEnabled
: current.autoContextEnabled,
};
return writeSettingsFile(next);
}
export function setAutoContextEnabled(enabled: boolean): AutoContextSettings {
return updateAutoContextSettings({ autoContextEnabled: enabled });
}
+52 -60
View File
@@ -1,26 +1,24 @@
/**
* Node metadata embedding service for RA-H knowledge management system
* Embeds node metadata (title, content, dimensions, AI analysis) into nodes.embedding field
* Embeds node metadata (title, source, context, AI analysis) into nodes.embedding field
*/
import OpenAI from 'openai';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import {
createDatabaseConnection,
getDbVectorCapability,
import {
createDatabaseConnection,
serializeFloat32Vector,
formatEmbeddingText,
batchProcess
batchProcess
} from './sqlite-vec';
import type { VectorCapability } from '@/services/database/sqlite-runtime';
interface NodeRecord {
id: number;
title: string;
source: string | null;
description: string | null;
dimensions_json: string;
context_name: string | null;
embedding?: Buffer | null;
embedding_updated_at?: string | null;
embedding_text?: string | null;
@@ -36,7 +34,6 @@ export class NodeEmbedder {
private openaiClient: OpenAI;
private openaiProvider: ReturnType<typeof createOpenAI>;
private db: ReturnType<typeof createDatabaseConnection>;
private readonly vectorCapability: VectorCapability;
private processedCount: number = 0;
private failedCount: number = 0;
@@ -45,25 +42,21 @@ export class NodeEmbedder {
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
this.openaiClient = new OpenAI({ apiKey });
this.openaiProvider = createOpenAI({ apiKey });
this.db = createDatabaseConnection();
this.vectorCapability = getDbVectorCapability(this.db);
}
/**
* Analyze node content with AI to extract insights
*/
private async analyzeNodeWithAI(node: NodeRecord): Promise<string> {
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
const dimensionsText = Array.isArray(dimensions) && dimensions.length ? dimensions.join(', ') : 'none';
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
Title: ${node.title}
Source: ${node.source || 'No source'}
Dimensions: ${dimensionsText}
Context: ${node.context_name || 'none'}
Focus on the main concepts, key relationships, and practical implications.`;
@@ -90,7 +83,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
@@ -104,15 +97,12 @@ Focus on the main concepts, key relationships, and practical implications.`;
return;
}
// Parse dimensions from JSON string
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
// Create base embedding text
let embeddingText = formatEmbeddingText(
node.title,
node.source || '',
dimensions,
node.description
node.description,
node.context_name
);
// Add AI analysis if source exists
@@ -128,36 +118,41 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Generate embedding
const embedding = await this.generateEmbedding(embeddingText);
const embeddingBlob = serializeFloat32Vector(embedding);
// Update database
const updateStmt = this.db.prepare(`
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
embedding_text = ?
WHERE id = ?
`);
const now = new Date().toISOString();
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
if (this.vectorCapability.available) {
try {
const pkCol = 'node_id';
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
const vectorString = `[${embedding.join(',')}]`;
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
insertStmt.run(BigInt(node.id), vectorString);
} catch (vecError) {
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
}
// Update vec_nodes virtual table
try {
// Determine correct column name for primary key (node_id vs id)
// Use declared PK column from your DB schema (confirmed: node_id)
const pkCol = 'node_id';
// Delete existing entry if any
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
// Insert new entry (use bracketed string format compatible with sqlite-vec)
const vectorString = `[${embedding.join(',')}]`;
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
insertStmt.run(BigInt(node.id), vectorString);
} catch (vecError) {
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
// Continue - main embedding is still saved
}
this.processedCount++;
console.log(`✓ Embedded node ${node.id}: "${node.title}"`);
} catch (error) {
this.failedCount++;
console.error(`✗ Failed to embed node ${node.id}:`, error);
@@ -170,54 +165,51 @@ Focus on the main concepts, key relationships, and practical implications.`;
*/
async embedNodes(options: EmbedNodeOptions = {}): Promise<{ processed: number; failed: number }> {
const { nodeId, forceReEmbed = false, verbose = false } = options;
let query: string;
let params: any[] = [];
if (nodeId) {
// Single node
query = `
SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
n.embedding, n.embedding_updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ?
`;
params = [nodeId];
} else if (forceReEmbed) {
// All nodes
query = `
SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
n.embedding, n.embedding_updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
ORDER BY n.id
`;
} else {
// Only nodes without embeddings
query = `
SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
n.embedding, n.embedding_updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
ORDER BY n.id
`;
}
const stmt = this.db.prepare(query);
const nodes = stmt.all(...params) as NodeRecord[];
if (nodes.length === 0) {
console.log('No nodes to process');
return { processed: 0, failed: 0 };
}
console.log(`Processing ${nodes.length} nodes...`);
// Process in batches
await batchProcess(
nodes,
@@ -233,9 +225,9 @@ Focus on the main concepts, key relationships, and practical implications.`;
console.log(`Progress: ${processed}/${total} nodes`);
} : undefined
);
console.log(`\nComplete! Processed: ${this.processedCount}, Failed: ${this.failedCount}`);
return {
processed: this.processedCount,
failed: this.failedCount
@@ -254,15 +246,15 @@ Focus on the main concepts, key relationships, and practical implications.`;
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
const nodeId = args.includes('--node-id')
const nodeId = args.includes('--node-id')
? parseInt(args[args.indexOf('--node-id') + 1])
: undefined;
const forceReEmbed = args.includes('--force');
const verbose = args.includes('--verbose');
const embedder = new NodeEmbedder();
try {
await embedder.embedNodes({ nodeId, forceReEmbed, verbose });
} finally {
+47 -19
View File
@@ -4,13 +4,9 @@
*/
import Database from 'better-sqlite3';
import {
ensureDatabaseDirectory,
getDatabasePath,
getDbVectorCapability,
getVecExtensionPath,
loadVecExtension,
} from '@/services/database/sqlite-runtime';
import path from 'path';
import os from 'os';
import { getDbVectorCapability as getVectorCapability } from '@/services/database/sqlite-runtime';
/**
* Serialize a float array to binary format for vec0 storage
@@ -35,24 +31,56 @@ export function deserializeFloat32Vector(blob: Buffer): number[] {
return vector;
}
/**
* Get SQLite database path from environment or default location
*/
export function getDatabasePath(): string {
const envPath = process.env.SQLITE_DB_PATH;
if (envPath) {
return envPath;
}
// Default path: ~/Library/Application Support/RA-H/db/rah.sqlite
const homeDir = os.homedir();
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
}
/**
* Get vec extension path from environment or default location
*/
export function getVecExtensionPath(): string {
const envPath = process.env.SQLITE_VEC_EXTENSION_PATH;
if (envPath) {
return envPath;
}
// Default path relative to project root
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
}
/**
* Create database connection with vec0 extension loaded
*/
export function createDatabaseConnection(): Database.Database {
const dbPath = getDatabasePath();
const vecPath = getVecExtensionPath();
ensureDatabaseDirectory(dbPath);
const db = new Database(dbPath);
const capability = loadVecExtension(db, vecPath);
if (!capability.available) {
console.warn(`Warning: ${capability.reason}`);
// Load vec0 extension
try {
db.loadExtension(vecPath);
} catch (error) {
console.error('Warning: Could not load vec0 extension:', error);
// Continue without vector support for non-vector operations
}
return db;
}
export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
export function getDbVectorCapability(db: Database.Database) {
return getVectorCapability(db);
}
/**
* Format embedding text for node metadata
@@ -60,12 +88,12 @@ export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
export function formatEmbeddingText(
title: string,
content: string,
dimensions: string[],
description?: string | null
description?: string | null,
contextName?: string | null
): string {
const descriptionText = description && description.trim() ? description.trim() : 'none';
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
const contextText = contextName && contextName.trim() ? contextName.trim() : 'none';
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nContext: ${contextText}`;
}
/**
@@ -78,16 +106,16 @@ export async function batchProcess<T, R>(
onProgress?: (processed: number, total: number) => void
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, Math.min(i + batchSize, items.length));
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
if (onProgress) {
onProgress(Math.min(i + batchSize, items.length), items.length);
}
}
return results;
}
-64
View File
@@ -1,64 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
export const createDimensionTool = tool({
description: 'Create a new dimension only when the user explicitly instructs you to do so. Always provide a description explaining what belongs in this category.',
inputSchema: z.object({
name: z.string().describe('Dimension name'),
description: z.string().min(1).max(500).describe('Dimension description explaining what content belongs in this dimension (required, max 500 characters)')
}),
execute: async (params) => {
console.log('📁 CreateDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
// Call POST /api/dimensions
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
description: params.description.trim()
})
});
if (!response.ok) {
let errorMessage = 'Failed to create dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to create dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Created dimension "${trimmedName}"${params.description ? ' with description' : ''}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create dimension',
data: null
};
}
}
});
+2 -4
View File
@@ -119,14 +119,12 @@ export const createEdgeTool = tool({
const fromLabel = formatNodeForChat({
id: fromNode.id,
title: fromNode.title,
dimensions: fromNode.dimensions || []
title: fromNode.title
});
const toLabel = formatNodeForChat({
id: toNode.id,
title: toNode.title,
dimensions: toNode.dimensions || []
title: toNode.title
});
return {
+13 -11
View File
@@ -2,7 +2,6 @@ import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { normalizeDimensions } from '@/services/database/quality';
function extractTextFromMessageContent(content: unknown): string {
if (typeof content === 'string') {
@@ -57,7 +56,7 @@ function inferSourceFromContext(params: { title: string; description?: string; s
}
export const createNodeTool = tool({
description: 'Create node. Set the primary context explicitly when it is clear; otherwise the server will infer the best-fit context automatically so the node is not left unscoped. Infer a clean title, dimensions, and natural description with best effort. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
description: 'Create a node. Set context explicitly only when it is clear and useful; otherwise leave it blank. Focus on a clean title, a strong natural description, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
inputSchema: z.object({
title: z.string().describe('The title of the node'),
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
@@ -66,23 +65,18 @@ export const createNodeTool = tool({
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'),
context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'),
dimensions: z
.array(z.string())
.max(5)
.optional()
.describe('Optional secondary dimension tags to apply to this node (0-5 items).'),
metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.')
}),
execute: async (params, context) => {
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedDimensions = normalizeDimensions(params.dimensions || [], 5);
const canonicalSource = inferSourceFromContext(params, context);
// Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...params, source: canonicalSource, dimensions: trimmedDimensions })
body: JSON.stringify({ ...params, source: canonicalSource })
});
const result = await response.json();
@@ -95,10 +89,18 @@ export const createNodeTool = tool({
};
}
const formattedDisplay = formatNodeForChat({
id: result.data.id,
title: result.data.title,
});
return {
success: true,
data: result.data,
message: `Created: ${formatNodeForChat(result.data)}`
data: {
...result.data,
formatted_display: formattedDisplay,
},
message: `Created: ${formattedDisplay}`
};
} catch (error) {
return {
-58
View File
@@ -1,58 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
export const deleteDimensionTool = tool({
description: 'Delete a dimension and remove all node associations',
inputSchema: z.object({
name: z.string().describe('Dimension name to delete')
}),
execute: async (params) => {
console.log('🗑️ DeleteDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions?name=${encodeURIComponent(trimmedName)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
let errorMessage = 'Failed to delete dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to delete dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Deleted dimension "${trimmedName}" and removed all node associations`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete dimension',
data: null
};
}
}
});
-91
View File
@@ -1,91 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { DimensionService } from '@/services/database/dimensionService';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export const getDimensionTool = tool({
description: 'Get dimension details: description and node count.',
inputSchema: z.object({
name: z.string().describe('The exact name of the dimension to retrieve')
}),
execute: async ({ name }) => {
console.log('📁 GetDimension tool called for:', name);
try {
const trimmedName = name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
// Get dimension details from service
const dimension = await DimensionService.getDimensionByName(trimmedName);
// Get node count for this dimension
const sqlite = getSQLiteClient();
const countResult = sqlite.query(`
SELECT COUNT(*) AS count
FROM node_dimensions
WHERE dimension = ?
`, [trimmedName]);
const nodeCount = countResult.rows.length > 0
? Number((countResult.rows[0] as { count: number }).count)
: 0;
if (!dimension) {
// Dimension might exist in node_dimensions but not in dimensions table
if (nodeCount > 0) {
return {
success: true,
data: {
name: trimmedName,
description: null,
nodeCount,
exists: true,
hasMetadata: false
},
message: `Dimension "${trimmedName}" exists with ${nodeCount} nodes but has no metadata (description/priority not set).`
};
}
return {
success: false,
error: `Dimension "${trimmedName}" not found`,
data: null
};
}
const result = {
name: dimension.name,
description: dimension.description,
nodeCount,
updatedAt: dimension.updated_at,
exists: true,
hasMetadata: true
};
// Build descriptive message
const parts: string[] = [];
parts.push(`Dimension: ${result.name}`);
parts.push(`Nodes: ${result.nodeCount}`);
if (result.description) parts.push(`Description: ${result.description}`);
parts.push(`Last updated: ${result.updatedAt}`);
return {
success: true,
data: result,
message: parts.join('\n')
};
} catch (error) {
console.error('GetDimension tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get dimension',
data: null
};
}
}
});
+2 -1
View File
@@ -36,7 +36,8 @@ export const getNodesByIdTool = tool({
title: node.title,
link: node.link,
event_date: node.event_date ?? null,
dimensions: node.dimensions || [],
context_id: node.context_id ?? null,
context: node.context ?? null,
chunk_status: node.chunk_status || 'unknown',
created_at: node.created_at,
updated_at: node.updated_at,
+38 -24
View File
@@ -19,11 +19,11 @@ export const queryContextsTool = tool({
description: 'List and inspect contexts. Use this to discover available primary scopes before filtering nodes or assigning node context.',
inputSchema: z.object({
filters: z.object({
id: z.number().int().positive().optional(),
name: z.string().optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).default(50).optional(),
includeNodes: z.boolean().default(false).optional(),
id: z.number().int().positive().describe('Exact context ID lookup.').optional(),
name: z.string().describe('Exact context name lookup.').optional(),
search: z.string().describe('Case-insensitive search across context names and descriptions.').optional(),
limit: z.number().min(1).max(100).default(50).describe('Maximum number of contexts to return.').optional(),
includeNodes: z.boolean().default(false).describe('Include the node list for an exact single-context lookup.').optional(),
}).optional(),
}),
execute: async ({ filters = {} }: { filters?: QueryContextFilters }) => {
@@ -33,12 +33,15 @@ export const queryContextsTool = tool({
const normalizedSearch = filters.search?.trim().toLowerCase();
let contexts = await contextService.listContexts();
if (filters.id !== undefined) {
contexts = contexts.filter((context) => context.id === filters.id);
}
if (normalizedName) {
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
}
if (normalizedSearch) {
contexts = contexts.filter((context) =>
matchesSearch(context.name, normalizedSearch) ||
@@ -47,35 +50,46 @@ export const queryContextsTool = tool({
}
const limitedContexts = contexts.slice(0, limit);
const includeNodes = filters.includeNodes === true && limitedContexts.length === 1 && (filters.id !== undefined || Boolean(normalizedName));
const canIncludeNodes =
filters.includeNodes === true &&
limitedContexts.length === 1 &&
(filters.id !== undefined || Boolean(normalizedName));
const enriched = await Promise.all(limitedContexts.map(async (context) => {
if (!includeNodes) return context;
const nodes = await contextService.getNodesForContext(context.id);
return {
...context,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
description: node.description ?? null,
dimensions: node.dimensions || [],
context_id: node.context_id ?? null,
updated_at: node.updated_at,
})),
};
}));
const contextsWithNodes = await Promise.all(
limitedContexts.map(async (context) => {
if (!canIncludeNodes) return context;
const nodes = await contextService.getNodesForContext(context.id);
return {
...context,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
description: node.description ?? null,
context_id: node.context_id ?? null,
context: node.context ?? null,
updated_at: node.updated_at,
})),
};
})
);
const message = contextsWithNodes.length === 0
? 'No contexts found.'
: `Found ${contextsWithNodes.length} context${contextsWithNodes.length === 1 ? '' : 's'}:\n${contextsWithNodes.map((context) => `${context.name} (#${context.id}, ${context.count} nodes)`).join('\n')}`;
return {
success: true,
data: {
contexts: enriched,
count: enriched.length,
contexts: contextsWithNodes,
count: contextsWithNodes.length,
total_available: contexts.length,
filters_applied: filters,
},
message: enriched.length === 0 ? 'No contexts found.' : `Found ${enriched.length} context(s).`,
message,
};
} catch (error) {
console.error('QueryContexts tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to query contexts',
-78
View File
@@ -1,78 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import type { Node } from '@/types/database';
export const queryDimensionNodesTool = tool({
description: 'Get nodes in a dimension, sorted by connection count.',
inputSchema: z.object({
dimension: z.string().describe('The dimension name to query nodes from'),
limit: z.number().optional().default(20).describe('Maximum number of nodes to return (default: 20)'),
offset: z.number().optional().default(0).describe('Number of nodes to skip for pagination'),
includeContent: z.boolean().optional().default(false).describe('Include truncated content preview (default: false)'),
}),
execute: async ({ dimension, limit = 20, offset = 0, includeContent = false }) => {
try {
// Query nodes with this dimension
const nodes = await nodeService.getNodes({
dimensions: [dimension],
limit,
offset,
sortBy: 'edges',
});
if (!nodes || nodes.length === 0) {
return {
success: true,
dimension,
nodes: [],
total: 0,
message: `No nodes found in dimension "${dimension}"`,
};
}
const formattedNodes = nodes.map((node: Node) => {
const formatted: Record<string, unknown> = {
id: node.id,
title: node.title,
label: formatNodeForChat({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
}),
edgeCount: node.edge_count || 0,
dimensions: node.dimensions || [],
created_at: node.created_at,
updated_at: node.updated_at,
event_date: node.event_date ?? null,
};
if (includeContent && node.source) {
const previewSource = node.source;
// Truncate to ~100 chars
formatted.sourcePreview = previewSource.length > 100
? previewSource.substring(0, 100) + '...'
: previewSource;
}
return formatted;
});
return {
success: true,
dimension,
nodes: formattedNodes,
total: nodes.length,
hasMore: nodes.length >= limit,
message: `Found ${nodes.length} nodes in dimension "${dimension}"`,
};
} catch (error) {
console.error('queryDimensionNodes error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to query dimension nodes',
};
}
},
});
-100
View File
@@ -1,100 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
export const queryDimensionsTool = tool({
description: 'List the existing canonical dimensions with node counts. Use this before assigning dimensions to nodes. Do not invent new dimensions without explicit user instruction.',
inputSchema: z.object({
filters: z.object({
search: z.string().describe('Search term to match against dimension names').optional(),
limit: z.number().min(1).max(100).default(50).describe('Maximum number of results to return')
}).optional()
}),
execute: async ({ filters = {} }) => {
console.log('📁 QueryDimensions tool called with filters:', JSON.stringify(filters, null, 2));
try {
const limit = filters.limit || 50;
const baseUrl = getInternalApiBaseUrl();
// Use existing API endpoint for dimension listing
const response = await fetch(`${baseUrl}/api/dimensions/popular`);
if (!response.ok) {
let errorMessage = 'Failed to query dimensions';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
errorMessage = `Failed to query dimensions: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: { dimensions: [], count: 0 }
};
}
const result = await response.json();
if (!result.success || !result.data) {
return {
success: false,
error: 'Invalid response from dimensions API',
data: { dimensions: [], count: 0 }
};
}
// Apply filters
let dimensions = result.data as Array<{
dimension: string;
count: number;
description: string | null;
}>;
// Filter by search term
if (filters.search) {
const searchLower = filters.search.toLowerCase();
dimensions = dimensions.filter(d =>
d.dimension.toLowerCase().includes(searchLower)
);
}
// Apply limit
const limitedDimensions = dimensions.slice(0, limit);
// Format for display
const formattedDimensions = limitedDimensions.map(d => ({
name: d.dimension,
count: d.count,
description: d.description
}));
// Build message
const filterParts: string[] = [];
if (filters.search) filterParts.push(`matching "${filters.search}"`);
const filterDesc = filterParts.length > 0 ? ` (${filterParts.join(', ')})` : '';
const dimensionList = formattedDimensions
.map(d => `${d.name} (${d.count} nodes)${d.description ? ` - ${d.description}` : ''}`)
.join('\n');
return {
success: true,
data: {
dimensions: formattedDimensions,
count: formattedDimensions.length,
total_available: dimensions.length,
filters_applied: filters
},
message: `Found ${formattedDimensions.length} dimensions${filterDesc}:\n${dimensionList || 'No dimensions found'}`
};
} catch (error) {
console.error('QueryDimensions tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to query dimensions',
data: { dimensions: [], count: 0 }
};
}
}
});
+2 -3
View File
@@ -62,8 +62,7 @@ export const queryEdgeTool = tool({
const formattedConnections = limitedConnections.map(connection => {
const formattedNode = formatNodeForChat({
id: connection.connected_node.id,
title: connection.connected_node.title,
dimensions: connection.connected_node.dimensions || []
title: connection.connected_node.title
});
const context = connection.edge.context as Record<string, unknown> | undefined;
@@ -85,7 +84,7 @@ export const queryEdgeTool = tool({
id: connection.connected_node.id,
title: connection.connected_node.title,
description: truncateText(connection.connected_node.description, 140),
dimensions: connection.connected_node.dimensions || [],
context: connection.connected_node.context ?? null,
formatted_display: formattedNode
}
};
+3 -12
View File
@@ -6,7 +6,6 @@ import type { Node } from '@/types/database';
import { scoreNodeSearchMatch } from '@/services/database/searchRanking';
type QueryNodeFilters = {
dimensions?: string[];
contextId?: number;
search?: string;
limit?: number;
@@ -17,10 +16,9 @@ type QueryNodeFilters = {
};
export const queryNodesTool = tool({
description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.',
description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Use context filtering only when the user is explicitly asking about a known context.',
inputSchema: z.object({
filters: z.object({
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
search: z.string().describe('Search term to match against node title, description, or source').optional(),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
@@ -54,7 +52,6 @@ export const queryNodesTool = tool({
const formatted = formatNodeForChat({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
});
return {
@@ -63,7 +60,6 @@ export const queryNodesTool = tool({
nodes: [{
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
created_at: node.created_at,
updated_at: node.updated_at,
event_date: node.event_date ?? null,
@@ -83,7 +79,6 @@ export const queryNodesTool = tool({
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
limit,
dimensions: queryFilters.dimensions,
contextId: queryFilters.contextId,
search: queryFilters.search,
searchMode: searchTerm ? 'hybrid' : 'standard',
@@ -97,9 +92,7 @@ export const queryNodesTool = tool({
return Array.isArray(nodes) ? nodes : [];
};
const effectiveFilters = searchTerm
? { ...filters, dimensions: undefined }
: { ...filters };
const effectiveFilters = { ...filters };
let safeNodes = await runQuery(effectiveFilters);
@@ -118,12 +111,10 @@ export const queryNodesTool = tool({
const formatted = formatNodeForChat({
id: node.id,
title: node.title,
dimensions: node.dimensions || []
});
return {
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
created_at: node.created_at,
updated_at: node.updated_at,
event_date: node.event_date ?? null,
@@ -133,7 +124,7 @@ export const queryNodesTool = tool({
// Create message with formatted node labels only (no full node payload)
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', ');
const message = `Found ${safeNodes.length} nodes${effectiveFilters.dimensions ? ` with dimensions: ${effectiveFilters.dimensions.join(', ')}` : ''}${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
const message = `Found ${safeNodes.length} nodes${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
return {
success: true,
-75
View File
@@ -1,75 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
export const updateDimensionTool = tool({
description: 'Update a dimension name or description.',
inputSchema: z.object({
currentName: z.string().describe('Current dimension name'),
newName: z.string().optional().describe('New dimension name (if renaming)'),
description: z.string().max(500).optional().describe('New description (max 500 characters)')
}),
execute: async (params) => {
console.log('📝 UpdateDimension tool called with params:', JSON.stringify(params, null, 2));
try {
// Validate at least one update field
if (!params.newName && params.description === undefined) {
return {
success: false,
error: 'At least one update field (newName or description) must be provided',
data: null
};
}
// Handle rename + other updates
const body: any = {
currentName: params.currentName.trim(),
description: params.description?.trim() || ''
};
if (params.newName) {
body.newName = params.newName.trim();
}
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let errorMessage = 'Failed to update dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to update dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
const updates = [];
if (params.newName) updates.push(`renamed to "${params.newName}"`);
if (params.description !== undefined) updates.push('description updated');
return {
success: true,
data: result.data,
message: `Updated dimension "${params.currentName}": ${updates.join(', ')}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update dimension',
data: null
};
}
}
});
+4 -8
View File
@@ -1,7 +1,6 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { normalizeDimensions } from '@/services/database/quality';
export const updateNodeTool = tool({
description: 'Update node fields. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless context_id is supplied explicitly. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
@@ -14,7 +13,6 @@ export const updateNodeTool = tool({
link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'),
dimensions: z.array(z.string()).optional().describe('New secondary dimension tags - completely replaces existing dimensions'),
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
}),
@@ -28,10 +26,7 @@ export const updateNodeTool = tool({
};
}
if (Array.isArray(updates.dimensions)) {
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
}
// Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -51,16 +46,17 @@ export const updateNodeTool = tool({
return {
success: true,
data: result.node,
message: `Updated node ID ${id}${updates.dimensions ? ` with dimensions: ${updates.dimensions.join(', ')}` : ''}`
message: `Updated node ID ${id}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update node',
data: null
};
};
}
}
});
// Legacy export for backwards compatibility
export const updateItemTool = updateNodeTool;
+2 -5
View File
@@ -18,7 +18,7 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
orchestration: {
id: 'orchestration',
name: 'Orchestration',
description: 'Web search and reasoning tools',
description: 'Workflows, web search, and reasoning (orchestrator only)',
icon: '●',
color: '#8b5cf6'
},
@@ -37,13 +37,10 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
queryNodes: 'core',
getNodesById: 'core',
queryEdge: 'core',
queryDimensions: 'core',
getDimension: 'core',
queryDimensionNodes: 'core',
queryContexts: 'core',
searchContentEmbeddings: 'core',
// Orchestration: Web search and reasoning
// Orchestration: Web search and reasoning (orchestrator only)
webSearch: 'orchestration',
think: 'orchestration',
@@ -7,7 +7,6 @@
export interface NodeData {
id: number;
title: string;
dimensions?: string[];
}
/**
@@ -45,7 +44,6 @@ export function parseNodeMarkers(text: string): Array<NodeData & { raw: string }
raw,
id: parseInt(id, 10),
title,
dimensions: []
});
}
+7 -39
View File
@@ -1,20 +1,13 @@
import { getToolGroup, groupTools, getAllToolsByGroup } from './groups';
import { queryNodesTool } from '../database/queryNodes';
import { getNodesByIdTool } from '../database/getNodesById';
import { queryEdgeTool } from '../database/queryEdge';
import { queryContextsTool } from '../database/queryContexts';
import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode';
import { deleteNodeTool } from '../database/deleteNode';
import { createEdgeTool } from '../database/createEdge';
import { queryEdgeTool } from '../database/queryEdge';
import { updateEdgeTool } from '../database/updateEdge';
import { createDimensionTool } from '../database/createDimension';
import { updateDimensionTool } from '../database/updateDimension';
// lockDimension and unlockDimension consolidated into updateDimension (use isPriority param)
import { deleteDimensionTool } from '../database/deleteDimension';
import { queryDimensionsTool } from '../database/queryDimensions';
import { getDimensionTool } from '../database/getDimension';
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
import { queryContextsTool } from '../database/queryContexts';
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
import { webSearchTool } from '../other/webSearch';
import { thinkTool } from '../other/think';
@@ -24,34 +17,29 @@ import { paperExtractTool } from '../other/paperExtract';
import { sqliteQueryTool } from '../other/sqliteQuery';
import { logEvalToolCall } from '@/services/evals/evalsLogger';
// Core tools available to all agents (read-only graph operations)
// Read tools (graph queries)
const CORE_TOOLS: Record<string, any> = {
sqliteQuery: sqliteQueryTool,
queryNodes: queryNodesTool,
getNodesById: getNodesByIdTool,
queryEdge: queryEdgeTool,
queryDimensions: queryDimensionsTool,
getDimension: getDimensionTool,
queryDimensionNodes: queryDimensionNodesTool,
queryContexts: queryContextsTool,
searchContentEmbeddings: searchContentEmbeddingsTool,
};
// Utility tools
const ORCHESTRATION_TOOLS: Record<string, any> = {
webSearch: webSearchTool,
think: thinkTool,
};
// Execution tools for worker agents (includes write operations)
// Write tools (includes extraction)
const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool,
updateNode: updateNodeTool,
deleteNode: deleteNodeTool,
createEdge: createEdgeTool,
updateEdge: updateEdgeTool,
createDimension: createDimensionTool,
updateDimension: updateDimensionTool,
deleteDimension: deleteDimensionTool,
youtubeExtract: youtubeExtractTool,
websiteExtract: websiteExtractTool,
paperExtract: paperExtractTool,
@@ -69,28 +57,9 @@ export const TOOLS: Record<string, any> = {
...EXECUTION_TOOLS,
};
const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
...Object.keys(CORE_TOOLS),
'webSearch',
'think',
'createNode',
'updateNode',
'deleteNode',
'createEdge',
'updateEdge',
'createDimension',
'updateDimension',
'deleteDimension',
'youtubeExtract',
'websiteExtract',
'paperExtract',
]));
const ORCHESTRATOR_TOOL_NAMES = Object.keys(TOOLS);
const EXECUTOR_TOOL_NAMES = [
...Object.keys(CORE_TOOLS),
...Object.keys(ORCHESTRATION_TOOLS),
...Object.keys(EXECUTION_TOOLS),
];
const EXECUTOR_TOOL_NAMES = Object.keys(TOOLS);
// Note: PLANNER_TOOL_NAMES kept for backwards compatibility but workflows now use specific tool sets
const PLANNER_TOOL_NAMES = [
@@ -99,7 +68,6 @@ const PLANNER_TOOL_NAMES = [
'think',
'updateNode',
'createEdge',
'updateDimension',
];
/**
+87 -38
View File
@@ -8,15 +8,21 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string' ? candidate.trim().replace(/\s+/g, ' ') : '';
const normalizedCandidate = typeof candidate === 'string'
? candidate.trim().replace(/\s+/g, ' ')
: '';
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
return normalizedCandidate.slice(0, 500);
}
const lead = normalizedCandidate || fallbackLead.trim();
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
return `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`.slice(0, 500);
const joined = `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`;
return joined.slice(0, 500);
}
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try {
const prompt = `Analyze this ${contentType} content and provide classification.
@@ -25,35 +31,57 @@ Title: "${title}"
Description: "${description}"
CRITICAL nodeDescription rules (max 500 chars):
1. Write natural prose.
2. Make clear what this literally is.
3. State the actual finding, method, or contribution.
4. Make clear why it belongs in the graph. If unclear, say so naturally.
5. Make the workflow status clear.
1. Write natural prose, not labels or a checklist.
2. Make clear what this literally is: "Paper by…", "Research from…", "Preprint introducing…"
3. Name the authors if known from the metadata.
4. State the actual finding, method, or contribution not "a study on X" but what they actually found or built.
5. Make clear why it belongs in the graph. If that cannot be inferred, say so naturally.
6. Make the workflow status clear. If unknown, say naturally that it has not been reviewed yet.
7. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to". State things directly.
Respond with ONLY valid JSON:
Examples:
- Title: "Attention Is All You Need" / Authors: Vaswani et al.
GOOD: "Vaswani et al. introduce the Transformer architecture — replaces recurrence with self-attention for sequence modeling. It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
BAD: "This paper discusses a new architecture called the Transformer and explores its applications."
- Title: "Scaling Laws for Neural Language Models" / Authors: Kaplan et al.
GOOD: "Kaplan et al. show that LLM performance scales as a power law with compute, data, and parameters — and compute matters most. It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
BAD: "A study examining how neural language models scale with different factors."
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "A comprehensive summary.",
"nodeDescription": "<your natural description>",
"reasoning": "Brief explanation"
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your natural description following the rules above>",
"tags": ["relevant", "semantic", "tags"],
"reasoning": "Brief explanation of classification choices"
}`;
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt,
maxOutputTokens: 800
});
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
console.warn('Paper analysis fallback (using default description):', error);
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('Paper analysis fallback (using default description):', message);
return {
enhancedDescription: description,
nodeDescription: undefined,
tags: [],
reasoning: 'Fallback description used'
};
}
@@ -63,20 +91,30 @@ export const paperExtractTool = tool({
description: 'Extract a PDF or research paper into a node with summary, metadata, and full-text source',
inputSchema: z.object({
url: z.string().describe('The PDF URL to add to inbox'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
title: z.string().optional().describe('Custom title (auto-generated if not provided)')
}),
execute: async ({ url, title, dimensions }) => {
execute: async ({ url, title }) => {
try {
// Validate PDF URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
return {
success: false,
error: 'Invalid URL format - must start with http:// or https://',
data: null
};
}
// Check if URL likely points to a PDF
if (!url.toLowerCase().includes('.pdf') && !url.includes('arxiv.org')) {
return { success: false, error: 'URL does not appear to point to a PDF file', data: null };
return {
success: false,
error: 'URL does not appear to point to a PDF file',
data: null
};
}
let result: { success: boolean; source?: string; metadata?: any; error?: string };
try {
const extractionResult = await extractPaper(url);
result = {
@@ -92,73 +130,84 @@ export const paperExtractTool = tool({
}
};
} catch (error: any) {
result = { success: false, error: error.message || 'TypeScript extraction failed' };
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
}
if (!result.success || !result.source) {
return { success: false, error: result.error || 'Failed to extract PDF content', data: null };
return {
success: false,
error: result.error || 'Failed to extract PDF content',
data: null
};
}
console.log('🎯 PDF extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
result.source.substring(0, 2000) || 'PDF document content',
'pdf'
);
// Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
const nodeDescription = ensureNodeDescription(
aiAnalysis?.nodeDescription,
`Research paper or PDF from ${new URL(url).hostname} titled ${nodeTitle}`
);
const trimmedDimensions = Array.isArray(dimensions) ? dimensions.slice(0, 5) : [];
const fallbackDescriptionLead = `PDF document titled "${nodeTitle}"`;
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
description: nodeDescription,
description: finalDescription,
source: result.source,
link: url,
dimensions: trimmedDimensions,
metadata: {
type: 'pdf',
state: 'not_processed',
captured_method: 'paper_extract',
captured_by: 'agent',
captured_by: 'human',
source_metadata: {
hostname: new URL(url).hostname,
author: result.metadata?.author || result.metadata?.info?.Author,
pages: result.metadata?.pages,
file_size: result.metadata?.file_size,
content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'typescript',
ai_analysis: aiAnalysis?.reasoning,
enhanced_description: aiAnalysis?.enhancedDescription,
refined_at: new Date().toISOString()
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
refined_at: new Date().toISOString(),
}
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return { success: false, error: createResult.error || 'Failed to create node', data: null };
return {
success: false,
error: createResult.error || 'Failed to create node',
data: null
};
}
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
console.log('🎯 PaperExtract completed successfully');
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle })
: nodeTitle;
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
message: `Added ${formattedNode}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: result.source.length,
url,
dimensions: actualDimensions
url: url
}
};
} catch (error) {
+10 -2
View File
@@ -2,10 +2,18 @@ import { tool } from 'ai';
import { z } from 'zod';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getDatabasePath } from '@/services/database/sqlite-runtime';
import path from 'path';
const execAsync = promisify(exec);
// Get database path (same logic as sqlite-client.ts)
function getDatabasePath(): string {
return process.env.SQLITE_DB_PATH || path.join(
process.env.HOME || '~',
'Library/Application Support/RA-H/db/rah.sqlite'
);
}
// Security: Only allow SELECT statements
function isReadOnlyQuery(sql: string): boolean {
const normalized = sql.trim().toLowerCase();
@@ -37,7 +45,7 @@ function isReadOnlyQuery(sql: string): boolean {
}
export const sqliteQueryTool = tool({
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, edges, dimensions, chunks. Use PRAGMA table_info(tablename) for schema.',
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, contexts, edges, chunks, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema.',
inputSchema: z.object({
sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'),
+91 -94
View File
@@ -7,19 +7,19 @@ import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
interface ExistingDimension {
name: string;
description: string | null;
}
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string' ? candidate.trim().replace(/\s+/g, ' ') : '';
const normalizedCandidate = typeof candidate === 'string'
? candidate.trim().replace(/\s+/g, ' ')
: '';
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
return normalizedCandidate.slice(0, 500);
}
const lead = normalizedCandidate || fallbackLead.trim();
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
return `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`.slice(0, 500);
const joined = `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`;
return joined.slice(0, 500);
}
function inferWebsiteContentType(url: string): 'website' | 'tweet' {
@@ -33,47 +33,13 @@ function inferWebsiteContentType(url: string): 'website' | 'tweet' {
}
}
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
// AI-powered content analysis
async function analyzeContentWithAI(
title: string,
description: string,
contentType: string
) {
try {
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions/popular`);
if (!response.ok) return [];
const result = await response.json();
if (!Array.isArray(result.data)) return [];
return result.data
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
description: typeof dimension.description === 'string' ? dimension.description.trim() : null
}))
.filter((dimension: ExistingDimension) => dimension.name.length > 0);
} catch (error) {
console.warn('Website dimension fetch fallback (no dimension context):', error);
return [];
}
}
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of selected) {
if (typeof value !== 'string') continue;
const matched = byLowerName.get(value.trim().toLowerCase());
if (!matched) continue;
const key = matched.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(matched);
if (normalized.length >= max) break;
}
return normalized;
}
async function analyzeContentWithAI(title: string, description: string, contentType: string, existingDimensions: ExistingDimension[]) {
try {
const availableDimensionsBlock = existingDimensions.length > 0
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
: '- No existing dimensions available. Return an empty dimensions array.';
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
@@ -81,55 +47,77 @@ Description: "${description}"
CRITICAL nodeDescription rules (max 500 chars):
1. Write natural prose, not labels or a checklist.
2. Make clear what this literally is using explicit entity words only.
2. Make clear what this literally is using explicit entity words like "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…", "Tweet by…", "Post by…"
3. Name the author/site if known from the metadata.
4. State the actual claim or thesis.
5. Make clear why it belongs in the graph. If that remains unclear, say so naturally.
6. Make workflow status clear.
4. State the actual claim or thesis don't paraphrase into vague abstractions.
5. Make clear why it belongs in the graph. If that cannot be inferred, say so naturally.
6. Make the workflow status clear. If unknown, say naturally that it has not been reviewed yet.
7. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to". State things directly.
Available dimensions:
${availableDimensionsBlock}
Examples:
- Title: "Software is eating the world — again" / Author: Andrej Karpathy
GOOD: "Karpathy's blog post arguing AI agents make software fluid — they can rip functionality from repos instead of taking dependencies. It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
BAD: "By Karpathy — discusses the importance of software becoming more fluid and malleable with agents."
Respond with ONLY valid JSON:
- Title: "The case for slowing down AI" / Site: The Atlantic
GOOD: "Atlantic article making the case that AI labs should voluntarily slow capability research until safety catches up. It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
BAD: "This article explores ideas about slowing down AI development and its implications."
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "A comprehensive summary.",
"nodeDescription": "<your natural description>",
"dimensions": ["existing-dimension-1"],
"reasoning": "Brief explanation"
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your natural description following the rules above>",
"reasoning": "Brief explanation of classification choices"
}`;
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt,
maxOutputTokens: 800
});
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
console.warn('Website analysis fallback (using default description):', error);
return { enhancedDescription: description, nodeDescription: undefined, dimensions: [], reasoning: 'Fallback description used' };
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('Website analysis fallback (using default description):', message);
return {
enhancedDescription: description,
nodeDescription: undefined,
reasoning: 'Fallback description used'
};
}
}
export const websiteExtractTool = tool({
description: 'Extract website content and metadata into a node with summary, tags, and raw source text',
description: 'Extract website content and metadata into a node with summary and raw source text',
inputSchema: z.object({
url: z.string().describe('The website URL to add to knowledge base'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
title: z.string().optional().describe('Custom title (auto-generated if not provided)')
}),
execute: async ({ url, title, dimensions }) => {
execute: async ({ url, title }) => {
try {
// Validate URL format
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
return {
success: false,
error: 'Invalid URL format - must start with http:// or https://',
data: null
};
}
let result: { success: boolean; source?: string; metadata?: any; error?: string };
try {
const extractionResult = await extractWebsite(url);
result = {
@@ -146,76 +134,85 @@ export const websiteExtractTool = tool({
}
};
} catch (error: any) {
result = { success: false, error: error.message || 'TypeScript extraction failed' };
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
}
if (!result.success || !result.source) {
return { success: false, error: result.error || 'Failed to extract website content', data: null };
return {
success: false,
error: result.error || 'Failed to extract website content',
data: null
};
}
const existingDimensions = await fetchExistingDimensions();
console.log('🎯 Website extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const contentType = inferWebsiteContentType(url);
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `Website: ${new URL(url).hostname}`,
result.source.substring(0, 2000) || 'Website content',
inferWebsiteContentType(url),
existingDimensions
contentType
);
// Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions.slice(0, 5) : [];
const finalDimensions = suppliedDimensions.length > 0 ? suppliedDimensions : (aiAnalysis?.dimensions || []).slice(0, 5);
const nodeDescription = ensureNodeDescription(
aiAnalysis?.nodeDescription,
`Website article from ${result.metadata?.site_name || new URL(url).hostname} titled ${nodeTitle}`
);
const fallbackDescriptionLead = `${contentType === 'tweet' ? 'Tweet' : 'Website article'} from ${result.metadata?.author || result.metadata?.site_name || new URL(url).hostname} titled "${nodeTitle}"`;
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
description: nodeDescription,
description: finalDescription,
source: result.source,
link: url,
dimensions: finalDimensions,
event_date: result.metadata?.published_date || result.metadata?.date || null,
metadata: {
type: inferWebsiteContentType(url),
type: contentType,
state: 'not_processed',
captured_method: 'website_extract',
captured_by: 'agent',
captured_by: 'human',
source_metadata: {
hostname: new URL(url).hostname,
author: result.metadata?.author,
site_name: result.metadata?.site_name,
date: result.metadata?.date,
og_image: result.metadata?.og_image,
extraction_method: result.metadata?.extraction_method,
ai_analysis: aiAnalysis?.reasoning,
refined_at: new Date().toISOString()
published_date: result.metadata?.published_date || result.metadata?.date,
content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
refined_at: new Date().toISOString(),
}
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return { success: false, error: createResult.error || 'Failed to create node', data: null };
return {
success: false,
error: createResult.error || 'Failed to create node',
data: null
};
}
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
console.log('🎯 WebsiteExtract completed successfully');
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle })
: nodeTitle;
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
message: `Added ${formattedNode}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: result.source.length,
url,
dimensions: actualDimensions
url: url
}
};
} catch (error) {
+83 -104
View File
@@ -7,11 +7,6 @@ import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
interface ExistingDimension {
name: string;
description: string | null;
}
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string'
? candidate.trim().replace(/\s+/g, ' ')
@@ -27,53 +22,13 @@ function ensureNodeDescription(candidate: string | undefined, fallbackLead: stri
return joined.slice(0, 500);
}
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
// AI-powered content analysis
async function analyzeContentWithAI(
title: string,
description: string,
contentType: string
) {
try {
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions/popular`);
if (!response.ok) return [];
const result = await response.json();
if (!Array.isArray(result.data)) return [];
return result.data
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
description: typeof dimension.description === 'string' ? dimension.description.trim() : null
}))
.filter((dimension: ExistingDimension) => dimension.name.length > 0);
} catch (error) {
console.warn('YouTube dimension fetch fallback (no dimension context):', error);
return [];
}
}
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of selected) {
if (typeof value !== 'string') continue;
const matched = byLowerName.get(value.trim().toLowerCase());
if (!matched) continue;
const key = matched.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(matched);
if (normalized.length >= max) break;
}
return normalized;
}
async function analyzeContentWithAI(title: string, description: string, contentType: string, existingDimensions: ExistingDimension[]) {
try {
const availableDimensionsBlock = existingDimensions.length > 0
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
: '- No existing dimensions available. Return an empty dimensions array.';
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
@@ -83,24 +38,25 @@ CRITICAL — nodeDescription rules (max 500 chars):
1. Write natural prose, not labels or a checklist.
2. Make clear what this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
3. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
4. State the actual claim or thesis from the title.
4. State the actual claim or thesis from the title don't paraphrase into vague abstractions.
5. Make clear why it belongs in the graph. If that cannot be inferred, say so naturally.
6. Make the workflow status clear. If unknown, say naturally that it has not been reviewed yet.
7. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to".
7. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to". State things directly.
DIMENSION SELECTION:
You must select 0-3 dimensions from the list below.
Do NOT invent new dimension names.
Examples:
- Title: "Dario Amodei — We are near the end of the exponential" / Channel: Dwarkesh Patel
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective."
Available dimensions:
${availableDimensionsBlock}
- Title: "The spell of language models" / Channel: Andrej Karpathy
GOOD: "Karpathy talk on how LLMs work under the hood — tokenization, attention, and why they feel like magic but aren't. It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
BAD: "By Andrej Karpathy — explores the nature of language models and their capabilities."
Respond with ONLY valid JSON:
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars).",
"nodeDescription": "<your natural description>",
"dimensions": ["existing-dimension-1"],
"reasoning": "Brief explanation"
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your natural description following the rules above>",
"reasoning": "Brief explanation of classification choices"
}`;
const response = await generateText({
@@ -109,21 +65,24 @@ Respond with ONLY valid JSON:
maxOutputTokens: 800
});
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
console.warn('YouTube analysis fallback (using default description):', error);
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('YouTube analysis fallback (using default description):', message);
return {
enhancedDescription: description,
nodeDescription: undefined,
dimensions: [],
reasoning: 'Fallback description used'
};
}
@@ -134,21 +93,29 @@ async function summariseTranscript(title: string, transcript: string): Promise<s
return null;
}
const excerpt = transcript.trim().length > 16000
? `${transcript.trim().slice(0, 8000)}\n[...]\n${transcript.trim().slice(-8000)}`
: transcript.trim();
// Limit transcript length to keep token costs manageable
const MAX_CHARS = 16000;
let excerpt = transcript.trim();
if (excerpt.length > MAX_CHARS) {
const head = excerpt.slice(0, MAX_CHARS / 2);
const tail = excerpt.slice(-MAX_CHARS / 2);
excerpt = `${head}\n[...]\n${tail}`;
}
try {
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt: `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
const prompt = `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. Keep the tone factual.
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. If specific terms, frameworks, or memorable lines appear, mention them. Keep the tone factual (no marketing language). If the excerpt appears truncated, note that the summary is based on the portion provided.
Transcript excerpt:
"""
${excerpt}
"""`,
"""
`;
try {
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt,
maxOutputTokens: 400
});
return response.text?.trim() || null;
@@ -162,18 +129,23 @@ export const youtubeExtractTool = tool({
description: 'Extract a YouTube transcript and metadata, create a node, and return summary details',
inputSchema: z.object({
url: z.string().describe('The YouTube video URL to add to knowledge base'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
title: z.string().optional().describe('Custom title (auto-generated if not provided)')
}),
execute: async ({ url, title, dimensions }) => {
execute: async ({ url, title }) => {
console.log('🎯 YouTubeExtract tool called with URL:', url);
try {
// Validate YouTube URL
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
return { success: false, error: 'Invalid YouTube URL format', data: null };
return {
success: false,
error: 'Invalid YouTube URL format',
data: null
};
}
let result: { success: boolean; source?: string; metadata?: any; error?: string };
console.log('📝 Using TypeScript yt-dlp extractor');
try {
const extractionResult = await extractYouTube(url);
result = {
@@ -193,46 +165,48 @@ export const youtubeExtractTool = tool({
error: extractionResult.error
};
} catch (error: any) {
result = { success: false, error: error.message || 'TypeScript extraction failed' };
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
}
if (!result.success || !result.source) {
return { success: false, error: result.error || 'Failed to extract YouTube content', data: null };
return {
success: false,
error: result.error || 'Failed to extract YouTube content',
data: null
};
}
const existingDimensions = await fetchExistingDimensions();
console.log('🎯 YouTube extraction successful, analyzing with AI...');
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.video_title || 'YouTube Video',
`Video by ${result.metadata?.channel_name || 'Unknown Channel'}`,
'youtube',
existingDimensions
'youtube'
);
// Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
const finalDimensions = suppliedDimensions.slice(0, 5).length > 0
? suppliedDimensions.slice(0, 5)
: (aiAnalysis?.dimensions || []).slice(0, 5);
const nodeDescription = ensureNodeDescription(
aiAnalysis?.nodeDescription,
transcriptSummary || `YouTube video by ${result.metadata?.channel_name || 'an unknown creator'} about ${nodeTitle}`
);
const fallbackDescriptionLead = `YouTube video from ${result.metadata?.channel_name || 'an unknown channel'} titled "${nodeTitle}"`;
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
description: nodeDescription,
description: finalDescription,
source: result.source,
link: url,
dimensions: finalDimensions,
metadata: {
type: 'youtube',
state: 'not_processed',
captured_method: 'youtube_extract',
captured_by: 'agent',
captured_by: 'human',
source_metadata: {
video_id: result.metadata?.video_id,
channel_name: result.metadata?.channel_name,
@@ -242,33 +216,38 @@ export const youtubeExtractTool = tool({
total_segments: result.metadata?.total_segments,
language: result.metadata?.language,
extraction_method: result.metadata?.extraction_method,
ai_analysis: aiAnalysis?.reasoning,
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
refined_at: new Date().toISOString()
transcript_summary: transcriptSummary,
refined_at: new Date().toISOString(),
}
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return { success: false, error: createResult.error || 'Failed to create item', data: null };
return {
success: false,
error: createResult.error || 'Failed to create item',
data: null
};
}
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
console.log('🎯 YouTubeExtract completed successfully');
const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle })
: nodeTitle;
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
message: `Added ${formattedNode}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: result.source.length,
url,
dimensions: actualDimensions
url: url
}
};
} catch (error) {
-12
View File
@@ -36,7 +36,6 @@ export interface Node {
notes?: string; // Deprecated legacy field - do not write
link?: string;
event_date?: string | null; // When the thing actually happened (ISO 8601)
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
embedding?: Buffer; // Node-level embedding (BLOB data)
chunk?: string; // Deprecated legacy field - do not write
metadata?: CanonicalNodeMetadata | null; // Flexible metadata storage with canonical contract
@@ -97,7 +96,6 @@ export interface EdgeContext {
// New NodeFilters interface replacing rigid ItemFilters
export interface NodeFilters {
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
contextId?: number;
search?: string; // Text search in title/content
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
@@ -105,7 +103,6 @@ export interface NodeFilters {
limit?: number;
offset?: number;
sortBy?: 'updated' | 'edges' | 'created' | 'event_date'; // Sort by updated_at, edge count, created_at, or event_date
dimensionsMatch?: 'any' | 'all'; // 'any' = OR (default), 'all' = AND
createdAfter?: string; // ISO date (YYYY-MM-DD) — nodes created on or after
createdBefore?: string; // ISO date (YYYY-MM-DD) — nodes created before
eventAfter?: string; // ISO date (YYYY-MM-DD) — nodes with event_date on or after
@@ -151,12 +148,3 @@ export interface DatabaseError {
code?: string;
details?: any;
}
// Dimension interface for dimension management
export interface Dimension {
name: string;
description?: string | null;
icon?: string | null;
is_priority: boolean;
updated_at: string;
}
+15
View File
@@ -0,0 +1,15 @@
export interface SkillMeta {
name: string;
description: string;
immutable: boolean;
}
export interface Skill extends SkillMeta {
content: string;
}
export interface FocusedSkill {
name: string;
description: string;
content: string;
}
+2 -11
View File
@@ -1,9 +1,9 @@
// View system types
export type ViewType = 'focus' | 'list' | 'kanban' | 'grid';
export type ViewType = 'focus' | 'list' | 'grid' | 'table' | 'map';
export interface ViewFilter {
dimension: string;
context: string;
operator: 'includes' | 'excludes';
}
@@ -12,18 +12,10 @@ export interface ViewSort {
direction: 'asc' | 'desc';
}
export interface KanbanColumn {
id: string;
dimension: string;
order: number;
}
export interface ViewConfig {
filters: ViewFilter[];
filterLogic: 'and' | 'or';
sort: ViewSort;
// Kanban-specific
columns?: KanbanColumn[];
}
export interface SavedView {
@@ -41,5 +33,4 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
filters: [],
filterLogic: 'and',
sort: { field: 'updated_at', direction: 'desc' },
columns: []
};
+6 -35
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from 'react';
import { Video, FileText, File, Globe, Folder } from 'lucide-react';
import { Video, FileText, File, Globe } from 'lucide-react';
import { Node } from '@/types/database';
import { getIconByName } from '@/components/common/LucideIconPicker';
import { normalizeNodeLink } from '@/utils/nodeLink';
interface FaviconIconProps {
domain: string;
@@ -35,21 +35,19 @@ const FaviconIcon = ({ domain, size = 16 }: FaviconIconProps) => {
*
* Priority:
* 1. URL-derived icon (favicon, YouTube, PDF) if node has a link
* 2. Dimension-derived icon from the node's most popular dimension that has an icon set
* 3. Fallback to generic File icon
* 2. Fallback to generic File icon
*
* @param node - The database node
* @param dimensionIcons - Map of dimension name Lucide icon name (from DimensionIconsContext)
* @param size - Icon size in px (default 16)
*/
export function getNodeIcon(
node: Node,
dimensionIcons?: Record<string, string>,
size: number = 16,
): React.ReactElement {
// If node has a link, use URL-derived icon (primary)
if (node.link) {
const url = node.link.toLowerCase();
const normalizedLink = normalizeNodeLink(node.link);
const url = (normalizedLink || node.link).toLowerCase();
// YouTube videos
if (url.includes('youtube.com') || url.includes('youtu.be')) {
@@ -63,40 +61,13 @@ export function getNodeIcon(
// Website favicon with graceful fallback
try {
const domain = new URL(node.link).hostname;
const domain = new URL(normalizedLink || node.link).hostname;
return <FaviconIcon domain={domain} size={size} />;
} catch {
return <Globe size={size} color="#94a3b8" />;
}
}
// No link — try dimension-derived icon
if (dimensionIcons && node.dimensions?.length) {
// Find the first dimension that has an icon set
// (dimensions are already ordered, so first match wins)
for (const dim of node.dimensions) {
const iconName = dimensionIcons[dim];
if (iconName && iconName !== 'Folder') {
const IconComponent = getIconByName(iconName);
return <IconComponent size={size} color="#94a3b8" />;
}
}
}
// Fallback
return <File size={size} color="#94a3b8" />;
}
/**
* Get the dimension icon for a given dimension name.
* Returns Folder if no icon is set.
*/
export function getDimensionIcon(
dimensionName: string,
dimensionIcons: Record<string, string>,
size: number = 16,
): React.ReactElement {
const iconName = dimensionIcons[dimensionName] || 'Folder';
const IconComponent = getIconByName(iconName);
return <IconComponent size={size} color="#94a3b8" />;
}