sync: multiple features from private repo

- quick-add-loading: fire-and-forget with loading placeholders, auto-open feed, SSE events, QuickAddInput redesign
- database-table-pane: TablePane + DatabaseTableView, countNodes(), event_date sort
- ui-polish-fixes: focus tab order, dim name editing, tab title sync, cost chip removal, custom sort drag-reorder, refresh button, dimension filter removal (~2,200 lines)
- node-creation-quality: description service prompt rewrite, title sanitization, extraction tool AI prompt rewrites
- feed-pane-ux: stripped kanban/grid/saved views, sort dropdown, AND dimension filtering, description preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-15 17:57:32 +11:00
co-authored by Claude Opus 4.6
parent 9954792b1d
commit 2f6518207d
25 changed files with 1590 additions and 2647 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
``` ```
A local SQLite database with a UI for storing knowledge, and an MCP server so your AI tools can read/write to it. **TL;DR:** Clone this repository and you'll have a local SQLite database on your computer. The database schema is structured so external AI agents can continuously read and write to it, building your knowledge graph externally.
**Full documentation:** [ra-h.app/docs/open-source](https://ra-h.app/docs/open-source) **Full documentation:** [ra-h.app/docs/open-source](https://ra-h.app/docs/open-source)
+52 -17
View File
@@ -33,7 +33,7 @@ export async function GET(request: NextRequest) {
// Handle sortBy parameter (sortBy=edges|updated|created) // Handle sortBy parameter (sortBy=edges|updated|created)
const sortByParam = searchParams.get('sortBy'); const sortByParam = searchParams.get('sortBy');
if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created') { if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') {
filters.sortBy = sortByParam; filters.sortBy = sortByParam;
} }
@@ -46,11 +46,13 @@ export async function GET(request: NextRequest) {
} }
const nodes = await nodeService.getNodes(filters); const nodes = await nodeService.getNodes(filters);
const total = await nodeService.countNodes(filters);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: nodes, data: nodes,
count: nodes.length count: nodes.length,
total
}); });
} catch (error) { } catch (error) {
console.error('Error fetching nodes:', error); console.error('Error fetching nodes:', error);
@@ -62,10 +64,24 @@ export async function GET(request: NextRequest) {
} }
} }
// Weak-pattern regex for post-creation quality monitoring
const WEAK_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into|This is a)\b/i;
function sanitizeTitle(title: string): string {
let clean = title.trim();
// Strip "Title: " prefix (extraction artifact)
if (clean.startsWith('Title: ')) clean = clean.slice(7);
// Strip trailing " / X" (Twitter artifact)
if (clean.endsWith(' / X')) clean = clean.slice(0, -4);
// Collapse whitespace
clean = clean.replace(/\s+/g, ' ');
return clean.slice(0, 160);
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
// Validate required fields // Validate required fields
if (!body.title) { if (!body.title) {
return NextResponse.json({ return NextResponse.json({
@@ -74,7 +90,11 @@ export async function POST(request: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
// Sanitize title (strip extraction artifacts)
body.title = sanitizeTitle(body.title);
const rawNotes = typeof body.notes === 'string' ? body.notes : null; const rawNotes = typeof body.notes === 'string' ? body.notes : null;
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
// Process provided dimensions first (needed for description generation) // Process provided dimensions first (needed for description generation)
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : []; const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
@@ -83,19 +103,33 @@ export async function POST(request: NextRequest) {
.filter(Boolean) .filter(Boolean)
.slice(0, 8); .slice(0, 8);
// Generate description with all available context // Use provided description if present, otherwise auto-generate
let nodeDescription: string | undefined; let nodeDescription: string | undefined = typeof body.description === 'string' && body.description.trim()
try { ? body.description.trim().slice(0, 280)
nodeDescription = await generateDescription({ : undefined;
title: body.title,
notes: rawNotes || undefined, if (!nodeDescription) {
link: body.link || undefined, try {
metadata: body.metadata, nodeDescription = await generateDescription({
dimensions: trimmedProvidedDimensions title: body.title,
}); notes: rawNotes || undefined,
} catch (error) { link: body.link || undefined,
console.error('Error generating description:', error); metadata: body.metadata,
// Continue without description - dimension assignment will use content as fallback dimensions: trimmedProvidedDimensions
});
} catch (error) {
console.error('Error generating description:', error);
}
}
// Final safety net — never store null/empty description
if (!nodeDescription || nodeDescription.trim().length === 0) {
nodeDescription = body.title.slice(0, 280);
}
// Monitor description quality
if (nodeDescription && WEAK_PATTERNS.test(nodeDescription)) {
console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${nodeDescription}"`);
} }
// Auto-assign locked dimensions + keyword dimensions for all new nodes // Auto-assign locked dimensions + keyword dimensions for all new nodes
@@ -129,6 +163,7 @@ export async function POST(request: NextRequest) {
title: body.title, title: body.title,
description: nodeDescription, description: nodeDescription,
notes: rawNotes ?? undefined, notes: rawNotes ?? undefined,
event_date: eventDate ?? undefined,
link: body.link, link: body.link,
dimensions: finalDimensions, dimensions: finalDimensions,
chunk: chunkToStore ?? undefined, chunk: chunkToStore ?? undefined,
+208 -259
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
interface QuickAddSubmitPayload { interface QuickAddSubmitPayload {
@@ -11,11 +11,40 @@ interface QuickAddSubmitPayload {
interface QuickAddInputProps { interface QuickAddInputProps {
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>; onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
// External control (optional - if provided, component is controlled)
isOpen?: boolean; isOpen?: boolean;
onClose?: () => void; onClose?: () => void;
} }
type DetectedType = 'youtube' | 'website' | 'pdf-url' | 'note' | 'chat';
function detectType(raw: string): DetectedType {
const trimmed = raw.trim();
if (!trimmed) return 'note';
if (/youtu(\.be|be\.com)/i.test(trimmed)) return 'youtube';
if (/\.pdf($|\?)/i.test(trimmed) || /arxiv\.org\//i.test(trimmed)) return 'pdf-url';
if (/^https?:\/\//i.test(trimmed)) return 'website';
const newlines = (trimmed.match(/\n/g)?.length ?? 0);
if (newlines >= 3 && trimmed.length > 300) return 'chat';
if (/You said:|ChatGPT said:|Claude said:/i.test(trimmed)) return 'chat';
return 'note';
}
const TYPE_LABELS: Record<DetectedType, string> = {
youtube: 'YouTube',
website: 'Link',
'pdf-url': 'PDF',
note: 'Note',
chat: 'Transcript',
};
const TYPE_COLORS: Record<DetectedType, string> = {
youtube: '#ef4444',
website: '#3b82f6',
'pdf-url': '#f59e0b',
note: '#22c55e',
chat: '#a78bfa',
};
export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) { export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isPosting, setIsPosting] = useState(false); const [isPosting, setIsPosting] = useState(false);
@@ -24,42 +53,32 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
const [uploadedFile, setUploadedFile] = useState<File | null>(null); const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
// Support both controlled (isOpen/onClose) and uncontrolled (internal state) modes
const isControlled = isOpen !== undefined; const isControlled = isOpen !== undefined;
const isExpanded = isControlled ? isOpen : isExpandedInternal; const isExpanded = isControlled ? isOpen : isExpandedInternal;
const setIsExpanded = isControlled const setIsExpanded = isControlled
? (value: boolean) => { if (!value && onClose) onClose(); } ? (value: boolean) => { if (!value && onClose) onClose(); }
: setIsExpandedInternal; : setIsExpandedInternal;
const detectedType = useMemo(() => detectType(input), [input]);
const showTypePill = input.trim().length > 0 && !uploadedFile;
const handleFileUpload = useCallback(async (file: File) => { const handleFileUpload = useCallback(async (file: File) => {
setIsPosting(true); setIsPosting(true);
setUploadError(null); setUploadError(null);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch('/api/extract/pdf/upload', { const response = await fetch('/api/extract/pdf/upload', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.success) { if (!response.ok || !result.success) {
throw new Error(result.error || 'Upload failed'); throw new Error(result.error || 'Upload failed');
} }
// Success - clear state
setUploadedFile(null); setUploadedFile(null);
setInput(''); setInput('');
setIsExpanded(false); setIsExpanded(false);
// Show warning if present (large file)
if (result.warning) {
console.log('[QuickAddInput] Upload warning:', result.warning);
}
} catch (error) { } catch (error) {
console.error('[QuickAddInput] Upload error:', error); console.error('[QuickAddInput] Upload error:', error);
setUploadError(error instanceof Error ? error.message : 'Upload failed'); setUploadError(error instanceof Error ? error.message : 'Upload failed');
@@ -69,22 +88,14 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
// If there's a file, upload it
if (uploadedFile) { if (uploadedFile) {
await handleFileUpload(uploadedFile); await handleFileUpload(uploadedFile);
return; return;
} }
// Otherwise, submit text as before
if (!input.trim() || isPosting) return; if (!input.trim() || isPosting) return;
setIsPosting(true); setIsPosting(true);
try { try {
// Mode is auto-detected server-side via quickAdd.ts detectInputType() await onSubmit({ input: input.trim(), mode: 'link' });
await onSubmit({
input: input.trim(),
mode: 'link', // Default; actual type is inferred server-side
});
setInput(''); setInput('');
setIsExpanded(false); setIsExpanded(false);
} catch (error) { } catch (error) {
@@ -124,30 +135,18 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
e.stopPropagation(); e.stopPropagation();
setDragOver(false); setDragOver(false);
setUploadError(null); setUploadError(null);
const file = e.dataTransfer?.files[0]; const file = e.dataTransfer?.files[0];
if (!file) return; if (!file) return;
// Check if it's a PDF
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setUploadError('Only PDF files are supported'); setUploadError('Only PDF files are supported');
return; return;
} }
if (file.size > 50 * 1024 * 1024) {
// Check size (50MB limit) setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Max 50MB.`);
const MAX_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_SIZE) {
setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 50MB.`);
return; return;
} }
setUploadedFile(file); setUploadedFile(file);
setInput(''); // Clear text input when file is dropped setInput('');
}, []);
const clearFile = useCallback(() => {
setUploadedFile(null);
setUploadError(null);
}, []); }, []);
const hasContent = input.trim() || uploadedFile; const hasContent = input.trim() || uploadedFile;
@@ -159,12 +158,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
setUploadError(null); setUploadError(null);
}; };
// Collapsed state - only show button if NOT controlled externally
if (!isExpanded) { if (!isExpanded) {
// In controlled mode, don't render anything when closed
if (isControlled) return null; if (isControlled) return null;
// Uncontrolled mode - show the "ADD STUFF" button
return ( return (
<button <button
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
@@ -190,32 +185,23 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)'; e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)';
e.currentTarget.style.borderColor = '#22c55e'; e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.25)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)'; e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)'; e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)';
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.15)';
}} }}
> >
<span style={{ <span style={{
width: '18px', width: '18px', height: '18px', borderRadius: '50%',
height: '18px', background: '#22c55e', color: '#0a0a0a',
borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#22c55e', fontSize: '12px', fontWeight: 700
color: '#0a0a0a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700
}}>+</span> }}>+</span>
Add Stuff Add Stuff
</button> </button>
); );
} }
// Expanded state - the card content
const modalContent = ( const modalContent = (
<div <div
className="qa-card" className="qa-card"
@@ -224,117 +210,76 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
onDrop={handleDrop} onDrop={handleDrop}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* File preview */}
<div className="qa-header">
<span className="qa-title">Add Stuff</span>
<button onClick={handleClose} className="qa-close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{/* File preview (when a file is dropped) */}
{uploadedFile && ( {uploadedFile && (
<div className="qa-file-preview"> <div className="qa-file-row">
{/* PDF icon */} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
<div className="qa-file-icon"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5"> <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/> </svg>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/> <span className="qa-file-name">{uploadedFile.name}</span>
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round"/> <span className="qa-file-size">
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round"/> {uploadedFile.size < 1024 * 1024
</svg> ? `${Math.round(uploadedFile.size / 1024)} KB`
</div> : `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
</span>
{/* File info */} <button onClick={() => { setUploadedFile(null); setUploadError(null); }} className="qa-file-remove">
<div style={{ flex: 1, minWidth: 0 }}> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div style={{
color: '#e5e5e5',
fontSize: '13px',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{uploadedFile.name}
</div>
<div style={{ color: '#666', fontSize: '11px', marginTop: '2px' }}>
{uploadedFile.size < 1024 * 1024
? `${Math.round(uploadedFile.size / 1024)} KB`
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
</div>
</div>
{/* Remove button */}
<button onClick={clearFile} className="qa-close" style={{ color: '#666' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" /> <path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</button> </button>
</div> </div>
)} )}
{/* Error message */} {/* Error */}
{uploadError && ( {uploadError && (
<div style={{ <div className="qa-error">{uploadError}</div>
padding: '10px 12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '10px',
color: '#ef4444',
fontSize: '12px',
}}>
{uploadError}
</div>
)} )}
{/* Input area - show if no file uploaded */} {/* Input area */}
{!uploadedFile && ( {!uploadedFile && (
<div className={`qa-input-wrapper ${dragOver ? 'dragging' : ''}`}> <div className={`qa-input-area ${dragOver ? 'dragging' : ''}`}>
{/* Drag overlay */}
{dragOver && ( {dragOver && (
<div className="qa-drag-overlay"> <div className="qa-drag-overlay">
<div style={{ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="1.5">
display: 'flex', <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
flexDirection: 'column', <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
alignItems: 'center', </svg>
gap: '8px', <span>Drop PDF</span>
color: '#22c55e'
}}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontSize: '13px', fontWeight: 500 }}>Drop PDF here</span>
</div>
</div> </div>
)} )}
<textarea <textarea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Paste a URL, note, or transcript — or drop a PDF" placeholder="Paste a URL, write a note, or drop a PDF..."
disabled={isPosting} disabled={isPosting}
autoFocus autoFocus
className="qa-textarea" className="qa-textarea"
style={{ opacity: dragOver ? 0.3 : 1 }} style={{ opacity: dragOver ? 0.2 : 1 }}
/> />
</div> </div>
)} )}
{/* Footer */} {/* Footer */}
<div className="qa-footer"> <div className="qa-footer">
<span className="qa-hint"> <div className="qa-footer-left">
{uploadedFile {showTypePill && (
? 'Ready to upload' <span className="qa-type-pill" style={{
: <><kbd>{'\u2318\u21B5'}</kbd> submit <span className="qa-hint-sep">&middot;</span> <kbd>esc</kbd> close</> color: TYPE_COLORS[detectedType],
} borderColor: TYPE_COLORS[detectedType] + '30',
</span> background: TYPE_COLORS[detectedType] + '0a',
}}>
{TYPE_LABELS[detectedType]}
</span>
)}
<span className="qa-hint">
{uploadedFile
? 'Ready to upload'
: <><kbd>{'\u2318\u21B5'}</kbd><span className="qa-hint-sep">send</span><kbd>esc</kbd><span className="qa-hint-sep">close</span></>
}
</span>
</div>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!hasContent || isPosting} disabled={!hasContent || isPosting}
@@ -344,8 +289,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
<span className="qa-spinner" /> <span className="qa-spinner" />
) : ( ) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5"/> <path d="M5 12h14"/>
<path d="M5 12l7-7 7 7"/> <path d="M12 5l7 7-7 7"/>
</svg> </svg>
)} )}
</button> </button>
@@ -355,86 +300,27 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
.qa-card { .qa-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 0;
background: #141414; background: #111111;
padding: 24px;
border-radius: 16px; border-radius: 16px;
border: 1px solid #262626; border: 1px solid rgba(255, 255, 255, 0.06);
transition: border-color 0.15s ease; overflow: hidden;
box-shadow: box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04), 0 0 0 1px rgba(255, 255, 255, 0.03),
0 24px 48px -12px rgba(0, 0, 0, 0.6); 0 24px 80px -12px rgba(0, 0, 0, 0.8),
animation: qaCardIn 200ms cubic-bezier(0.16, 1, 0.3, 1); 0 0 60px -10px rgba(34, 197, 94, 0.06);
width: ${isControlled ? '540px' : 'auto'}; animation: qaIn 250ms cubic-bezier(0.16, 1, 0.3, 1);
width: ${isControlled ? '600px' : 'auto'};
max-width: ${isControlled ? '90vw' : 'none'}; max-width: ${isControlled ? '90vw' : 'none'};
} }
.qa-header { .qa-input-area {
display: flex;
align-items: center;
justify-content: space-between;
}
.qa-title {
color: #fafafa;
font-size: 15px;
font-weight: 600;
}
.qa-close {
padding: 6px;
background: transparent;
border: none;
color: #525252;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
}
.qa-close:hover {
color: #a3a3a3;
}
.qa-file-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #0f1a0f;
border: 1px solid #1a3a1a;
border-radius: 12px;
}
.qa-file-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.qa-input-wrapper {
position: relative; position: relative;
border-radius: 12px; transition: all 0.2s ease;
border: 1px solid #262626;
background: #0a0a0a;
transition: all 0.15s ease;
} }
.qa-input-wrapper:focus-within { .qa-input-area.dragging {
border-color: #333; background: rgba(34, 197, 94, 0.03);
}
.qa-input-wrapper.dragging {
border: 2px dashed #22c55e;
background: rgba(34, 197, 94, 0.05);
} }
.qa-drag-overlay { .qa-drag-overlay {
@@ -443,8 +329,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(34, 197, 94, 0.05); gap: 8px;
border-radius: 12px; color: #22c55e;
font-size: 13px;
font-weight: 500;
z-index: 10; z-index: 10;
pointer-events: none; pointer-events: none;
} }
@@ -453,10 +341,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
width: 100%; width: 100%;
min-height: 120px; min-height: 120px;
max-height: 300px; max-height: 300px;
padding: 16px 18px; padding: 20px 22px;
background: transparent; background: transparent;
border: none; border: none;
color: #fafafa; color: #e5e5e5;
font-size: 15px; font-size: 15px;
font-family: inherit; font-family: inherit;
outline: none; outline: none;
@@ -466,50 +354,119 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
} }
.qa-textarea::placeholder { .qa-textarea::placeholder {
color: #525252; color: #444;
}
.qa-file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.qa-file-name {
flex: 1;
min-width: 0;
color: #e5e5e5;
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qa-file-size {
color: #555;
font-size: 11px;
flex-shrink: 0;
}
.qa-file-remove {
padding: 4px;
background: transparent;
border: none;
color: #555;
cursor: pointer;
border-radius: 4px;
display: flex;
transition: color 0.15s ease;
}
.qa-file-remove:hover {
color: #ef4444;
}
.qa-error {
padding: 10px 20px;
color: #ef4444;
font-size: 12px;
background: rgba(239, 68, 68, 0.06);
border-bottom: 1px solid rgba(239, 68, 68, 0.1);
} }
.qa-footer { .qa-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 14px 12px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.qa-footer-left {
display: flex;
align-items: center;
gap: 10px;
}
.qa-type-pill {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid;
text-transform: uppercase;
animation: pillIn 150ms ease-out;
} }
.qa-hint { .qa-hint {
font-size: 11px; font-size: 11px;
color: #525252; color: #3a3a3a;
display: flex;
align-items: center;
gap: 4px;
} }
.qa-hint kbd { .qa-hint kbd {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; padding: 1px 5px;
padding: 2px 6px; background: rgba(255, 255, 255, 0.04);
background: #262626; border-radius: 3px;
border-radius: 4px;
font-size: 10px; font-size: 10px;
font-family: inherit; font-family: inherit;
color: #737373; color: #444;
border: 1px solid #333;
} }
.qa-hint-sep { .qa-hint-sep {
margin: 0 2px; margin: 0 1px;
color: #333;
} }
.qa-submit { .qa-submit {
width: 36px; width: 34px;
height: 36px; height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
background: #262626; background: rgba(255, 255, 255, 0.04);
border: none; border: none;
border-radius: 10px; border-radius: 10px;
color: #525252; color: #333;
cursor: default; cursor: default;
transition: all 0.2s ease; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
} }
.qa-submit.active { .qa-submit.active {
@@ -520,23 +477,23 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
.qa-submit.active:hover { .qa-submit.active:hover {
background: #16a34a; background: #16a34a;
transform: translateY(-1px); transform: scale(1.05);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
} }
.qa-spinner { .qa-spinner {
width: 14px; width: 14px;
height: 14px; height: 14px;
border: 2px solid #0a0a0a; border: 2px solid #052e16;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
animation: qaSpin 0.8s linear infinite; animation: qaSpin 0.8s linear infinite;
} }
@keyframes qaCardIn { @keyframes qaIn {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.96) translateY(-8px); transform: scale(0.97) translateY(-6px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -544,6 +501,11 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
} }
} }
@keyframes pillIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes qaSpin { @keyframes qaSpin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@@ -551,55 +513,42 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
</div> </div>
); );
// In controlled mode, wrap with blurred backdrop + portal
if (isControlled) { if (isControlled) {
const backdrop = ( const backdrop = (
<div <div className="qa-backdrop" onClick={handleClose}>
className="qa-backdrop"
onClick={handleClose}
>
<div className="qa-container"> <div className="qa-container">
{modalContent} {modalContent}
</div> </div>
<style jsx>{` <style jsx>{`
.qa-backdrop { .qa-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
padding-top: 15vh;
z-index: 9999; z-index: 9999;
animation: qaBackdropIn 200ms ease-out; animation: qaFadeIn 200ms ease-out;
} }
.qa-container { .qa-container {
width: 100%; width: 100%;
max-width: 540px; max-width: 600px;
height: fit-content;
} }
@keyframes qaFadeIn {
@keyframes qaBackdropIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
`}</style> `}</style>
</div> </div>
); );
return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null; return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null;
} }
// Uncontrolled mode - render with absolute positioning
return ( return (
<div style={{ <div style={{ position: 'absolute', top: '60px', left: '20px', right: '20px', zIndex: 100 }}>
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
zIndex: 100,
}}>
{modalContent} {modalContent}
</div> </div>
); );
+18 -18
View File
@@ -119,7 +119,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null); const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
// Content tab state: 'notes', 'desc', or 'source' // Content tab state: 'notes', 'desc', or 'source'
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('notes'); const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc');
// Desc (description) edit mode state // Desc (description) edit mode state
const [descEditMode, setDescEditMode] = useState(false); const [descEditMode, setDescEditMode] = useState(false);
@@ -2106,23 +2106,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
marginBottom: '12px', marginBottom: '12px',
borderBottom: '1px solid #1a1a1a' borderBottom: '1px solid #1a1a1a'
}}> }}>
<button
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'notes' ? 600 : 400,
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Notes
</button>
<button <button
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }} onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }}
style={{ style={{
@@ -2140,6 +2123,23 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
> >
Desc Desc
</button> </button>
<button
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'notes' ? 600 : 400,
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Notes
</button>
<button <button
onClick={() => { setActiveContentTab('edges'); setDescEditMode(false); setNotesEditMode(false); setSourceEditMode(false); }} onClick={() => { setActiveContentTab('edges'); setDescEditMode(false); setNotesEditMode(false); setSourceEditMode(false); }}
style={{ style={{
+13 -1
View File
@@ -4,9 +4,11 @@ import { useState, useCallback } from 'react';
import { import {
Search, Search,
Plus, Plus,
RefreshCw,
LayoutList, LayoutList,
Map, Map,
Folder, Folder,
Table2,
Settings, Settings,
} from 'lucide-react'; } from 'lucide-react';
import type { PaneType } from '../panes/types'; import type { PaneType } from '../panes/types';
@@ -14,6 +16,7 @@ import type { PaneType } from '../panes/types';
interface LeftToolbarProps { interface LeftToolbarProps {
onSearchClick: () => void; onSearchClick: () => void;
onAddStuffClick: () => void; onAddStuffClick: () => void;
onRefreshClick: () => void;
onSettingsClick: () => void; onSettingsClick: () => void;
onPaneTypeClick: (paneType: PaneType) => void; onPaneTypeClick: (paneType: PaneType) => void;
activePane: 'A' | 'B'; activePane: 'A' | 'B';
@@ -26,16 +29,18 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
views: LayoutList, views: LayoutList,
map: Map, map: Map,
dimensions: Folder, dimensions: Folder,
table: Table2,
}; };
const PANE_TYPE_LABELS: Record<string, string> = { const PANE_TYPE_LABELS: Record<string, string> = {
views: 'Feed', views: 'Feed',
map: 'Map', map: 'Map',
dimensions: 'Dimensions', dimensions: 'Dimensions',
table: 'Table',
}; };
// Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings) // Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings)
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions']; const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table'];
interface ToolbarButtonProps { interface ToolbarButtonProps {
icon: typeof Search; icon: typeof Search;
@@ -140,6 +145,7 @@ function PaneTypeButton({ icon: Icon, label, paneType, isOpen, isActivePane, onC
export default function LeftToolbar({ export default function LeftToolbar({
onSearchClick, onSearchClick,
onAddStuffClick, onAddStuffClick,
onRefreshClick,
onSettingsClick, onSettingsClick,
onPaneTypeClick, onPaneTypeClick,
activePane, activePane,
@@ -180,6 +186,12 @@ export default function LeftToolbar({
label="Add Stuff" label="Add Stuff"
onClick={onAddStuffClick} onClick={onAddStuffClick}
/> />
<ToolbarButton
icon={RefreshCw}
label="Refresh"
shortcut="⌘⇧R"
onClick={onRefreshClick}
/>
</div> </div>
{/* Middle section - Pane Types */} {/* Middle section - Pane Types */}
+124 -26
View File
@@ -21,12 +21,21 @@ type AgentDelegation = {
updatedAt: string; updatedAt: string;
}; };
export interface PendingNode {
id: string;
input: string;
inputType: string;
submittedAt: number;
status: 'processing' | 'error';
error?: string;
}
// Layout components // Layout components
import LeftToolbar from './LeftToolbar'; import LeftToolbar from './LeftToolbar';
import SplitHandle from './SplitHandle'; import SplitHandle from './SplitHandle';
// Pane components (ChatPane removed in rah-light, GuidesPane moved to settings) // Pane components (ChatPane removed in rah-light, GuidesPane moved to settings)
import { NodePane, DimensionsPane, MapPane, ViewsPane } from '../panes'; import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane } from '../panes';
import QuickAddInput from '../agents/QuickAddInput'; import QuickAddInput from '../agents/QuickAddInput';
import type { PaneType, SlotState, PaneAction } from '../panes/types'; import type { PaneType, SlotState, PaneAction } from '../panes/types';
@@ -99,6 +108,9 @@ export default function ThreePanelLayout() {
selectedText: string; selectedText: string;
} | null>(null); } | null>(null);
// Pending quick-add nodes (loading placeholders)
const [pendingNodes, setPendingNodes] = useState<PendingNode[]>([]);
// Ref to get current openTabs value in SSE handler // Ref to get current openTabs value in SSE handler
const openTabsRef = useRef<number[]>([]); const openTabsRef = useRef<number[]>([]);
@@ -164,16 +176,23 @@ export default function ThreePanelLayout() {
} }
}; };
// Update tab data whenever openTabs changes (use string key to prevent infinite loops) // Update tab data whenever openTabs changes or focus panel refreshes (use string key to prevent infinite loops)
const openTabsKey = openTabs.join(','); const openTabsKey = openTabs.join(',');
useEffect(() => { useEffect(() => {
openTabsRef.current = openTabs; openTabsRef.current = openTabs;
fetchOpenTabsData(openTabs); fetchOpenTabsData(openTabs);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [openTabsKey]); }, [openTabsKey, focusPanelRefresh]);
// Delegations loading removed (delegation system removed in rah-light) // Delegations loading removed (delegation system removed in rah-light)
// Refresh all panes
const handleRefreshAll = useCallback(() => {
setNodesPanelRefresh(prev => prev + 1);
setFolderViewRefresh(prev => prev + 1);
setFocusPanelRefresh(prev => prev + 1);
}, []);
// Keyboard shortcut handler // Keyboard shortcut handler
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -192,6 +211,11 @@ export default function ThreePanelLayout() {
setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null }); setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null });
} }
} }
// Cmd+Shift+R - refresh all panes
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
e.preventDefault();
handleRefreshAll();
}
// Cmd+N - open Add Stuff modal // Cmd+N - open Add Stuff modal
if ((e.metaKey || e.ctrlKey) && e.key === 'n') { if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
// Don't prevent default - browser may want this for new window // Don't prevent default - browser may want this for new window
@@ -205,7 +229,7 @@ export default function ThreePanelLayout() {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [slotB, setSlotB]); }, [slotB, setSlotB, handleRefreshAll]);
// SSE connection for real-time updates // SSE connection for real-time updates
useEffect(() => { useEffect(() => {
@@ -274,6 +298,22 @@ export default function ThreePanelLayout() {
} }
break; break;
case 'QUICK_ADD_COMPLETED':
if (data.data?.quickAddId) {
setPendingNodes(prev => prev.filter(p => p.id !== data.data.quickAddId));
}
break;
case 'QUICK_ADD_FAILED':
if (data.data?.quickAddId) {
setPendingNodes(prev => prev.map(p =>
p.id === data.data.quickAddId
? { ...p, status: 'error' as const, error: data.data.error || 'Unknown error' }
: p
));
}
break;
case 'CONNECTION_ESTABLISHED': case 'CONNECTION_ESTABLISHED':
console.log('✅ SSE connection established'); console.log('✅ SSE connection established');
break; break;
@@ -301,6 +341,21 @@ export default function ThreePanelLayout() {
}; };
}, []); }, []);
// Auto-dismiss pending nodes after timeout
useEffect(() => {
if (pendingNodes.length === 0) return;
const interval = setInterval(() => {
const now = Date.now();
setPendingNodes(prev => prev.filter(p => {
const age = now - p.submittedAt;
if (p.status === 'processing' && age > 90_000) return false; // 90s timeout
if (p.status === 'error' && age > 120_000) return false; // 120s for errors
return true;
}));
}, 5000);
return () => clearInterval(interval);
}, [pendingNodes.length]);
// Node tab management // Node tab management
const handleNodeSelect = useCallback((nodeId: number, multiSelect: boolean) => { const handleNodeSelect = useCallback((nodeId: number, multiSelect: boolean) => {
// If slotA is not a node pane (or doesn't exist), switch it to node // If slotA is not a node pane (or doesn't exist), switch it to node
@@ -405,27 +460,6 @@ export default function ThreePanelLayout() {
setActivePane('A'); setActivePane('A');
}, [slotA, setSlotA]); }, [slotA, setSlotA]);
// Handle Quick Add submit (used by global Add Stuff modal)
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
try {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode, description })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to submit Quick Add');
}
// Close the modal on success
setShowAddStuff(false);
} catch (error) {
console.error('[ThreePanelLayout] Quick Add error:', error);
}
}, []);
const handleNodeDeleted = useCallback((nodeId: number) => { const handleNodeDeleted = useCallback((nodeId: number) => {
handleCloseTab(nodeId); handleCloseTab(nodeId);
}, [handleCloseTab]); }, [handleCloseTab]);
@@ -494,6 +528,51 @@ export default function ThreePanelLayout() {
} }
}, [activePane, slotA, slotB, setSlotA, setSlotB]); }, [activePane, slotA, slotB, setSlotA, setSlotB]);
// Ensure the Feed pane is visible (for quick-add loading placeholders)
const ensureFeedOpen = useCallback(() => {
if (slotA?.type === 'views') return;
if (slotB?.type === 'views') return;
handlePaneTypeClick('views');
}, [slotA, slotB, handlePaneTypeClick]);
// Handle Quick Add submit (used by global Add Stuff modal)
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
try {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode, description })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to submit Quick Add');
}
const result = await response.json();
const delegation = result.delegation;
// Add pending placeholder
if (delegation?.id && delegation?.inputType) {
setPendingNodes(prev => [{
id: delegation.id,
input: input.slice(0, 120),
inputType: delegation.inputType,
submittedAt: Date.now(),
status: 'processing' as const,
}, ...prev]);
}
// Ensure feed pane is visible
ensureFeedOpen();
// Close the modal on success
setShowAddStuff(false);
} catch (error) {
console.error('[ThreePanelLayout] Quick Add error:', error);
}
}, [ensureFeedOpen]);
// Handle closing a pane // Handle closing a pane
const handleCloseSlotA = useCallback(() => { const handleCloseSlotA = useCallback(() => {
if (slotB) { if (slotB) {
@@ -860,6 +939,24 @@ export default function ThreePanelLayout() {
}} }}
onNodeOpenInOtherPane={slot === 'A' ? handleNodeOpenInSlotB : handleNodeOpenInSlotA} onNodeOpenInOtherPane={slot === 'A' ? handleNodeOpenInSlotB : handleNodeOpenInSlotA}
refreshToken={nodesPanelRefresh} refreshToken={nodesPanelRefresh}
pendingNodes={pendingNodes}
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
/>
);
case 'table':
return (
<TablePane
slot={slot}
isActive={isActive}
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
onCollapse={onCollapse}
onSwapPanes={slotB ? handleSwapPanes : undefined}
onNodeClick={(nodeId) => {
handleNodeSelect(nodeId, false);
setActivePane(slot);
}}
refreshToken={nodesPanelRefresh}
/> />
); );
@@ -892,6 +989,7 @@ export default function ThreePanelLayout() {
activePane={activePane} activePane={activePane}
slotAType={slotA?.type ?? null} slotAType={slotA?.type ?? null}
slotBType={slotB?.type ?? null} slotBType={slotB?.type ?? null}
onRefreshClick={handleRefreshAll}
/> />
{/* Main content area */} {/* Main content area */}
@@ -931,7 +1029,7 @@ export default function ThreePanelLayout() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
// Center single pane (except map) // Center single pane (except map)
...((!slotB && slotA.type !== 'map') ? { ...((!slotB && slotA.type !== 'map' && slotA.type !== 'table') ? {
maxWidth: '900px', maxWidth: '900px',
margin: '0 auto', margin: '0 auto',
width: '100%', width: '100%',
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
"use client";
import PaneHeader from './PaneHeader';
import DatabaseTableView from '../views/DatabaseTableView';
import type { BasePaneProps } from './types';
export interface TablePaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
}
export default function TablePane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
onNodeClick,
refreshToken
}: TablePaneProps) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'transparent',
overflow: 'hidden'
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar} />
<div style={{ flex: 1, overflow: 'hidden' }}>
<DatabaseTableView
onNodeClick={onNodeClick}
refreshToken={refreshToken}
/>
</div>
</div>
);
}
+8 -1
View File
@@ -3,11 +3,14 @@
import PaneHeader from './PaneHeader'; import PaneHeader from './PaneHeader';
import ViewsOverlay from '../views/ViewsOverlay'; import ViewsOverlay from '../views/ViewsOverlay';
import type { BasePaneProps, PaneAction, PaneType } from './types'; import type { BasePaneProps, PaneAction, PaneType } from './types';
import type { PendingNode } from '../layout/ThreePanelLayout';
export interface ViewsPaneProps extends BasePaneProps { export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
} }
export default function ViewsPane({ export default function ViewsPane({
@@ -18,7 +21,9 @@ export default function ViewsPane({
onSwapPanes, onSwapPanes,
onNodeClick, onNodeClick,
onNodeOpenInOtherPane, onNodeOpenInOtherPane,
refreshToken refreshToken,
pendingNodes,
onDismissPending,
}: ViewsPaneProps) { }: ViewsPaneProps) {
const handleTypeChange = (type: PaneType) => { const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type }); onPaneAction?.({ type: 'switch-pane-type', paneType: type });
@@ -38,6 +43,8 @@ export default function ViewsPane({
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeOpenInOtherPane={onNodeOpenInOtherPane} onNodeOpenInOtherPane={onNodeOpenInOtherPane}
refreshToken={refreshToken} refreshToken={refreshToken}
pendingNodes={pendingNodes}
onDismissPending={onDismissPending}
/> />
</div> </div>
</div> </div>
+1
View File
@@ -2,5 +2,6 @@ export { default as NodePane } from './NodePane';
export { default as DimensionsPane } from './DimensionsPane'; export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane'; export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane'; export { default as ViewsPane } from './ViewsPane';
export { default as TablePane } from './TablePane';
export { default as PaneHeader } from './PaneHeader'; export { default as PaneHeader } from './PaneHeader';
export * from './types'; export * from './types';
+8 -1
View File
@@ -15,7 +15,7 @@ export type AgentDelegation = {
}; };
// The four pane types (chat removed in rah-light, guides moved to settings) // The four pane types (chat removed in rah-light, guides moved to settings)
export type PaneType = 'node' | 'dimensions' | 'map' | 'views'; export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table';
// State for each slot // State for each slot
export interface SlotState { export interface SlotState {
@@ -89,6 +89,12 @@ export interface ViewsPaneProps extends BasePaneProps {
refreshToken?: number; refreshToken?: number;
} }
// TablePane specific props
export interface TablePaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
}
// Pane header props // Pane header props
export interface PaneHeaderProps { export interface PaneHeaderProps {
slot?: 'A' | 'B'; slot?: 'A' | 'B';
@@ -104,6 +110,7 @@ export const PANE_LABELS: Record<PaneType, string> = {
dimensions: 'Dimensions', dimensions: 'Dimensions',
map: 'Map', map: 'Map',
views: 'Feed', views: 'Feed',
table: 'Table',
}; };
// Default slot states // Default slot states
-5
View File
@@ -97,11 +97,6 @@ export default function LogsRow({ log, isEven }: LogsRowProps) {
🧵 {metrics.thread.substring(0, 16)}… 🧵 {metrics.thread.substring(0, 16)}…
</span> </span>
)} )}
{metrics.cost_usd !== undefined && (
<span style={{ color: '#34d399' }}>
💰 ${metrics.cost_usd.toFixed(6)}
</span>
)}
{metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && ( {metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && (
<span> <span>
📊 {metrics.input_tokens}↓ {metrics.output_tokens}↑ 📊 {metrics.input_tokens}↓ {metrics.output_tokens}↑
+629
View File
@@ -0,0 +1,629 @@
"use client";
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import type { Node } from '@/types/database';
type SortOrder = 'updated' | 'edges' | 'created' | 'event_date';
const SORT_LABELS: Record<SortOrder, string> = {
updated: 'Recently Updated',
edges: 'Most Edges',
created: 'Creation Date',
event_date: 'Event Date',
};
const PAGE_SIZE = 50;
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
}
interface DatabaseTableViewProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '\u2014';
try {
return dateStr.slice(0, 10);
} catch {
return '\u2014';
}
}
export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: DatabaseTableViewProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
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);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE),
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();
if (data.success) {
setNodes(data.data || []);
setTotal(data.total ?? data.count ?? 0);
}
} catch (e) {
console.error('Error fetching nodes:', e);
} finally {
setLoading(false);
}
}, [page, sortOrder, activeSearch, selectedFilters]);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshToken]);
// Reset to page 1 when filters/sort/search change
const filtersKey = selectedFilters.join(',');
useEffect(() => {
setPage(1);
}, [sortOrder, activeSearch, filtersKey]);
// 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]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
setActiveSearch(searchQuery);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const startItem = (page - 1) * PAGE_SIZE + 1;
const endItem = Math.min(page * PAGE_SIZE, total);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent' }}>
{/* Compact toolbar */}
<div style={{
padding: '8px 12px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
{/* Search */}
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0' }}>
<div style={{
display: 'flex',
alignItems: 'center',
background: '#0d0d0d',
border: '1px solid #222',
borderRadius: '6px',
padding: '0 8px',
gap: '6px',
}}>
<Search size={12} style={{ color: '#555', flexShrink: 0 }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search nodes..."
style={{
background: 'transparent',
border: 'none',
color: '#f0f0f0',
fontSize: '12px',
padding: '5px 0',
outline: 'none',
width: '140px',
}}
/>
{activeSearch && (
<button
type="button"
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
style={{ background: 'transparent', border: 'none', color: '#555', cursor: 'pointer', padding: 0, display: 'flex' }}
>
<X size={11} />
</button>
)}
</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 #222', borderRadius: '5px',
color: '#888', 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: '#141414', border: '1px solid #222', 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: '#0d0d0d',
border: '1px solid transparent', borderRadius: '6px',
color: '#f0f0f0', fontSize: '12px', marginBottom: '4px', outline: 'none',
}}
/>
{filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: '#666', 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: '#ccc',
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: '#555', fontSize: '10px', background: '#1a1a1a', 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: '#666', fontSize: '11px', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
Clear
</button>
)}
</div>
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
onClick={() => setShowSortDropdown(!showSortDropdown)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent',
border: '1px solid #222', borderRadius: '5px',
color: '#888', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<ArrowUpDown size={11} />
{SORT_LABELS[sortOrder]}
<ChevronDown size={10} />
</button>
{showSortDropdown && (
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
background: '#141414', border: '1px solid #222', borderRadius: '10px',
padding: '4px', minWidth: '160px', zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => (
<button
key={key}
onClick={() => { setSortOrder(key); setShowSortDropdown(false); }}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
width: '100%', padding: '7px 10px',
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
border: 'none', borderRadius: '5px',
color: sortOrder === key ? '#f0f0f0' : '#999',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
>
{sortOrder === key && <span style={{ color: '#22c55e', fontSize: '12px' }}>✓</span>}
{SORT_LABELS[key]}
</button>
))}
</div>
)}
</div>
{/* Pagination */}
<div style={{
display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '11px', color: '#666', whiteSpace: 'nowrap',
}}>
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
style={{
background: 'transparent', border: '1px solid #222', borderRadius: '4px',
color: page <= 1 ? '#333' : '#888', cursor: page <= 1 ? 'default' : 'pointer',
padding: '2px 4px', display: 'flex',
}}
>
<ChevronLeft size={14} />
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={{
background: 'transparent', border: '1px solid #222', borderRadius: '4px',
color: page >= totalPages ? '#333' : '#888',
cursor: page >= totalPages ? 'default' : 'pointer',
padding: '2px 4px', display: 'flex',
}}
>
<ChevronRight size={14} />
</button>
</div>
</div>
{/* Table */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ padding: '40px', color: '#666', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
) : nodes.length === 0 ? (
<div style={{ padding: '40px', color: '#666', textAlign: 'center', fontSize: '13px' }}>
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
</div>
) : (
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<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: '180px' })}>LINK</th>
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
<th style={thStyle({ width: '90px' })}>EVENT</th>
<th style={thStyle({ width: '85px' })}>UPDATED</th>
<th style={thStyle({ width: '85px' })}>CREATED</th>
<th style={thStyle({ width: '160px' })}>METADATA</th>
<th style={thStyle({ width: '160px' })}>CHUNK</th>
<th style={thStyle({ width: '80px' })}>CHUNK STATUS</th>
<th style={thStyle({ width: '85px' })}>EMB UPDATED</th>
</tr>
</thead>
<tbody>
{nodes.map((node, i) => {
const metaStr = node.metadata
? (typeof node.metadata === 'string' ? node.metadata : JSON.stringify(node.metadata))
: '';
return (
<tr
key={node.id}
onClick={() => onNodeClick(node.id)}
onMouseEnter={() => setHoveredRow(node.id)}
onMouseLeave={() => setHoveredRow(null)}
style={{
height: '44px',
cursor: 'pointer',
background: hoveredRow === node.id
? '#141414'
: i % 2 === 0 ? '#080808' : '#0d0d0d',
transition: 'background 0.1s ease',
}}
>
{/* Title */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 400 }}>
{node.title || 'Untitled'}
</span>
</div>
</td>
{/* ID */}
<td style={tdStyle({ textAlign: 'right' })}>
<span style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
fontSize: '11px', color: '#555',
}}>
{node.id}
</span>
</td>
{/* Description */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '11px', color: '#888' }}>
{node.description || '\u2014'}
</span>
</div>
</td>
{/* Notes */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '11px', color: '#888' }}>
{node.notes ? node.notes.slice(0, 120) : '\u2014'}
</span>
</div>
</td>
{/* Link */}
<td style={tdStyle()}>
<div style={truncCell}>
{node.link ? (
<span style={{ fontSize: '11px', color: '#6a9fd8', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ExternalLink size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.link.replace(/^https?:\/\/(www\.)?/, '')}
</span>
</span>
) : (
<span style={{ fontSize: '11px', color: '#333' }}>{'\u2014'}</span>
)}
</div>
</td>
{/* Dimensions */}
<td style={tdStyle()}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
{node.dimensions && node.dimensions.length > 0 ? (
<>
{node.dimensions.slice(0, 3).map(d => (
<span key={d} style={{
fontSize: '9px', padding: '1px 5px',
background: '#111914', border: '1px solid #1f2f24',
color: '#bbf7d0', borderRadius: '3px',
whiteSpace: 'nowrap',
}}>
{d}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: '#555' }}>
+{node.dimensions.length - 3}
</span>
)}
</>
) : (
<span style={{ fontSize: '10px', color: '#333' }}>{'\u2014'}</span>
)}
</div>
</td>
{/* Edges */}
<td style={tdStyle({ textAlign: 'right' })}>
<span style={{ fontSize: '12px', color: node.edge_count ? '#888' : '#333' }}>
{node.edge_count ?? 0}
</span>
</td>
{/* Event Date */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: node.event_date ? '#888' : '#333' }}>
{formatDate(node.event_date)}
</span>
</td>
{/* Updated */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: '#666' }}>
{formatRelativeTime(node.updated_at)}
</span>
</td>
{/* Created */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: '#666' }}>
{formatRelativeTime(node.created_at)}
</span>
</td>
{/* Metadata */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '10px', color: '#555', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
{metaStr || '\u2014'}
</span>
</div>
</td>
{/* Chunk */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '10px', color: '#555' }}>
{node.chunk ? node.chunk.slice(0, 100) : '\u2014'}
</span>
</div>
</td>
{/* Chunk Status */}
<td style={tdStyle()}>
<span style={{
fontSize: '10px',
color: node.chunk_status === 'chunked' ? '#4a9' : node.chunk_status === 'error' ? '#e55' : '#555',
}}>
{node.chunk_status || '\u2014'}
</span>
</td>
{/* Embedding Updated */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: '#666' }}>
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}
function thStyle(extra: React.CSSProperties = {}): React.CSSProperties {
return {
position: 'sticky' as const,
top: 0,
background: '#0a0a0a',
padding: '8px 12px',
fontSize: '10px',
fontWeight: 500,
color: '#555',
textAlign: 'left',
letterSpacing: '0.05em',
whiteSpace: 'nowrap',
borderBottom: '1px solid rgba(255,255,255,0.06)',
zIndex: 1,
...extra,
};
}
function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties {
return {
padding: '0 12px',
verticalAlign: 'middle',
borderBottom: '1px solid #111',
overflow: 'hidden',
...extra,
};
}
const truncCell: React.CSSProperties = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
+231 -11
View File
@@ -1,18 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { Filter, ChevronDown, X, ArrowUpDown } from 'lucide-react'; import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext'; import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { usePersistentState } from '@/hooks/usePersistentState'; import { usePersistentState } from '@/hooks/usePersistentState';
import type { PendingNode } from '../layout/ThreePanelLayout';
type SortOrder = 'updated' | 'edges' | 'created'; type SortOrder = 'updated' | 'edges' | 'created' | 'custom';
const SORT_LABELS: Record<SortOrder, string> = { const SORT_LABELS: Record<SortOrder, string> = {
updated: 'Updated', updated: 'Updated',
edges: 'Edges', edges: 'Edges',
created: 'Created', created: 'Created',
custom: 'Custom',
}; };
interface ColumnFilter { interface ColumnFilter {
@@ -27,13 +29,112 @@ interface DimensionSummary {
description?: string | null; description?: string | null;
} }
const INPUT_TYPE_LABELS: Record<string, string> = {
youtube: 'Extracting YouTube video...',
website: 'Extracting webpage...',
pdf: 'Extracting PDF...',
note: 'Creating note...',
chat: 'Importing transcript...',
};
function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) {
const isError = pending.status === 'error';
return (
<div style={{
padding: '10px 12px',
background: 'transparent',
borderBottom: '1px solid #141414',
borderLeft: `3px solid ${isError ? '#ef4444' : '#22c55e'}`,
opacity: 0.8,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: '#141414',
border: `1px solid ${isError ? 'rgba(239,68,68,0.3)' : '#1f1f1f'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
{isError ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span className="pending-spinner" />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '13px',
fontWeight: 500,
color: isError ? '#ef4444' : '#888',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{isError ? 'Failed' : (INPUT_TYPE_LABELS[pending.inputType] || 'Processing...')}
</div>
<div style={{
fontSize: '11px',
color: '#555',
marginTop: '2px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{isError && pending.error ? pending.error : pending.input}
</div>
</div>
{isError && onDismiss && (
<button
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
style={{
padding: '4px',
background: 'transparent',
border: 'none',
color: '#555',
cursor: 'pointer',
borderRadius: '4px',
display: 'flex',
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }}
>
<X size={14} />
</button>
)}
</div>
<style jsx>{`
.pending-spinner {
width: 14px;
height: 14px;
border: 2px solid #22c55e;
border-top-color: transparent;
border-radius: 50%;
animation: pendingSpin 0.8s linear infinite;
}
@keyframes pendingSpin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
interface ViewsOverlayProps { interface ViewsOverlayProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
} }
export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) { export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0, pendingNodes, onDismissPending }: ViewsOverlayProps) {
const { dimensionIcons } = useDimensionIcons(); const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker // Dimensions for filter picker
@@ -43,6 +144,13 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
// Sort order (persisted) // Sort order (persisted)
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated'); const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
// Custom order (persisted) — stores node IDs in user-defined order
const [customOrder, setCustomOrder] = usePersistentState<number[]>('ui.feedCustomOrder', []);
// Drag-to-reorder state
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
// Filter system state // Filter system state
const [columns, setColumns] = useState<ColumnFilter[]>([]); const [columns, setColumns] = useState<ColumnFilter[]>([]);
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]); const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
@@ -86,18 +194,37 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
const fetchAllNodes = useCallback(async () => { const fetchAllNodes = useCallback(async () => {
setFilteredNodesLoading(true); setFilteredNodesLoading(true);
try { try {
const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}`); // Custom sort fetches with 'updated' then reorders client-side
const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`);
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes'); throw new Error(data.error || 'Failed to fetch nodes');
} }
setFilteredNodes(data.data || []); const nodes: Node[] = data.data || [];
if (sortOrder === 'custom' && customOrder.length > 0) {
// Reorder nodes based on saved custom order
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); // New nodes not in custom order — append at bottom
}
}
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
setFilteredNodes([...ordered, ...unordered]);
} else {
setFilteredNodes(nodes);
}
} catch (error) { } catch (error) {
console.error('Error fetching nodes:', error); console.error('Error fetching nodes:', error);
} finally { } finally {
setFilteredNodesLoading(false); setFilteredNodesLoading(false);
} }
}, [sortOrder]); }, [sortOrder, customOrder]);
const fetchFilteredNodes = useCallback(async (filters: string[]) => { const fetchFilteredNodes = useCallback(async (filters: string[]) => {
if (filters.length === 0) { if (filters.length === 0) {
@@ -106,18 +233,35 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
} }
setFilteredNodesLoading(true); setFilteredNodesLoading(true);
try { try {
const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`); const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`);
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes'); throw new Error(data.error || 'Failed to fetch nodes');
} }
setFilteredNodes(data.data || []); 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([...ordered, ...unordered]);
} else {
setFilteredNodes(nodes);
}
} catch (error) { } catch (error) {
console.error('Error fetching filtered nodes:', error); console.error('Error fetching filtered nodes:', error);
} finally { } finally {
setFilteredNodesLoading(false); setFilteredNodesLoading(false);
} }
}, [fetchAllNodes, sortOrder]); }, [fetchAllNodes, sortOrder, customOrder]);
// Stringify filters for stable dependency // Stringify filters for stable dependency
const filtersKey = selectedFilters.join(','); const filtersKey = selectedFilters.join(',');
@@ -192,9 +336,29 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterPicker, showSortDropdown]); }, [showFilterPicker, showSortDropdown]);
// Reorder handlers
const handleReorderDrop = useCallback((dropIdx: number) => {
if (reorderDragIndex === null || reorderDragIndex === dropIdx) {
setReorderDragIndex(null);
setReorderDropIndex(null);
return;
}
// Reorder filteredNodes and persist
const newNodes = [...filteredNodes];
const [moved] = newNodes.splice(reorderDragIndex, 1);
newNodes.splice(dropIdx > reorderDragIndex ? dropIdx - 1 : dropIdx, 0, moved);
setFilteredNodes(newNodes);
setCustomOrder(newNodes.map(n => n.id));
setReorderDragIndex(null);
setReorderDropIndex(null);
}, [reorderDragIndex, filteredNodes, setCustomOrder]);
// Render node card // Render node card
const renderNodeCard = (node: Node) => { const renderNodeCard = (node: Node, index: number) => {
const nodeIcon = getNodeIcon(node, dimensionIcons, 18); const nodeIcon = getNodeIcon(node, dimensionIcons, 18);
const isCustomSort = sortOrder === 'custom';
const isDragSource = reorderDragIndex === index;
const isDropTarget = reorderDropIndex === index;
// Description preview — first meaningful line, truncated // Description preview — first meaningful line, truncated
const descPreview = node.description && node.description.length > 10 const descPreview = node.description && node.description.length > 10
@@ -212,6 +376,21 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
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, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`); e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
}} }}
onDragOver={(e) => {
if (!isCustomSort || reorderDragIndex === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setReorderDropIndex(index);
}}
onDragLeave={() => {
if (!isCustomSort) return;
setReorderDropIndex(null);
}}
onDrop={(e) => {
if (!isCustomSort || reorderDragIndex === null) return;
e.preventDefault();
handleReorderDrop(index);
}}
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
background: 'transparent', background: 'transparent',
@@ -219,6 +398,8 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
borderBottom: '1px solid #141414', borderBottom: '1px solid #141414',
borderLeft: '3px solid transparent', borderLeft: '3px solid transparent',
opacity: isDragSource ? 0.4 : 1,
borderTop: isDropTarget ? '2px solid #22c55e' : '2px solid transparent',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'; e.currentTarget.style.background = 'rgba(255,255,255,0.02)';
@@ -234,6 +415,38 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
}} }}
> >
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
{/* Grip handle — only in custom sort mode */}
{isCustomSort && (
<div
draggable
onDragStart={(e) => {
e.stopPropagation();
setReorderDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-rah-reorder', String(index));
}}
onDragEnd={() => {
setReorderDragIndex(null);
setReorderDropIndex(null);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
cursor: 'grab',
color: '#444',
flexShrink: 0,
alignSelf: 'center',
transition: 'color 0.15s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#444'; }}
onClick={(e) => e.stopPropagation()}
>
<GripVertical size={14} />
</div>
)}
<div style={{ <div style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
@@ -622,7 +835,14 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
flexDirection: 'column', flexDirection: 'column',
gap: '8px' gap: '8px'
}}> }}>
{filteredNodes.map(node => renderNodeCard(node))} {pendingNodes && pendingNodes.length > 0 && pendingNodes.map(p => (
<PendingNodeCard
key={p.id}
pending={p}
onDismiss={onDismissPending ? () => onDismissPending(p.id) : undefined}
/>
))}
{filteredNodes.map((node, index) => renderNodeCard(node, index))}
</div> </div>
)} )}
</div> </div>
+14 -4
View File
@@ -28,11 +28,21 @@ When creating a node derived from existing content:
## Dimension Assignment ## Dimension Assignment
- New nodes should be assigned to relevant existing dimensions - New nodes should be assigned to relevant existing dimensions
- Check locked/priority dimensions — these auto-assign - Check priority dimensions — assign these when relevant
- If no existing dimension fits, create a new one (but prefer existing) - If no existing dimension fits, create a new one (but prefer existing)
## Description Field ## Description Field
- AI auto-generates a ~1 sentence description after creation The description is the most important field for AI context. It powers search (5x boost),
- Description is used for embeddings and search ranking (5x boost) appears in system prompts, and determines how agents understand the node.
- Format: what is this node about, in plain language
**Standard:** State WHAT this is + WHY it matters. Extremely concise, high-level. Max 280 characters.
**Rules:**
- NO weak verbs: "discusses", "explores", "examines", "talks about"
- State the actual claim, insight, or purpose directly
- Include significance or implication in 1 phrase
- If auto-generated (omitted), the system will generate one — but agent-written descriptions are always better
**Good:** "By Karpathy — Software is becoming fluid: agents can rip functionality from repos instead of taking dependencies."
**Bad:** "This article discusses the importance of software becoming more fluid."
+41 -34
View File
@@ -51,16 +51,6 @@ function buildTaskPrompt(type: QuickAddInputType, input: string): string {
} }
} }
function buildExpectedOutcome(type: QuickAddInputType): string {
if (type === 'note') {
return 'Call createNode with the user input as content. Use a descriptive title extracted from the first few words. Set dimensions to an empty array. Return a brief confirmation like "Created note [NODE:id:title]"';
}
if (type === 'chat') {
return 'Store the entire transcript in chunk_content, summarize the conversation into content, and return a confirmation like "Created chat transcript node [NODE:id:title]"';
}
return 'Use the appropriate extraction tool (youtubeExtract, websiteExtract, or paperExtract) to create the node. Return the standard extraction summary.';
}
type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>; type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>;
const EXTRACTION_TOOL_MAP = { const EXTRACTION_TOOL_MAP = {
@@ -207,7 +197,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
const title = deriveNoteTitle(content); const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = { const nodePayload: Record<string, unknown> = {
title, title,
content, notes: content,
metadata: { metadata: {
source: 'quick-add-note', source: 'quick-add-note',
refined_at: new Date().toISOString(), refined_at: new Date().toISOString(),
@@ -312,7 +302,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title, title,
content, notes: content,
chunk: transcript, chunk: transcript,
metadata, metadata,
}), }),
@@ -341,41 +331,58 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
} }
export interface QuickAddResult { export interface QuickAddResult {
success: boolean; id: string;
task: string;
inputType: QuickAddInputType;
status: 'queued' | 'completed' | 'failed';
summary?: string; summary?: string;
error?: string; error?: string;
nodeId?: number;
} }
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> { export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
const inputType = detectInputType(rawInput, mode); const inputType = detectInputType(rawInput, mode);
const task = buildTaskPrompt(inputType, rawInput); const task = buildTaskPrompt(inputType, rawInput);
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try { const result: QuickAddResult = {
let summary: string; id,
if (inputType === 'note') { task,
summary = await handleNoteQuickAdd(rawInput, task, description); inputType,
} else if (inputType === 'chat') { status: 'queued',
summary = await handleChatTranscriptQuickAdd(rawInput, task); };
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
// Extract node ID from summary if present // Run async - fire and forget
const nodeIdMatch = summary.match(/\[NODE:(\d+)/); setImmediate(async () => {
const nodeId = nodeIdMatch ? parseInt(nodeIdMatch[1], 10) : undefined; try {
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
// Broadcast node created event console.log(`[QuickAdd] Completed: ${task}`);
if (nodeId) { // 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({ eventBroadcaster.broadcast({
type: 'NODE_CREATED', type: 'NODE_CREATED',
data: { node: { id: nodeId, title: 'Quick Add' } } data: { node: { title: task }, source: 'quick-add' }
});
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.error(`[QuickAdd] Failed: ${task} - ${message}`);
eventBroadcaster.broadcast({
type: 'QUICK_ADD_FAILED',
data: { quickAddId: id, error: message, source: 'quick-add' }
}); });
} }
});
return { success: true, summary, nodeId }; return result;
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
return { success: false, error: `Quick Add failed: ${message}` };
}
} }
+16 -12
View File
@@ -40,7 +40,7 @@ export function generateFallbackDescription(input: DescriptionInput): string {
return `${parts.join(' — ')}: ${title.slice(0, 200)}`; return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
} }
return `Knowledge item: ${title.slice(0, 250)}`; return title.slice(0, 280);
} }
/** /**
@@ -133,20 +133,24 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const contentPreview = input.notes?.slice(0, 800) || ''; const contentPreview = input.notes?.slice(0, 800) || '';
if (contentPreview) lines.push(`Notes: ${contentPreview}${input.notes && input.notes.length > 800 ? '...' : ''}`); if (contentPreview) lines.push(`Notes: ${contentPreview}${input.notes && input.notes.length > 800 ? '...' : ''}`);
return `Your job is to answer: "what is this?" in one short line. Max 280 characters. return `Write a description for this knowledge node. Max 280 characters.
GOAL: Include "who created it" when possible. Say WHAT this literally is and WHY it matters. Be concrete and specific — like you're telling a friend what this thing is in one breath.
RULES (in priority order): RULES:
1) ONLY use a creator if you have a creator hint (Author/Channel) or the content explicitly says "by <X>" / "hosted by <X>". 1) Name the format: "Podcast episode where…", "Blog post arguing…", "Your note on…", "Research paper showing…", "Idea that…"
- Do NOT treat prominent people in the title/transcript (e.g. a guest) as the creator. 2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
- If a creator hint is provided, prefer it. 3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
2) If you can identify a creator/author/channel/person/org from a creator hint, start with: "By <creator> — ..." 4) End with why it's interesting or important — one concrete phrase.
3) If it's likely user-authored, start with: "Your <thing> — ..." (don't invent a creator name). 5) ABSOLUTELY FORBIDDEN — these words will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for". State things directly instead.
4) If creator is unknown, do NOT guess; omit the byline.
Then, in the remainder, state what it is (video/paper/article/note/idea/etc) + what it's about (high-signal). GOOD: "Karpathy blog post — AI agents make software fluid, ripping functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
If unsure, say so briefly. GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what comes next."
GOOD: "Your note — morning optimism consistently reverses to evening pessimism. Not energy — the belief itself flips. Pattern worth tracking."
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
BAD: "This article explores ideas about how software is changing."
Return ONLY the description text. Nothing else.
${lines.join('\n')}`; ${lines.join('\n')}`;
} }
+42
View File
@@ -7,6 +7,46 @@ export class NodeService {
return this.getNodesSQLite(filters); return this.getNodesSQLite(filters);
} }
async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore } = filters;
const sqlite = getSQLiteClient();
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.notes LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (createdAfter) { query += ` AND n.created_at >= ?`; params.push(createdAfter); }
if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); }
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
const result = sqlite.query<{ total: number }>(query, params);
return result.rows[0]?.total ?? 0;
}
// PostgreSQL path removed in SQLite-only consolidation // PostgreSQL path removed in SQLite-only consolidation
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> { private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
@@ -94,6 +134,8 @@ export class NodeService {
query += ' ORDER BY edge_count DESC, n.updated_at DESC'; query += ' ORDER BY edge_count DESC, n.updated_at DESC';
} else if (sortBy === 'created') { } else if (sortBy === 'created') {
query += ' ORDER BY n.created_at DESC'; query += ' ORDER BY n.created_at DESC';
} else if (sortBy === 'event_date') {
query += ' ORDER BY n.event_date IS NULL, n.event_date DESC, n.updated_at DESC';
} else { } else {
query += ' ORDER BY n.updated_at DESC'; query += ' ORDER BY n.updated_at DESC';
} }
+2
View File
@@ -16,6 +16,8 @@ export interface DatabaseEvent {
| 'AGENT_DELEGATION_CREATED' | 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED' | 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED' | 'GUIDE_UPDATED'
| 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED'
| 'CONNECTION_ESTABLISHED'; | 'CONNECTION_ESTABLISHED';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any; data: any;
+3 -1
View File
@@ -6,8 +6,10 @@ export const createNodeTool = tool({
description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)', description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)',
inputSchema: z.object({ inputSchema: z.object({
title: z.string().describe('The title of the node'), title: z.string().describe('The title of the node'),
notes: z.string().optional().describe('The main notes, description, or notes for this node'), description: z.string().max(280).optional().describe('WHAT this is + WHY it matters. Extremely concise. No "discusses/explores". Auto-generated if omitted.'),
notes: z.string().optional().describe('The main notes or content for this node'),
link: z.string().optional().describe('A URL link to the source'), link: z.string().optional().describe('A URL link to the source'),
event_date: z.string().optional().describe('ISO date string for time-anchored nodes (e.g. meetings, events)'),
dimensions: z dimensions: z
.array(z.string()) .array(z.string())
.max(5) .max(5)
+4 -2
View File
@@ -7,8 +7,10 @@ export const updateNodeTool = tool({
id: z.number().describe('The ID of the node to update'), id: z.number().describe('The ID of the node to update'),
updates: z.object({ updates: z.object({
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
notes: z.string().optional().describe('New content/description/notes'), description: z.string().max(280).optional().describe('New description (overwrites existing). WHAT this is + WHY it matters. No "discusses/explores".'),
notes: z.string().optional().describe('New notes (appended to existing)'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('ISO date string for time-anchored nodes'),
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'), dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
chunk: z.string().optional().describe('New chunk content'), chunk: z.string().optional().describe('New chunk content'),
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata') metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
@@ -79,7 +81,7 @@ export const updateNodeTool = tool({
// Call the nodes API endpoint // Call the nodes API endpoint
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, { const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Notes-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates) body: JSON.stringify(updates)
}); });
+28 -18
View File
@@ -8,43 +8,51 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const prompt = `Analyze this ${contentType} content and provide classification: const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
Description: "${description}" Description: "${description}"
CRITICAL — nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Paper by…", "Research from…", "Preprint introducing…"
2. Name the authors if known from the metadata.
3. State the actual finding, method, or contribution — not "a study on X" but what they actually found or built.
4. End with why it matters — one concrete phrase about impact or implication.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
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. Foundation of every modern LLM."
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. The paper that launched the scaling era."
BAD: "A study examining how neural language models scale with different factors."
Respond with ONLY valid JSON (no markdown, no code blocks): Respond with ONLY valid JSON (no markdown, no code blocks):
{ {
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)", "enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"], "nodeDescription": "<your 280-char description following the rules above>",
"reasoning": "Brief explanation of why you chose these categories" "tags": ["relevant", "semantic", "tags"],
} "reasoning": "Brief explanation of classification choices"
}`;
Guidelines:
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- For academic papers, include tags like: research, academic, paper, plus domain-specific tags
- For AI/ML papers, include: ai, machine-learning, artificial-intelligence, deep-learning
- For economics papers, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({ const response = await generateText({
model: openai('gpt-5-mini'), model: openai('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 800 maxOutputTokens: 800
}); });
let content = response.text || '{}'; let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present // Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content); const result = JSON.parse(content);
return { return {
enhancedDescription: result.enhancedDescription || description, enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [], tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed' reasoning: result.reasoning || 'AI analysis completed'
}; };
@@ -53,6 +61,7 @@ Guidelines:
console.warn('Paper analysis fallback (using default description):', message); console.warn('Paper analysis fallback (using default description):', message);
return { return {
enhancedDescription: description, enhancedDescription: description,
nodeDescription: undefined,
tags: [], tags: [],
reasoning: 'Fallback description used' reasoning: 'Fallback description used'
}; };
@@ -143,6 +152,7 @@ export const paperExtractTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription,
notes: enhancedDescription, notes: enhancedDescription,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
+28 -17
View File
@@ -8,42 +8,51 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const prompt = `Analyze this ${contentType} content and provide classification: const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
Description: "${description}" Description: "${description}"
CRITICAL — nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…"
2. Name the author/site if known from the metadata.
3. State the actual claim or thesis — don't paraphrase into vague abstractions.
4. End with why it's interesting or important — one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
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. Signals the end of monolithic libraries."
BAD: "By Karpathy — discusses the importance of software becoming more fluid and malleable with agents."
- 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. Notable because it cites internal lab disagreements."
BAD: "This article explores ideas about slowing down AI development and its implications."
Respond with ONLY valid JSON (no markdown, no code blocks): Respond with ONLY valid JSON (no markdown, no code blocks):
{ {
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)", "enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"], "nodeDescription": "<your 280-char description following the rules above>",
"reasoning": "Brief explanation of why you chose these categories" "tags": ["relevant", "semantic", "tags"],
} "reasoning": "Brief explanation of classification choices"
}`;
Guidelines:
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({ const response = await generateText({
model: openai('gpt-5-mini'), model: openai('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 800 maxOutputTokens: 800
}); });
let content = response.text || '{}'; let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present // Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content); const result = JSON.parse(content);
return { return {
enhancedDescription: result.enhancedDescription || description, enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [], tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed' reasoning: result.reasoning || 'AI analysis completed'
}; };
@@ -52,6 +61,7 @@ Guidelines:
console.warn('Website analysis fallback (using default description):', message); console.warn('Website analysis fallback (using default description):', message);
return { return {
enhancedDescription: description, enhancedDescription: description,
nodeDescription: undefined,
tags: [], tags: [],
reasoning: 'Fallback description used' reasoning: 'Fallback description used'
}; };
@@ -134,6 +144,7 @@ export const websiteExtractTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription,
notes: enhancedDescription, notes: enhancedDescription,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
+31 -36
View File
@@ -5,61 +5,55 @@ import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube'; import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// Available segments for categorization - match database constraint
const AVAILABLE_SEGMENTS = [
'inbox', 'parking', 'in progress', 'archive'
];
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const prompt = `Analyze this ${contentType} content and provide classification: const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
Description: "${description}" Description: "${description}"
Available types: ${AVAILABLE_SEGMENTS.join(', ')} CRITICAL — nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
2. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
3. State the actual claim or thesis from the title — don't paraphrase into vague abstractions.
4. End with why it's interesting or important — one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
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. Key signal for what the next phase of AI development looks like."
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective."
- 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. Essential mental model for anyone building with LLMs."
BAD: "By Andrej Karpathy — explores the nature of language models and their capabilities."
Respond with ONLY valid JSON (no markdown, no code blocks): Respond with ONLY valid JSON (no markdown, no code blocks):
{ {
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)", "enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"], "nodeDescription": "<your 280-char description following the rules above>",
"segments": ["research"], "tags": ["relevant", "semantic", "tags"],
"reasoning": "Brief explanation of why you chose these categories" "reasoning": "Brief explanation of classification choices"
} }`;
Guidelines:
- Choose 1 segment that best fits the content type
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({ const response = await generateText({
model: openai('gpt-5-mini'), model: openai('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 800 maxOutputTokens: 800
}); });
let content = response.text || '{}'; let content = response.text || '{}';
// Clean up the response - remove markdown code blocks if present // Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content); const result = JSON.parse(content);
// Validate segments are from available list
const validSegments = result.segments?.filter((seg: string) =>
AVAILABLE_SEGMENTS.includes(seg)
) || [];
return { return {
enhancedDescription: result.enhancedDescription || description, enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [], tags: Array.isArray(result.tags) ? result.tags : [],
segments: validSegments,
reasoning: result.reasoning || 'AI analysis completed' reasoning: result.reasoning || 'AI analysis completed'
}; };
} catch (error) { } catch (error) {
@@ -67,8 +61,8 @@ Guidelines:
console.warn('YouTube analysis fallback (using default description):', message); console.warn('YouTube analysis fallback (using default description):', message);
return { return {
enhancedDescription: description, enhancedDescription: description,
nodeDescription: undefined,
tags: [], tags: [],
segments: [],
reasoning: 'Fallback description used' reasoning: 'Fallback description used'
}; };
} }
@@ -79,7 +73,7 @@ async function summariseTranscript(title: string, transcript: string): Promise<s
return null; return null;
} }
// Limit transcript length to keep token costs manageable (approx ≤4k tokens for gpt-4o-mini) // Limit transcript length to keep token costs manageable
const MAX_CHARS = 16000; const MAX_CHARS = 16000;
let excerpt = transcript.trim(); let excerpt = transcript.trim();
if (excerpt.length > MAX_CHARS) { if (excerpt.length > MAX_CHARS) {
@@ -179,8 +173,8 @@ export const youtubeExtractTool = tool({
// Step 3: Create node with extracted content and AI analysis // 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 nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.notes || ''); const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.notes || '');
const content = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`; const nodeNotes = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : []; const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions let trimmedDimensions = suppliedDimensions
.map(dim => (typeof dim === 'string' ? dim.trim() : '')) .map(dim => (typeof dim === 'string' ? dim.trim() : ''))
@@ -193,7 +187,8 @@ export const youtubeExtractTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
content, description: aiAnalysis?.nodeDescription,
notes: nodeNotes,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
chunk: result.chunk || result.notes, chunk: result.chunk || result.notes,
+1 -1
View File
@@ -69,7 +69,7 @@ export interface NodeFilters {
search?: string; // Text search in title/content search?: string; // Text search in title/content
limit?: number; limit?: number;
offset?: number; offset?: number;
sortBy?: 'updated' | 'edges' | 'created'; // Sort by updated_at, edge count, or created_at 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 dimensionsMatch?: 'any' | 'all'; // 'any' = OR (default), 'all' = AND
createdAfter?: string; // ISO date (YYYY-MM-DD) — nodes created on or after createdAfter?: string; // ISO date (YYYY-MM-DD) — nodes created on or after
createdBefore?: string; // ISO date (YYYY-MM-DD) — nodes created before createdBefore?: string; // ISO date (YYYY-MM-DD) — nodes created before