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:
co-authored by
Claude Opus 4.6
parent
9954792b1d
commit
2f6518207d
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+50
-15
@@ -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,6 +64,20 @@ 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();
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">·</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}↑
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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}` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,30 +8,37 @@ 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
|
||||||
});
|
});
|
||||||
@@ -45,6 +52,7 @@ Guidelines:
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -8,29 +8,37 @@ 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
|
||||||
});
|
});
|
||||||
@@ -44,6 +52,7 @@ Guidelines:
|
|||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -5,41 +5,40 @@ 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
|
||||||
});
|
});
|
||||||
@@ -51,15 +50,10 @@ Guidelines:
|
|||||||
|
|
||||||
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,7 +173,7 @@ 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user