feat: port holistic node refinement contract
This commit is contained in:
+135
-133
@@ -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 "{searchQuery.trim()}"</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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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' }}>✓</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' }}>✓</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 · 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)',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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: []
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user