sync: Idea Genealogy + Quick Capture Overhaul
From private repo: Idea Genealogy: - Required edge explanations across UI/tools/API/MCP - Inferred edge schema (category/type/confidence) - Added part_of relationship type - Connection UI chips (Made by / Part of / Came from / Related) Quick Capture Overhaul: - Jina.ai fallback for JS-rendered sites (Twitter, SPAs) - Simplified UI: removed mode buttons, auto-detect input type - Renamed to "Add Stuff", moved to top-right - Auto-edge creation from description entities - Increased content summary length (500→1500 chars) Description Generation Fix: - Simplified prompt to "what is this" - Added link/dimensions context - Better creator attribution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
f7b8b2058c
commit
2cbc950fd4
@@ -202,11 +202,11 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Top Bar - just collapse button */}
|
||||
{/* Top Bar - collapse button + Add Stuff */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{onCollapse && (
|
||||
@@ -238,6 +238,13 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{/* Add Stuff - top right */}
|
||||
<div style={{ maxWidth: '200px' }}>
|
||||
<QuickAddInput
|
||||
activeDelegations={orderedDelegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Section - Start button centered */}
|
||||
@@ -280,7 +287,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<span
|
||||
className="start-text"
|
||||
style={{
|
||||
color: '#737373',
|
||||
@@ -293,7 +300,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
>
|
||||
Start
|
||||
</span>
|
||||
<div
|
||||
<div
|
||||
className="start-icon"
|
||||
style={{
|
||||
width: '44px',
|
||||
@@ -314,14 +321,6 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Add - at bottom, full width with padding */}
|
||||
<div style={{ width: '100%', padding: '0 16px 16px 16px' }}>
|
||||
<QuickAddInput
|
||||
activeDelegations={orderedDelegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -399,7 +398,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -413,7 +412,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
fontWeight: 600,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
Capture
|
||||
Add Stuff
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
type QuickAddIntent = 'link' | 'note' | 'chat';
|
||||
|
||||
interface QuickAddSubmitPayload {
|
||||
input: string;
|
||||
mode: QuickAddIntent;
|
||||
mode: 'link' | 'note' | 'chat';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -17,63 +14,10 @@ interface QuickAddInputProps {
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
const MODE_CONFIG: Array<{
|
||||
key: QuickAddIntent;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
}> = [
|
||||
{
|
||||
key: 'link',
|
||||
label: 'Link',
|
||||
hint: 'Drop URLs for auto extraction',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
label: 'Note',
|
||||
hint: 'Quick note, no processing',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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" />
|
||||
<path d="M14 2v6h6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M16 13H8" strokeLinecap="round" />
|
||||
<path d="M16 17H8" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Chat',
|
||||
hint: 'Paste conversations',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
|
||||
const [autoMode, setAutoMode] = useState<QuickAddIntent>('link');
|
||||
|
||||
const effectiveMode: QuickAddIntent = manualMode ?? autoMode;
|
||||
|
||||
const currentPlaceholder = useMemo(() => {
|
||||
if (effectiveMode === 'note') return 'Write a quick note...';
|
||||
if (effectiveMode === 'chat') return 'Paste conversation...';
|
||||
return 'Paste a link or write something...';
|
||||
}, [effectiveMode]);
|
||||
|
||||
const maxConcurrent = 5;
|
||||
const activeCount = activeDelegations.filter(
|
||||
@@ -81,44 +25,17 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
).length;
|
||||
const isSoftLimited = activeCount >= maxConcurrent;
|
||||
|
||||
const inferChatIntent = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || manualMode) return;
|
||||
|
||||
const newlineCount = (trimmed.match(/\n/g)?.length ?? 0);
|
||||
const looksLikeTranscript =
|
||||
newlineCount >= 2 ||
|
||||
/You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed);
|
||||
|
||||
if (looksLikeTranscript && trimmed.length > 280) {
|
||||
setAutoMode('chat');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInput(value);
|
||||
inferChatIntent(value);
|
||||
};
|
||||
|
||||
const handleModeClick = (mode: QuickAddIntent) => {
|
||||
setManualMode(mode);
|
||||
setAutoMode(mode);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
// Mode is auto-detected server-side via quickAdd.ts detectInputType()
|
||||
await onSubmit({
|
||||
input: input.trim(),
|
||||
mode: effectiveMode,
|
||||
description: description.trim() || undefined
|
||||
mode: 'link', // Default; actual type is inferred server-side
|
||||
});
|
||||
setInput('');
|
||||
setDescription('');
|
||||
setManualMode(null);
|
||||
setAutoMode('link');
|
||||
setIsExpanded(false);
|
||||
} catch (error) {
|
||||
console.error('[QuickAddInput] Submit error:', error);
|
||||
@@ -138,7 +55,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
}
|
||||
};
|
||||
|
||||
// Collapsed state - visible trigger
|
||||
// Collapsed state - prominent "ADD STUFF" button
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
@@ -147,114 +64,105 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
padding: '16px 24px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '12px',
|
||||
color: '#888',
|
||||
fontSize: '12px',
|
||||
gap: '8px',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '8px',
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.1em',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
transition: 'all 0.2s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 0 12px rgba(34, 197, 94, 0.15)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#444';
|
||||
e.currentTarget.style.color = '#aaa';
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
e.currentTarget.style.color = '#888';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
>
|
||||
CAPTURE
|
||||
<span style={{
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700
|
||||
}}>+</span>
|
||||
Add Stuff
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
// Expanded state - full width overlay
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
padding: '0',
|
||||
background: '#0a0a0a',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #2a2a2a',
|
||||
zIndex: 100,
|
||||
animation: 'fadeIn 150ms ease-out'
|
||||
}}>
|
||||
{/* Mode tabs */}
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
{MODE_CONFIG.map((mode) => {
|
||||
const isActive = effectiveMode === mode.key;
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
onClick={() => handleModeClick(mode.key)}
|
||||
title={mode.hint}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
border: isActive ? '1px solid #333' : '1px solid transparent',
|
||||
background: isActive ? '#1a1a1a' : 'transparent',
|
||||
color: isActive ? '#fff' : '#666',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.color = '#999';
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', opacity: isActive ? 1 : 0.7 }}>{mode.icon}</span>
|
||||
{mode.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700
|
||||
}}>+</span>
|
||||
Add Stuff
|
||||
</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
setInput('');
|
||||
setDescription('');
|
||||
}}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '8px',
|
||||
padding: '6px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
@@ -274,28 +182,29 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input area - consistent height */}
|
||||
{/* Input area - expanded */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentPlaceholder}
|
||||
placeholder="Paste a URL, note, or transcript — RA-H will figure out what it is."
|
||||
disabled={isPosting || isSoftLimited}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '80px',
|
||||
maxHeight: '200px',
|
||||
padding: '12px 14px',
|
||||
minHeight: '140px',
|
||||
maxHeight: '300px',
|
||||
padding: '14px 16px',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '10px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
lineHeight: '1.6',
|
||||
transition: 'border-color 0.15s ease'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
@@ -307,51 +216,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 280) {
|
||||
setDescription(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="This is a... (optional)"
|
||||
disabled={isPosting || isSoftLimited}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
maxHeight: '100px',
|
||||
padding: '10px 14px',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
color: '#a5a5a5',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
transition: 'border-color 0.15s ease'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
right: '10px',
|
||||
fontSize: '10px',
|
||||
color: description.length >= 260 ? '#f59e0b' : '#525252'
|
||||
}}>
|
||||
{description.length}/280
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '11px', color: '#525252' }}>
|
||||
@@ -361,8 +225,8 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isPosting || isSoftLimited}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
width: '38px',
|
||||
height: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -386,9 +250,9 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
}}
|
||||
>
|
||||
{isPosting ? (
|
||||
<span style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
<span style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid #0a0a0a',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState<NodeSearchResult[]>([]);
|
||||
const [selectedSearchIndex, setSelectedSearchIndex] = useState(0);
|
||||
const [edgeExplanation, setEdgeExplanation] = useState('');
|
||||
const [pendingEdgeTarget, setPendingEdgeTarget] = useState<{ id: number; title: string } | null>(null);
|
||||
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
|
||||
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
|
||||
const [edgeEditingValue, setEdgeEditingValue] = useState<string>('');
|
||||
@@ -296,7 +297,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
from_node_id: nodeId,
|
||||
to_node_id: toId,
|
||||
source: 'user',
|
||||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
||||
explanation: 'Referenced via @ mention'
|
||||
})
|
||||
});
|
||||
}));
|
||||
@@ -345,7 +346,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
from_node_id: nodeId,
|
||||
to_node_id: toId,
|
||||
source: 'user',
|
||||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
||||
explanation: 'Referenced via @ mention'
|
||||
})
|
||||
});
|
||||
}));
|
||||
@@ -540,7 +541,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
from_node_id: sourceNodeId,
|
||||
to_node_id: nodeId,
|
||||
source: 'user',
|
||||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
||||
explanation: 'Referenced via @ mention'
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -630,7 +631,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
// Edge management functions (following same patterns as node functions)
|
||||
|
||||
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
|
||||
createEdgeWithExplanation(targetNodeId, '');
|
||||
setPendingEdgeTarget({ id: targetNodeId, title: _targetNodeTitle || `Node ${targetNodeId}` });
|
||||
setEdgeExplanation('');
|
||||
};
|
||||
|
||||
// Handle node search keyboard navigation
|
||||
@@ -648,13 +650,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
};
|
||||
|
||||
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
|
||||
createEdgeWithExplanation(suggestion.id, '');
|
||||
setNodeSearchQuery('');
|
||||
setPendingEdgeTarget({ id: suggestion.id, title: suggestion.title });
|
||||
setEdgeExplanation('');
|
||||
setNodeSearchSuggestions([]);
|
||||
};
|
||||
|
||||
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
||||
if (activeNodeId === null) return;
|
||||
const trimmed = (explanation || '').trim();
|
||||
if (!trimmed) {
|
||||
alert('Please add a short explanation for why this connection exists.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/edges', {
|
||||
method: 'POST',
|
||||
@@ -665,10 +672,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
from_node_id: activeNodeId,
|
||||
to_node_id: targetNodeId,
|
||||
source: 'user',
|
||||
context: {
|
||||
explanation: explanation,
|
||||
created_via: 'focus_panel'
|
||||
}
|
||||
explanation: trimmed
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -682,6 +686,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
// Reset state
|
||||
setAddingEdge(null);
|
||||
setEdgeExplanation('');
|
||||
setPendingEdgeTarget(null);
|
||||
setNodeSearchQuery('');
|
||||
setNodeSearchSuggestions([]);
|
||||
setShowConnectionsModal(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating edge:', error);
|
||||
@@ -807,14 +815,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
key={suggestion.id}
|
||||
onClick={() => handleSelectNodeSuggestion(suggestion)}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
||||
transition: 'background 100ms ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
gap: '10px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (index !== selectedSearchIndex) {
|
||||
@@ -831,13 +839,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
fontSize: '9px',
|
||||
fontWeight: 600,
|
||||
color: '#0a0a0a',
|
||||
background: '#22c55e',
|
||||
padding: '4px 8px',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '6px',
|
||||
minWidth: '28px',
|
||||
minWidth: '24px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||
@@ -845,7 +853,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
{suggestion.id}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '15px',
|
||||
fontSize: '13px',
|
||||
color: '#e5e5e5',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -861,6 +869,123 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation (required) */}
|
||||
{pendingEdgeTarget && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 500 }}>
|
||||
Connecting to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Made by', value: 'Created by ' },
|
||||
{ label: 'Part of', value: 'Part of ' },
|
||||
{ label: 'Came from', value: 'Came from ' },
|
||||
{ label: 'Related', value: 'Related to ' },
|
||||
].map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEdgeExplanation((prev) => {
|
||||
const trimmed = (prev || '').trim();
|
||||
return trimmed.length > 0 ? prev : chip.value;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #262626',
|
||||
background: '#141414',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#141414'; }}
|
||||
title={`Prefill: ${chip.value.trim()}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={edgeExplanation}
|
||||
onChange={(e) => setEdgeExplanation(e.target.value)}
|
||||
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
background: '#141414',
|
||||
border: '1px solid #1f1f1f',
|
||||
color: '#fafafa',
|
||||
borderRadius: '10px',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #262626',
|
||||
color: '#a3a3a3',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: '#22c55e',
|
||||
border: '1px solid #16a34a',
|
||||
color: '#0a0a0a',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
Create connection
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: '#737373', fontSize: '11px' }}>
|
||||
Tip: press <span style={{ fontFamily: 'monospace' }}>⌘</span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing Connections */}
|
||||
@@ -877,11 +1002,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
}
|
||||
const visible = edgesExpanded[activeTab] ? list : list.slice(0, 5);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visible.map((connection) => (
|
||||
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
|
||||
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '10px',
|
||||
@@ -896,7 +1021,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
}}>
|
||||
{connection.connected_node.id}
|
||||
</span>
|
||||
<span style={{ color: '#f8fafc', fontSize: '14px', fontWeight: 500 }}>{connection.connected_node.title}</span>
|
||||
<span style={{ color: '#f8fafc', fontSize: '13px', fontWeight: 500 }}>{connection.connected_node.title}</span>
|
||||
</div>
|
||||
{edgeEditingId === connection.edge.id ? (
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
@@ -958,8 +1083,33 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{typeof connection.edge.context?.type === 'string' && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#a3a3a3',
|
||||
background: '#1f1f1f',
|
||||
border: '1px solid #262626',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
textTransform: 'none'
|
||||
}}>
|
||||
{String(connection.edge.context.type).replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
{connection.edge.context?.explanation ? (
|
||||
<span style={{ color: '#94a3b8', fontSize: '12px' }}>— {connection.edge.context.explanation}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#94a3b8',
|
||||
fontSize: '12px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
title={String(connection.edge.context.explanation)}
|
||||
>
|
||||
— {connection.edge.context.explanation}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#64748b', fontSize: '12px', fontStyle: 'italic' }}>No explanation</span>
|
||||
)}
|
||||
@@ -986,7 +1136,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
disabled={deletingEdge === connection.edge.id}
|
||||
style={{
|
||||
color: deletingEdge === connection.edge.id ? '#64748b' : '#94a3b8',
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
|
||||
@@ -2127,14 +2277,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
setNodeSearchSuggestions([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||||
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
||||
transition: 'background 100ms ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px'
|
||||
gap: '10px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
@@ -2149,13 +2299,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
fontSize: '9px',
|
||||
fontWeight: 600,
|
||||
color: '#0a0a0a',
|
||||
background: '#22c55e',
|
||||
padding: '4px 8px',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '6px',
|
||||
minWidth: '28px',
|
||||
minWidth: '24px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||
@@ -2163,7 +2313,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
{suggestion.id}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
color: '#e5e5e5',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -2196,6 +2346,126 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation prompt (required) */}
|
||||
{pendingEdgeTarget && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
background: '#141414',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '16px',
|
||||
padding: '16px 18px',
|
||||
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 600 }}>
|
||||
Create connection to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Made by', value: 'Created by ' },
|
||||
{ label: 'Part of', value: 'Part of ' },
|
||||
{ label: 'Came from', value: 'Came from ' },
|
||||
{ label: 'Related', value: 'Related to ' },
|
||||
].map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEdgeExplanation((prev) => {
|
||||
const trimmed = (prev || '').trim();
|
||||
return trimmed.length > 0 ? prev : chip.value;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #262626',
|
||||
background: '#0f0f0f',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#0f0f0f'; }}
|
||||
title={`Prefill: ${chip.value.trim()}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={edgeExplanation}
|
||||
onChange={(e) => setEdgeExplanation(e.target.value)}
|
||||
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #333',
|
||||
color: '#fafafa',
|
||||
borderRadius: '12px',
|
||||
padding: '10px 12px',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#262626',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
color: '#a3a3a3',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#22c55e',
|
||||
border: '1px solid #16a34a',
|
||||
borderRadius: '10px',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: '#737373', fontSize: '11px' }}>
|
||||
Tip: press <span style={{ fontFamily: 'monospace' }}>⌘</span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Connections */}
|
||||
{!nodeSearchQuery && (
|
||||
<div style={{
|
||||
@@ -2211,7 +2481,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
minHeight: 0
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #1f1f1f',
|
||||
fontSize: '12px',
|
||||
color: '#737373',
|
||||
@@ -2236,15 +2506,15 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
<div
|
||||
key={connection.id}
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #1f1f1f',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
{/* Connection header row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -2253,9 +2523,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
fontWeight: 600,
|
||||
color: '#0a0a0a',
|
||||
background: '#22c55e',
|
||||
padding: '4px 8px',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '6px',
|
||||
minWidth: '28px',
|
||||
minWidth: '24px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||
@@ -2266,7 +2536,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
@@ -2302,7 +2572,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
{/* Description row */}
|
||||
{edgeEditingId === connection.edge.id ? (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<input
|
||||
value={edgeEditingValue}
|
||||
onChange={(e) => setEdgeEditingValue(e.target.value)}
|
||||
@@ -2319,12 +2589,12 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
placeholder="Add explanation..."
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
color: '#e5e5e5',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 10px',
|
||||
padding: '6px 8px',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
@@ -2332,7 +2602,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
<button
|
||||
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
padding: '5px 10px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
@@ -2347,7 +2617,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
<button
|
||||
onClick={cancelEditEdgeExplanation}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
padding: '5px 10px',
|
||||
background: '#262626',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
@@ -2368,12 +2638,39 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
color: connection.edge.context?.explanation ? '#888' : '#525252',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic'
|
||||
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#aaa'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = connection.edge.context?.explanation ? '#888' : '#525252'; }}
|
||||
>
|
||||
{(connection.edge.context?.explanation as string) || 'Click to add explanation...'}
|
||||
{typeof connection.edge.context?.type === 'string' && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#a3a3a3',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #262626',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{String(connection.edge.context.type).replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={String(connection.edge.context?.explanation || '')}
|
||||
>
|
||||
{(connection.edge.context?.explanation as string) || 'Click to add explanation...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,11 @@ WORKFLOW STEPS
|
||||
Call createEdge for each:
|
||||
- from_node_id: focused node ID
|
||||
- to_node_id: related node ID
|
||||
- context: { explanation: "brief reason" }
|
||||
- explanation: "brief reason for this connection"
|
||||
Direction rule: write the explanation so it reads FROM → TO.
|
||||
Examples:
|
||||
- Episode → Podcast: "Episode of this podcast"
|
||||
- Book → Author: "Written by"
|
||||
|
||||
4. DONE
|
||||
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
|
||||
|
||||
@@ -30,7 +30,11 @@ WORKFLOW STEPS
|
||||
For each connection found, call createEdge:
|
||||
- from_node_id: the focused node ID
|
||||
- to_node_id: the connected node ID
|
||||
- context: { explanation: "why this connection matters" }
|
||||
- explanation: "why this connection matters"
|
||||
Direction rule: write the explanation so it reads FROM → TO.
|
||||
Examples:
|
||||
- Insight → Source: "Came from / inspired by"
|
||||
- Episode → Podcast: "Episode of this podcast"
|
||||
|
||||
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Auto-Edge Creation Service
|
||||
*
|
||||
* After Quick Capture creates a node, this service:
|
||||
* 1. Extracts candidate entity strings from the node's description
|
||||
* 2. Looks up existing entity nodes by exact title match
|
||||
* 3. Creates edges with explanations for matches
|
||||
*
|
||||
* This is a "fast path" for obvious connections only - conservative by design.
|
||||
*/
|
||||
|
||||
import { nodeService, edgeService } from '@/services/database';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
// Entity-like dimensions where we expect to find referenceable entities
|
||||
const ENTITY_DIMENSIONS = ['people', 'companies', 'organizations', 'books', 'papers', 'articles', 'podcasts', 'creators'];
|
||||
|
||||
/**
|
||||
* Clean up a candidate entity string by removing common prefixes/suffixes.
|
||||
*/
|
||||
function cleanEntityCandidate(candidate: string): string {
|
||||
let cleaned = candidate.trim();
|
||||
|
||||
// Remove common author/attribution prefixes
|
||||
const prefixPatterns = [
|
||||
/^by\s+/i,
|
||||
/^author:\s*/i,
|
||||
/^written by\s+/i,
|
||||
/^from\s+/i,
|
||||
/^via\s+/i,
|
||||
/^featuring\s+/i,
|
||||
/^with\s+/i,
|
||||
/^hosted by\s+/i,
|
||||
];
|
||||
|
||||
for (const pattern of prefixPatterns) {
|
||||
cleaned = cleaned.replace(pattern, '');
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate entity strings from text using conservative heuristics.
|
||||
* Returns proper names, quoted titles, and recognized patterns.
|
||||
*/
|
||||
function extractCandidateEntities(text: string): string[] {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
|
||||
const candidates: Set<string> = new Set();
|
||||
|
||||
// Pattern 1: "By [Name]" pattern - common in article descriptions
|
||||
// Matches: "By Simon Willison", "by Sam Altman"
|
||||
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
|
||||
let match;
|
||||
while ((match = byPattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
if (name.length >= 4 && !isGenericPhrase(name)) {
|
||||
candidates.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Proper name sequences (2-4 capitalized words)
|
||||
// Matches: "Sam Altman", "Dario Amodei", "Peter Thiel"
|
||||
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
|
||||
while ((match = properNamePattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
// Clean the candidate (remove "By ", etc.)
|
||||
const cleaned = cleanEntityCandidate(name);
|
||||
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
|
||||
candidates.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: Quoted titles (single or double quotes)
|
||||
// Matches: "Zero to One", 'The Lean Startup'
|
||||
const quotedPattern = /["']([^"']{3,60})["']/g;
|
||||
while ((match = quotedPattern.exec(text)) !== null) {
|
||||
const title = match[1].trim();
|
||||
if (title.length >= 3 && !isGenericPhrase(title)) {
|
||||
candidates.add(title);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Known organization patterns
|
||||
// Matches: OpenAI, DeepMind, Y Combinator, Fly.io
|
||||
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
|
||||
while ((match = orgPattern.exec(text)) !== null) {
|
||||
candidates.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phrase is too generic to be a useful entity reference.
|
||||
*/
|
||||
function isGenericPhrase(phrase: string): boolean {
|
||||
const normalized = phrase.toLowerCase();
|
||||
|
||||
// Common stopwords and generic terms
|
||||
const genericTerms = [
|
||||
'the author', 'the article', 'the book', 'the podcast',
|
||||
'this article', 'this book', 'this podcast', 'this paper',
|
||||
'new research', 'recent study', 'key points', 'main ideas',
|
||||
'artificial intelligence', 'machine learning', 'deep learning',
|
||||
'first section', 'last section', 'next chapter',
|
||||
'united states', 'new york', 'san francisco', 'silicon valley'
|
||||
];
|
||||
|
||||
return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up existing nodes that match candidate entity strings.
|
||||
* Uses exact title matching (case-insensitive) with optional dimension filtering.
|
||||
*/
|
||||
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
|
||||
const matches = new Map<string, Node>();
|
||||
|
||||
if (candidates.length === 0) return matches;
|
||||
|
||||
// Get all nodes (we'll filter in memory for exact title matches)
|
||||
// In a larger system, we'd use a more efficient query
|
||||
const allNodes = await nodeService.getNodes({ limit: 10000 });
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalizedCandidate = candidate.toLowerCase().trim();
|
||||
|
||||
// Find exact title match (case-insensitive)
|
||||
const matchingNode = allNodes.find(node => {
|
||||
const normalizedTitle = (node.title || '').toLowerCase().trim();
|
||||
if (normalizedTitle !== normalizedCandidate) return false;
|
||||
|
||||
// Optionally filter to entity-like dimensions for higher confidence
|
||||
// But don't require it - some entities might not have dimensions yet
|
||||
const nodeDimensions = node.dimensions || [];
|
||||
const hasEntityDimension = nodeDimensions.some(dim =>
|
||||
ENTITY_DIMENSIONS.includes(dim.toLowerCase())
|
||||
);
|
||||
|
||||
// Accept if it has an entity dimension OR if it's a short title (likely a named entity)
|
||||
return hasEntityDimension || node.title.length < 50;
|
||||
});
|
||||
|
||||
if (matchingNode) {
|
||||
matches.set(candidate, matchingNode);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create edges from a new node to matched entity nodes.
|
||||
* Each edge includes an explanation for auditability.
|
||||
*/
|
||||
async function createAutoEdges(
|
||||
newNodeId: number,
|
||||
matches: Map<string, Node>
|
||||
): Promise<number> {
|
||||
let edgesCreated = 0;
|
||||
|
||||
for (const [candidateText, entityNode] of matches) {
|
||||
// Skip self-references
|
||||
if (entityNode.id === newNodeId) continue;
|
||||
|
||||
// Check if edge already exists
|
||||
const exists = await edgeService.edgeExists(newNodeId, entityNode.id);
|
||||
if (exists) continue;
|
||||
|
||||
try {
|
||||
await edgeService.createEdge({
|
||||
from_node_id: newNodeId,
|
||||
to_node_id: entityNode.id,
|
||||
explanation: `Explicitly mentioned in description: "${candidateText}"`,
|
||||
created_via: 'quick_capture_auto',
|
||||
source: 'ai_similarity',
|
||||
skip_inference: false, // Let Idea Genealogy classify the relationship
|
||||
});
|
||||
edgesCreated++;
|
||||
console.log(`[autoEdge] Created edge: ${newNodeId} → ${entityNode.id} (${entityNode.title})`);
|
||||
} catch (error) {
|
||||
console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return edgesCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: Run auto-edge creation for a newly created node.
|
||||
* This is designed to be called fire-and-forget (non-blocking).
|
||||
*/
|
||||
export async function runAutoEdgeCreation(nodeId: number): Promise<void> {
|
||||
try {
|
||||
// Fetch the newly created node
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use description as the source of truth for entity extraction
|
||||
const description = node.description || '';
|
||||
if (!description || description.length < 10) {
|
||||
console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract candidate entities from description
|
||||
const candidates = extractCandidateEntities(description);
|
||||
if (candidates.length === 0) {
|
||||
console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates);
|
||||
|
||||
// Find matching existing nodes
|
||||
const matches = await findMatchingEntityNodes(candidates);
|
||||
if (matches.size === 0) {
|
||||
console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create edges
|
||||
const edgesCreated = await createAutoEdges(nodeId, matches);
|
||||
console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`);
|
||||
} catch (error) {
|
||||
console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule auto-edge creation to run asynchronously (fire-and-forget).
|
||||
* Use this from the nodes API to avoid blocking the response.
|
||||
*/
|
||||
export function scheduleAutoEdgeCreation(nodeId: number): void {
|
||||
setImmediate(() => {
|
||||
runAutoEdgeCreation(nodeId).catch(error => {
|
||||
console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -48,6 +48,34 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
||||
}
|
||||
|
||||
function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
const normalizedSource = (input.metadata?.source || '').toLowerCase();
|
||||
const url = typeof input.link === 'string' ? input.link.trim() : '';
|
||||
|
||||
// Best-effort creator hint from structured metadata (when available),
|
||||
// but never assume a particular extraction source (YouTube vs paper vs website vs note).
|
||||
const creatorHint =
|
||||
input.metadata?.author?.trim() ||
|
||||
input.metadata?.channel_name?.trim() ||
|
||||
'';
|
||||
|
||||
// Best-effort publisher / container hint (less ideal than a true author, but better than nothing).
|
||||
const publisherHint = input.metadata?.site_name?.trim() || '';
|
||||
|
||||
const likelyExternal =
|
||||
Boolean(url) ||
|
||||
normalizedSource.includes('youtube') ||
|
||||
normalizedSource.includes('extract') ||
|
||||
normalizedSource.includes('paper') ||
|
||||
normalizedSource.includes('pdf') ||
|
||||
normalizedSource.includes('website');
|
||||
|
||||
const likelyUserAuthored =
|
||||
!likelyExternal &&
|
||||
(normalizedSource.includes('quick-add-note') ||
|
||||
normalizedSource.includes('quick-add-chat') ||
|
||||
normalizedSource.includes('note') ||
|
||||
normalizedSource.length === 0);
|
||||
|
||||
const lines: string[] = [`Title: ${input.title}`];
|
||||
|
||||
if (input.link) lines.push(`URL: ${input.link}`);
|
||||
@@ -55,11 +83,27 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
|
||||
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
|
||||
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
|
||||
if (creatorHint) lines.push(`Creator hint: ${creatorHint}`);
|
||||
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
|
||||
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
|
||||
|
||||
const contentPreview = input.content?.slice(0, 800) || '';
|
||||
if (contentPreview) lines.push(`Content: ${contentPreview}${input.content && input.content.length > 800 ? '...' : ''}`);
|
||||
|
||||
return `Your job is to do your best to answer 'what is this' - the most simple, high level contextual information of what this thing is. Users will be adding a variety of different nodes (ideas, books, podcasts, people, papers etc). Do your best to take the available information and infer what it is - high level. If unsure, that's fine just give your best guess and say you're unsure. Max 280 characters.
|
||||
return `Your job is to answer: "what is this?" in one short line. Max 280 characters.
|
||||
|
||||
GOAL: Include "who created it" when possible.
|
||||
|
||||
RULES (in priority order):
|
||||
1) ONLY use a creator if you have a creator hint (Author/Channel) or the content explicitly says "by <X>" / "hosted by <X>".
|
||||
- Do NOT treat prominent people in the title/transcript (e.g. a guest) as the creator.
|
||||
- If a creator hint is provided, prefer it.
|
||||
2) If you can identify a creator/author/channel/person/org from a creator hint, start with: "By <creator> — ..."
|
||||
3) If it's likely user-authored, start with: "Your <thing> — ..." (don't invent a creator name).
|
||||
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).
|
||||
If unsure, say so briefly.
|
||||
|
||||
${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,120 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { Edge, EdgeData, NodeConnection, Node } from '@/types/database';
|
||||
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
|
||||
import { eventBroadcaster } from '../events';
|
||||
import { nodeService } from './nodes';
|
||||
import { apiKeyService } from '../storage/apiKeys';
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const inferredEdgeContextSchema = z.object({
|
||||
category: z.enum(['attribution', 'intellectual']),
|
||||
type: z.enum([
|
||||
'created_by',
|
||||
'features',
|
||||
'part_of',
|
||||
'source_of',
|
||||
'extends',
|
||||
'supports',
|
||||
'contradicts',
|
||||
'related_to'
|
||||
]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
async function inferEdgeContext(params: {
|
||||
explanation: string;
|
||||
fromNode: Node;
|
||||
toNode: Node;
|
||||
}): Promise<Pick<EdgeContext, 'category' | 'type' | 'confidence'>> {
|
||||
const { explanation, fromNode, toNode } = params;
|
||||
|
||||
// Heuristic fast-paths for the 4 core UI chips.
|
||||
// This makes classification robust and reduces reliance on the model.
|
||||
const norm = explanation.trim().toLowerCase();
|
||||
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
|
||||
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
|
||||
return { category: 'attribution', type: 'created_by', confidence: 1.0 };
|
||||
}
|
||||
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
|
||||
return { category: 'attribution', type: 'part_of', confidence: 1.0 };
|
||||
}
|
||||
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
|
||||
return { category: 'attribution', type: 'features', confidence: 0.95 };
|
||||
}
|
||||
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
|
||||
return { category: 'intellectual', type: 'source_of', confidence: 0.9 };
|
||||
}
|
||||
if (startsWithAny(['related to', 'related'])) {
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.8 };
|
||||
}
|
||||
|
||||
// If no API key is configured, degrade gracefully.
|
||||
// We still enforce explanation, but fall back to "related_to" classification.
|
||||
const apiKey = apiKeyService.getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.0 };
|
||||
}
|
||||
|
||||
const provider = createOpenAI({ apiKey });
|
||||
const prompt = [
|
||||
`Given this edge explanation: "${explanation}"`,
|
||||
``,
|
||||
`From node:`,
|
||||
`- Title: "${fromNode.title}"`,
|
||||
`- Description: ${fromNode.description || 'No description available'}`,
|
||||
`- Dimensions: ${fromNode.dimensions?.join(', ') || 'none'}`,
|
||||
``,
|
||||
`To node:`,
|
||||
`- Title: "${toNode.title}"`,
|
||||
`- Description: ${toNode.description || 'No description available'}`,
|
||||
`- Dimensions: ${toNode.dimensions?.join(', ') || 'none'}`,
|
||||
``,
|
||||
`Classify the relationship:`,
|
||||
`- category: "attribution" (factual: author, creator, host, guest) or "intellectual" (idea relationship)`,
|
||||
`- type: one of [created_by, features, part_of, source_of, extends, supports, contradicts, related_to]`,
|
||||
`- confidence: 0-1`,
|
||||
``,
|
||||
`IMPORTANT: Interpret the direction as "FROM node → TO node". Pick a type that reads correctly in that direction:`,
|
||||
`- created_by: FROM was created/founded/authored by TO`,
|
||||
`- features: FROM features TO (host/guest/subject appearing in FROM)`,
|
||||
`- part_of: FROM is part of TO (episode→podcast, chapter→book, note→project)`,
|
||||
`- source_of: FROM came from TO / was inspired by TO`,
|
||||
`- extends/supports/contradicts: FROM extends/supports/contradicts TO`,
|
||||
``,
|
||||
`Return JSON only: {"category": "...", "type": "...", "confidence": 0.X}`
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: provider('gpt-4o-mini'),
|
||||
prompt,
|
||||
temperature: 0.0,
|
||||
maxOutputTokens: 120,
|
||||
});
|
||||
|
||||
const parsedJson = (() => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Sometimes models wrap JSON in prose; try to recover.
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) return JSON.parse(match[0]);
|
||||
throw new Error('AI did not return valid JSON');
|
||||
}
|
||||
})();
|
||||
|
||||
const parsed = inferredEdgeContextSchema.safeParse(parsedJson);
|
||||
if (!parsed.success) {
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
console.warn('[edges] inferEdgeContext failed; falling back to related_to', error);
|
||||
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
|
||||
}
|
||||
}
|
||||
|
||||
export class EdgeService {
|
||||
async getEdges(): Promise<Edge[]> {
|
||||
@@ -24,14 +138,41 @@ export class EdgeService {
|
||||
private async createEdgeSQLite(edgeData: EdgeData): Promise<Edge> {
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
|
||||
const explanation = (edgeData.explanation || '').trim();
|
||||
if (!explanation) {
|
||||
throw new Error('Edge explanation is required');
|
||||
}
|
||||
|
||||
const createdVia: EdgeCreatedVia = edgeData.created_via;
|
||||
|
||||
// Fetch nodes for inference context
|
||||
const [fromNode, toNode] = await Promise.all([
|
||||
nodeService.getNodeById(edgeData.from_node_id),
|
||||
nodeService.getNodeById(edgeData.to_node_id),
|
||||
]);
|
||||
|
||||
if (!fromNode) throw new Error(`Source node ${edgeData.from_node_id} not found`);
|
||||
if (!toNode) throw new Error(`Target node ${edgeData.to_node_id} not found`);
|
||||
|
||||
const inferred = edgeData.skip_inference
|
||||
? { category: 'intellectual' as const, type: 'related_to' as const, confidence: 0.0 }
|
||||
: await inferEdgeContext({ explanation, fromNode, toNode });
|
||||
|
||||
const context: EdgeContext = {
|
||||
...inferred,
|
||||
inferred_at: now,
|
||||
explanation,
|
||||
created_via: createdVia,
|
||||
};
|
||||
|
||||
const result = sqlite.prepare(`
|
||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
edgeData.from_node_id,
|
||||
edgeData.to_node_id,
|
||||
JSON.stringify(edgeData.context || {}),
|
||||
JSON.stringify(context),
|
||||
edgeData.source,
|
||||
now
|
||||
);
|
||||
@@ -67,6 +208,51 @@ export class EdgeService {
|
||||
const updateFields: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
// If explanation changes, re-infer classification and write full EdgeContext
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'context') && updates.context && typeof updates.context === 'object') {
|
||||
const incomingContext = updates.context as Partial<EdgeContext> & { explanation?: unknown };
|
||||
if (typeof incomingContext.explanation === 'string') {
|
||||
const explanation = incomingContext.explanation.trim();
|
||||
if (!explanation) {
|
||||
throw new Error('Edge explanation is required');
|
||||
}
|
||||
|
||||
const existingEdge = await this.getEdgeById(id);
|
||||
if (!existingEdge) {
|
||||
throw new Error(`Edge with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const [fromNode, toNode] = await Promise.all([
|
||||
nodeService.getNodeById(existingEdge.from_node_id),
|
||||
nodeService.getNodeById(existingEdge.to_node_id),
|
||||
]);
|
||||
|
||||
if (!fromNode) throw new Error(`Source node ${existingEdge.from_node_id} not found`);
|
||||
if (!toNode) throw new Error(`Target node ${existingEdge.to_node_id} not found`);
|
||||
|
||||
const inferred = await inferEdgeContext({ explanation, fromNode, toNode });
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existingContext = (existingEdge.context && typeof existingEdge.context === 'object')
|
||||
? (existingEdge.context as Partial<EdgeContext>)
|
||||
: undefined;
|
||||
|
||||
const created_via: EdgeCreatedVia =
|
||||
(incomingContext.created_via as EdgeCreatedVia) ||
|
||||
(existingContext?.created_via as EdgeCreatedVia) ||
|
||||
'ui';
|
||||
|
||||
updates.context = {
|
||||
...existingContext,
|
||||
...incomingContext,
|
||||
...inferred,
|
||||
inferred_at: now,
|
||||
explanation,
|
||||
created_via,
|
||||
} satisfies EdgeContext;
|
||||
}
|
||||
}
|
||||
|
||||
// Build dynamic update query
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
|
||||
@@ -309,17 +495,23 @@ export class EdgeService {
|
||||
}
|
||||
|
||||
async createBidirectionalEdge(fromId: number, toId: number, options?: {
|
||||
context?: any;
|
||||
explanation?: string;
|
||||
created_via?: EdgeCreatedVia;
|
||||
source?: 'user' | 'ai_similarity' | 'helper_name';
|
||||
skip_inference?: boolean;
|
||||
}): Promise<Edge[]> {
|
||||
const edges: Edge[] = [];
|
||||
const explanation = (options?.explanation || 'Similarity-based connection').trim();
|
||||
const created_via: EdgeCreatedVia = options?.created_via || 'workflow';
|
||||
|
||||
// Create edge from A to B
|
||||
const forwardEdge = await this.createEdge({
|
||||
from_node_id: fromId,
|
||||
to_node_id: toId,
|
||||
context: options?.context,
|
||||
source: options?.source || 'ai_similarity'
|
||||
explanation,
|
||||
created_via,
|
||||
source: options?.source || 'ai_similarity',
|
||||
skip_inference: options?.skip_inference,
|
||||
});
|
||||
edges.push(forwardEdge);
|
||||
|
||||
@@ -327,8 +519,10 @@ export class EdgeService {
|
||||
const backwardEdge = await this.createEdge({
|
||||
from_node_id: toId,
|
||||
to_node_id: fromId,
|
||||
context: options?.context,
|
||||
source: options?.source || 'ai_similarity'
|
||||
explanation,
|
||||
created_via,
|
||||
source: options?.source || 'ai_similarity',
|
||||
skip_inference: options?.skip_inference,
|
||||
});
|
||||
edges.push(backwardEdge);
|
||||
|
||||
|
||||
@@ -111,13 +111,18 @@ function parseMetadata(metadata: unknown): Record<string, any> {
|
||||
if (!metadata) return {};
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
const parsed = JSON.parse(metadata);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as Record<string, any>;
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse node metadata JSON:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof metadata === 'object') {
|
||||
if (metadata === null) return {};
|
||||
return metadata as Record<string, any>;
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -27,6 +27,46 @@ export class WebsiteExtractor {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract content using Jina.ai Reader API
|
||||
* Used as fallback when cheerio fails (JS-rendered sites, Twitter, SPAs)
|
||||
* Free tier: 20 requests/minute per IP (no API key needed)
|
||||
*/
|
||||
private async extractWithJina(url: string): Promise<ExtractionResult> {
|
||||
const jinaUrl = `https://r.jina.ai/${url}`;
|
||||
|
||||
const response = await fetch(jinaUrl, {
|
||||
headers: { 'User-Agent': 'RA-H/1.0' },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Jina extraction failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const markdown = await response.text();
|
||||
|
||||
// Parse title from first markdown heading or first line
|
||||
const titleMatch = markdown.match(/^#\s+(.+)$/m) || markdown.match(/^(.+)$/m);
|
||||
const title = titleMatch?.[1]?.trim() || 'Extracted Content';
|
||||
|
||||
// Build metadata
|
||||
const metadata: WebsiteMetadata = {
|
||||
title,
|
||||
extraction_method: 'jina',
|
||||
};
|
||||
|
||||
// Format content consistently with cheerio output
|
||||
const content = this.formatContent(metadata, markdown);
|
||||
|
||||
return {
|
||||
content,
|
||||
chunk: markdown,
|
||||
metadata,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean extracted content for better readability
|
||||
*/
|
||||
@@ -189,55 +229,69 @@ export class WebsiteExtractor {
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content using cheerio (static HTML parser)
|
||||
* Fast, free, no external calls - but fails on JS-rendered sites
|
||||
*/
|
||||
private async extractWithCheerio(url: string): Promise<ExtractionResult> {
|
||||
const response = await fetch(url, {
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const metadata = this.extractMetadata($);
|
||||
metadata.extraction_method = 'cheerio';
|
||||
const mainContent = this.extractMainContent($);
|
||||
|
||||
const content = this.formatContent(metadata, mainContent);
|
||||
|
||||
return {
|
||||
content,
|
||||
chunk: mainContent,
|
||||
metadata,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extraction method
|
||||
* Strategy: Try cheerio first (fast, free), fall back to Jina for JS-rendered sites
|
||||
*/
|
||||
async extract(url: string): Promise<ExtractionResult> {
|
||||
// Validate URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new Error('Invalid URL format - must start with http:// or https://');
|
||||
}
|
||||
|
||||
|
||||
// Try cheerio first (fast, no external call)
|
||||
try {
|
||||
// Fetch the webpage
|
||||
const response = await fetch(url, {
|
||||
headers: this.headers,
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const result = await this.extractWithCheerio(url);
|
||||
|
||||
// If content is substantial, cheerio worked
|
||||
if (result.chunk.length > 200) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML with cheerio
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Extract metadata and content
|
||||
const metadata = this.extractMetadata($);
|
||||
// Mark extraction method for downstream metadata
|
||||
metadata.extraction_method = 'typescript_cheerio';
|
||||
const mainContent = this.extractMainContent($);
|
||||
|
||||
// Format content for display
|
||||
const content = this.formatContent(metadata, mainContent);
|
||||
|
||||
// Chunk is the main content text
|
||||
const chunk = mainContent;
|
||||
|
||||
return {
|
||||
content,
|
||||
chunk,
|
||||
metadata,
|
||||
url,
|
||||
};
|
||||
|
||||
|
||||
console.log('[WebsiteExtractor] Cheerio extraction too short, trying Jina...');
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timeout - website took too long to respond');
|
||||
}
|
||||
throw error;
|
||||
console.log('[WebsiteExtractor] Cheerio failed, trying Jina...', error.message);
|
||||
}
|
||||
|
||||
// Fallback to Jina for JS-rendered sites
|
||||
try {
|
||||
return await this.extractWithJina(url);
|
||||
} catch (jinaError: any) {
|
||||
throw new Error(`Extraction failed: ${jinaError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,32 @@ import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
|
||||
export const createEdgeTool = tool({
|
||||
description: 'Create directed relationship between nodes',
|
||||
description:
|
||||
'Create directed relationship between nodes.\n\n' +
|
||||
'Direction rule: FROM node → TO node should read correctly.\n' +
|
||||
'Prefer the 4 core relations unless the user clearly wants an advanced intellectual relation:\n' +
|
||||
'- Made by → created_by (attribution)\n' +
|
||||
'- Part of → part_of (attribution)\n' +
|
||||
'- Came from → source_of (intellectual)\n' +
|
||||
'- Related → related_to (intellectual fallback)\n\n' +
|
||||
'Examples:\n' +
|
||||
'- Episode → Podcast: "Episode of this podcast"\n' +
|
||||
'- Book → Author: "Written by"\n' +
|
||||
'- Company → Founder: "Founded by"\n' +
|
||||
'- Insight → Source: "Came from / inspired by"\n',
|
||||
inputSchema: z.object({
|
||||
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
|
||||
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
|
||||
context: z.record(z.any()).optional().describe('Additional context about this connection - can include explanation, relationship type, strength, notes, etc.'),
|
||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe('Source of this edge - use "ai" for AI-created connections, "user" for manual connections, "ai_similarity" for similarity-based connections')
|
||||
explanation: z.string().describe(
|
||||
'REQUIRED: Why does this connection exist? Be specific. ' +
|
||||
'Write it as a relationship that reads FROM → TO. ' +
|
||||
'Examples: "Author of this book", "Guest on this podcast", ' +
|
||||
'"Episode of this podcast", "This insight came from this podcast episode", "Extends the concept introduced here"'
|
||||
),
|
||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe(
|
||||
'Source of this edge. Use "ai" (or "helper_name") for AI-created connections, ' +
|
||||
'"user" for manual connections, "ai_similarity" for similarity-based connections.'
|
||||
)
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||
@@ -41,6 +61,15 @@ export const createEdgeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
const explanation = (params.explanation || '').trim();
|
||||
if (!explanation) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'explanation is required. Provide a clear reason for why these two nodes should be connected.',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const [fromNode, toNode] = await Promise.all([
|
||||
nodeService.getNodeById(params.from_node_id),
|
||||
nodeService.getNodeById(params.to_node_id)
|
||||
@@ -84,7 +113,8 @@ export const createEdgeTool = tool({
|
||||
const newEdge = await edgeService.createEdge({
|
||||
from_node_id: params.from_node_id,
|
||||
to_node_id: params.to_node_id,
|
||||
context: params.context || {},
|
||||
explanation,
|
||||
created_via: 'agent',
|
||||
source
|
||||
});
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@ Description: "${description}"
|
||||
|
||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
{
|
||||
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
|
||||
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
|
||||
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
||||
"reasoning": "Brief explanation of why you chose these categories"
|
||||
}
|
||||
|
||||
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)
|
||||
- Make description informative and contextual
|
||||
- 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
|
||||
@@ -32,7 +33,7 @@ Guidelines:
|
||||
const response = await generateText({
|
||||
model: openai('gpt-5-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 500
|
||||
maxOutputTokens: 800
|
||||
});
|
||||
|
||||
let content = response.text || '{}';
|
||||
@@ -122,7 +123,7 @@ export const paperExtractTool = tool({
|
||||
// Step 2: AI Analysis for enhanced metadata
|
||||
const aiAnalysis = await analyzeContentWithAI(
|
||||
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
||||
result.content?.substring(0, 1000) || 'PDF document content',
|
||||
result.content?.substring(0, 2000) || 'PDF document content',
|
||||
'pdf'
|
||||
);
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@ Description: "${description}"
|
||||
|
||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
{
|
||||
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
|
||||
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
|
||||
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
||||
"reasoning": "Brief explanation of why you chose these categories"
|
||||
}
|
||||
|
||||
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)
|
||||
- Make description informative and contextual
|
||||
- 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
|
||||
@@ -31,7 +32,7 @@ Guidelines:
|
||||
const response = await generateText({
|
||||
model: openai('gpt-5-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 500
|
||||
maxOutputTokens: 800
|
||||
});
|
||||
|
||||
let content = response.text || '{}';
|
||||
@@ -113,7 +114,7 @@ export const websiteExtractTool = tool({
|
||||
// Step 2: AI Analysis for enhanced metadata
|
||||
const aiAnalysis = await analyzeContentWithAI(
|
||||
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
||||
result.content?.substring(0, 500) || 'Website content',
|
||||
result.content?.substring(0, 2000) || 'Website content',
|
||||
'website'
|
||||
);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Available types: ${AVAILABLE_SEGMENTS.join(', ')}
|
||||
|
||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
{
|
||||
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
|
||||
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
|
||||
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
|
||||
"segments": ["research"],
|
||||
"reasoning": "Brief explanation of why you chose these categories"
|
||||
@@ -30,8 +30,9 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
|
||||
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)
|
||||
- Make description informative and contextual
|
||||
- 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
|
||||
@@ -40,7 +41,7 @@ Guidelines:
|
||||
const response = await generateText({
|
||||
model: openai('gpt-5-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 500
|
||||
maxOutputTokens: 800
|
||||
});
|
||||
|
||||
let content = response.text || '{}';
|
||||
|
||||
+35
-3
@@ -58,10 +58,40 @@ export interface Edge {
|
||||
from_node_id: number;
|
||||
to_node_id: number;
|
||||
context?: any;
|
||||
source: 'user' | 'ai_similarity' | 'helper_name';
|
||||
source: EdgeSource;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type EdgeSource = 'user' | 'ai_similarity' | 'helper_name';
|
||||
|
||||
export type EdgeContextCategory = 'attribution' | 'intellectual';
|
||||
|
||||
export type EdgeContextType =
|
||||
| 'created_by'
|
||||
| 'features'
|
||||
| 'part_of'
|
||||
| 'source_of'
|
||||
| 'extends'
|
||||
| 'supports'
|
||||
| 'contradicts'
|
||||
| 'related_to';
|
||||
|
||||
export type EdgeCreatedVia = 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink' | 'quick_capture_auto';
|
||||
|
||||
export interface EdgeContext {
|
||||
// SYSTEM-INFERRED (AI classifies from explanation + nodes)
|
||||
category: EdgeContextCategory;
|
||||
type: EdgeContextType;
|
||||
confidence: number; // 0-1
|
||||
inferred_at: string; // ISO timestamp
|
||||
|
||||
// PROVIDED AT CREATION / EDIT
|
||||
explanation: string;
|
||||
|
||||
// SYSTEM-MANAGED
|
||||
created_via: EdgeCreatedVia;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: number;
|
||||
user_message?: string;
|
||||
@@ -120,8 +150,10 @@ export interface ChunkData {
|
||||
export interface EdgeData {
|
||||
from_node_id: number;
|
||||
to_node_id: number;
|
||||
context?: any;
|
||||
source: 'user' | 'ai_similarity' | 'helper_name';
|
||||
explanation: string;
|
||||
created_via: EdgeCreatedVia;
|
||||
source: EdgeSource;
|
||||
skip_inference?: boolean; // reserved for bulk imports / migrations
|
||||
}
|
||||
|
||||
export interface ChatData {
|
||||
|
||||
Reference in New Issue
Block a user