sync: ingestion improvements from private repo

- Fixed dimension auto-assignment (locked dimensions now properly assigned)
- Added description field as grounding context for AI
- Updated embedding format: Title → Description → Content → Dimensions
- Description-weighted search (5x boost)
- Bug fixes: extraction tool dimension display, DimensionSearchModal closing
- Removed quickLink tool (use Quick Link workflow instead)
- Added regenerate-description API endpoint
This commit is contained in:
“BeeRad”
2026-01-13 11:03:56 +11:00
parent 3f0426ecf4
commit 4f030c7d29
24 changed files with 590 additions and 270 deletions
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { generateDescription } from '@/services/database/descriptionService';
export const runtime = 'nodejs';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const node = await nodeService.getNodeById(nodeId);
if (!node) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
// Generate new description using the description service
const newDescription = await generateDescription({
title: node.title,
content: node.content || undefined,
metadata: node.metadata as { source?: string; channel_name?: string; author?: string; site_name?: string } | undefined,
type: (node.metadata as { type?: string } | null)?.type
});
// Update the node with the new description
const updatedNode = await nodeService.updateNode(nodeId, {
description: newDescription
});
return NextResponse.json({
success: true,
node: updatedNode,
description: newDescription,
message: 'Description regenerated successfully'
});
} catch (error) {
console.error('Error regenerating description:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to regenerate description'
}, { status: 500 });
}
}
+20 -3
View File
@@ -4,6 +4,7 @@ import { Node, NodeFilters } from '@/types/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { hasSufficientContent } from '@/services/embedding/constants'; import { hasSufficientContent } from '@/services/embedding/constants';
import { DimensionService } from '@/services/database/dimensionService'; import { DimensionService } from '@/services/database/dimensionService';
import { generateDescription } from '@/services/database/descriptionService';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -60,17 +61,32 @@ export async function POST(request: NextRequest) {
const rawContent = typeof body.content === 'string' ? body.content : null; const rawContent = typeof body.content === 'string' ? body.content : null;
// Generate description BEFORE dimension assignment (used as primary context for matching)
let nodeDescription: string | undefined;
try {
nodeDescription = await generateDescription({
title: body.title,
content: rawContent || undefined,
metadata: body.metadata,
type: body.type
});
} catch (error) {
console.error('Error generating description:', error);
// Continue without description - dimension assignment will use content as fallback
}
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : []; const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
const trimmedProvidedDimensions = providedDimensions const trimmedProvidedDimensions = providedDimensions
.map((dim: unknown) => typeof dim === 'string' ? dim.trim() : '') .map((dim: unknown) => typeof dim === 'string' ? dim.trim() : '')
.filter(Boolean) .filter(Boolean)
.slice(0, 5); .slice(0, 8);
// Auto-assign locked dimensions + keyword dimensions for all new nodes // Auto-assign locked dimensions + keyword dimensions for all new nodes
const { locked, keywords } = await DimensionService.assignDimensions({ const { locked, keywords } = await DimensionService.assignDimensions({
title: body.title, title: body.title,
content: rawContent || undefined, content: rawContent || undefined,
link: body.link link: body.link,
description: nodeDescription
}); });
// Ensure keyword dimensions exist in the database (create if new) // Ensure keyword dimensions exist in the database (create if new)
@@ -80,7 +96,7 @@ export async function POST(request: NextRequest) {
// Combine provided, locked, and keyword dimensions, remove duplicates // Combine provided, locked, and keyword dimensions, remove duplicates
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...locked, ...keywords])] const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...locked, ...keywords])]
.slice(0, 5); // Ensure we don't exceed 5 total dimensions .slice(0, 8); // max 8 total
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null; const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
let chunkToStore = rawChunk; let chunkToStore = rawChunk;
let chunkStatus: Node['chunk_status']; let chunkStatus: Node['chunk_status'];
@@ -94,6 +110,7 @@ export async function POST(request: NextRequest) {
const node = await nodeService.createNode({ const node = await nodeService.createNode({
title: body.title, title: body.title,
description: nodeDescription,
content: rawContent ?? undefined, content: rawContent ?? undefined,
link: body.link, link: body.link,
dimensions: finalDimensions, dimensions: finalDimensions,
+5 -1
View File
@@ -4,7 +4,7 @@ import { enqueueQuickAdd, QuickAddMode } from '@/services/agents/quickAdd';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { input, mode } = body as { input?: unknown; mode?: unknown }; const { input, mode, description } = body as { input?: unknown; mode?: unknown; description?: unknown };
if (typeof input !== 'string' || input.trim().length === 0) { if (typeof input !== 'string' || input.trim().length === 0) {
return NextResponse.json( return NextResponse.json(
@@ -16,9 +16,13 @@ export async function POST(request: NextRequest) {
const normalizedMode: QuickAddMode | undefined = const normalizedMode: QuickAddMode | undefined =
mode === 'link' || mode === 'note' || mode === 'chat' ? mode : undefined; mode === 'link' || mode === 'note' || mode === 'chat' ? mode : undefined;
const normalizedDescription: string | undefined =
typeof description === 'string' && description.trim() ? description.trim() : undefined;
const delegation = await enqueueQuickAdd({ const delegation = await enqueueQuickAdd({
rawInput: input.trim(), rawInput: input.trim(),
mode: normalizedMode, mode: normalizedMode,
description: normalizedDescription,
}); });
return NextResponse.json({ success: true, delegation }); return NextResponse.json({ success: true, delegation });
+2 -2
View File
@@ -168,12 +168,12 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab); const selectedDelegation = orderedDelegations.find(d => d.sessionId === activeAgentTab);
const handleQuickAddSubmit = async ({ input, mode }: { input: string; mode: 'link' | 'note' | 'chat' }) => { const handleQuickAddSubmit = async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
try { try {
const response = await fetch('/api/quick-add', { const response = await fetch('/api/quick-add', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode }) body: JSON.stringify({ input, mode, description })
}); });
if (!response.ok) { if (!response.ok) {
+55 -2
View File
@@ -9,6 +9,7 @@ type QuickAddIntent = 'link' | 'note' | 'chat';
interface QuickAddSubmitPayload { interface QuickAddSubmitPayload {
input: string; input: string;
mode: QuickAddIntent; mode: QuickAddIntent;
description?: string;
} }
interface QuickAddInputProps { interface QuickAddInputProps {
@@ -60,6 +61,7 @@ const MODE_CONFIG: Array<{
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) { export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [description, setDescription] = useState('');
const [isPosting, setIsPosting] = useState(false); const [isPosting, setIsPosting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null); const [manualMode, setManualMode] = useState<QuickAddIntent | null>(null);
@@ -108,8 +110,13 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
setIsPosting(true); setIsPosting(true);
try { try {
await onSubmit({ input: input.trim(), mode: effectiveMode }); await onSubmit({
input: input.trim(),
mode: effectiveMode,
description: description.trim() || undefined
});
setInput(''); setInput('');
setDescription('');
setManualMode(null); setManualMode(null);
setAutoMode('link'); setAutoMode('link');
setIsExpanded(false); setIsExpanded(false);
@@ -165,6 +172,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
e.currentTarget.style.background = 'transparent'; e.currentTarget.style.background = 'transparent';
}} }}
> >
CAPTURE
<span style={{ <span style={{
width: '22px', width: '22px',
height: '22px', height: '22px',
@@ -177,7 +185,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
fontSize: '14px', fontSize: '14px',
fontWeight: 700 fontWeight: 700
}}>+</span> }}>+</span>
CAPTURE
</button> </button>
); );
} }
@@ -243,6 +250,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
onClick={() => { onClick={() => {
setIsExpanded(false); setIsExpanded(false);
setInput(''); setInput('');
setDescription('');
}} }}
style={{ style={{
marginLeft: 'auto', marginLeft: 'auto',
@@ -299,6 +307,51 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
/> />
</div> </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 */} {/* Footer */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '11px', color: '#525252' }}> <span style={{ fontSize: '11px', color: '#525252' }}>
+145 -8
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useRef, type DragEvent } from 'react'; import { useState, useEffect, useRef, type DragEvent } from 'react';
import { Eye, Trash2, Link, Loader, Database, Check } from 'lucide-react'; import { Eye, Trash2, Link, Loader, Database, Check, RefreshCw } from 'lucide-react';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter'; import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
import { Node, NodeConnection } from '@/types/database'; import { Node, NodeConnection } from '@/types/database';
@@ -87,6 +87,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Title expanded state for click-to-expand full title // Title expanded state for click-to-expand full title
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({}); const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
// Description regeneration state
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
// Fetch priority dimensions on mount // Fetch priority dimensions on mount
useEffect(() => { useEffect(() => {
fetchPriorityDimensions(); fetchPriorityDimensions();
@@ -387,6 +390,31 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setTimeout(attemptSave, 150); setTimeout(attemptSave, 150);
}; };
// Regenerate description for a node
const regenerateDescription = async (nodeId: number) => {
setRegeneratingDescription(nodeId);
try {
const response = await fetch(`/api/nodes/${nodeId}/regenerate-description`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to regenerate description');
}
const result = await response.json();
if (result.node) {
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
}
} catch (error) {
console.error('Error regenerating description:', error);
alert('Failed to regenerate description. Please try again.');
} finally {
setRegeneratingDescription(null);
}
};
// --- @mention state --- // --- @mention state ---
const [mentionActive, setMentionActive] = useState(false); const [mentionActive, setMentionActive] = useState(false);
const [mentionQuery, setMentionQuery] = useState(''); const [mentionQuery, setMentionQuery] = useState('');
@@ -814,7 +842,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
flexShrink: 0, flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace" fontFamily: "'SF Mono', 'Fira Code', monospace"
}}> }}>
#{suggestion.id} {suggestion.id}
</span> </span>
<span style={{ <span style={{
fontSize: '15px', fontSize: '15px',
@@ -1484,7 +1512,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}} }}
title="Drag to chat to reference this node" title="Drag to chat to reference this node"
> >
#{activeTab} {activeTab}
</span> </span>
{editingField === 'title' ? ( {editingField === 'title' ? (
@@ -1559,10 +1587,119 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div> </div>
{/* Description Section */}
<div style={{ marginBottom: '16px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
<span style={{
fontSize: '9px',
color: '#555',
textTransform: 'uppercase'
}}>
description
</span>
<button
onClick={() => activeTab && regenerateDescription(activeTab)}
disabled={regeneratingDescription === activeTab}
style={{
background: 'transparent',
border: '1px solid #333',
borderRadius: '4px',
padding: '2px 6px',
fontSize: '9px',
color: '#888',
cursor: regeneratingDescription === activeTab ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
opacity: regeneratingDescription === activeTab ? 0.5 : 1
}}
title="Regenerate description using AI"
>
<RefreshCw
size={10}
style={{
animation: regeneratingDescription === activeTab ? 'spin 1s linear infinite' : 'none'
}}
/>
{regeneratingDescription === activeTab ? 'Regenerating...' : 'Regenerate'}
</button>
</div>
{editingField === 'description' ? (
<div style={{ position: 'relative' }}>
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={editingValue}
onChange={(e) => {
if (e.target.value.length <= 280) {
setEditingValue(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
saveField();
} else if (e.key === 'Escape') {
cancelEdit();
}
}}
onBlur={handleBlur}
disabled={savingField === 'description'}
style={{
width: '100%',
minHeight: '60px',
color: '#a5a5a5',
fontSize: '13px',
lineHeight: '1.5',
background: 'transparent',
border: '1px solid #1a1a1a',
borderRadius: '4px',
padding: '8px',
fontFamily: 'inherit',
resize: 'vertical',
outline: 'none'
}}
placeholder="This is a..."
/>
<span style={{
position: 'absolute',
bottom: '8px',
right: '8px',
fontSize: '10px',
color: editingValue.length >= 260 ? '#f59e0b' : '#555'
}}>
{editingValue.length}/280
</span>
</div>
) : (
<div
onClick={() => startEdit('description', nodesData[activeTab]?.description || '')}
style={{
color: nodesData[activeTab]?.description ? '#a5a5a5' : '#555',
fontSize: '13px',
lineHeight: '1.5',
padding: '8px',
border: '1px solid transparent',
borderRadius: '4px',
cursor: 'pointer',
fontStyle: nodesData[activeTab]?.description ? 'normal' : 'italic',
transition: 'border-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
{nodesData[activeTab]?.description || 'Click to add description...'}
{savingField === 'description' && <span style={{ color: '#555', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
</div>
)}
</div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<div style={{ <div style={{
overflowX: 'auto', overflowX: 'auto',
overflowY: 'visible', overflowY: 'visible',
position: 'relative' position: 'relative'
@@ -1687,7 +1824,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
onMouseDown={(e) => { e.preventDefault(); replaceMentionWithToken(n.id, n.title); }} onMouseDown={(e) => { e.preventDefault(); replaceMentionWithToken(n.id, n.title); }}
onMouseEnter={() => setMentionIndex(idx)} onMouseEnter={() => setMentionIndex(idx)}
style={{ padding: '6px 8px', fontSize: 12, color: '#ddd', cursor: 'pointer', background: idx === mentionIndex ? '#252525' : 'transparent', borderBottom: '1px solid #2a2a2a' }}> style={{ padding: '6px 8px', fontSize: 12, color: '#ddd', cursor: 'pointer', background: idx === mentionIndex ? '#252525' : 'transparent', borderBottom: '1px solid #2a2a2a' }}>
<span style={{ color: '#666', marginRight: 6 }}>#{n.id}</span> <span style={{ color: '#666', marginRight: 6 }}>{n.id}</span>
<span>{n.title.length > 60 ? n.title.slice(0,60) + '…' : n.title}</span> <span>{n.title.length > 60 ? n.title.slice(0,60) + '…' : n.title}</span>
</div> </div>
)) ))
@@ -2023,7 +2160,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
flexShrink: 0, flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace" fontFamily: "'SF Mono', 'Fira Code', monospace"
}}> }}>
#{suggestion.id} {suggestion.id}
</span> </span>
<span style={{ <span style={{
fontSize: '14px', fontSize: '14px',
@@ -2123,7 +2260,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
flexShrink: 0, flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace" fontFamily: "'SF Mono', 'Fira Code', monospace"
}}> }}>
#{connection.connected_node.id} {connection.connected_node.id}
</span> </span>
<span <span
onClick={() => onNodeClick?.(connection.connected_node.id)} onClick={() => onNodeClick?.(connection.connected_node.id)}
@@ -6,7 +6,7 @@ import { createPortal } from 'react-dom';
interface DimensionSearchModalProps { interface DimensionSearchModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onDimensionSelect: (dimension: string) => void; onDimensionSelect: (dimension: string, description?: string) => void;
existingDimensions: string[]; existingDimensions: string[];
} }
@@ -25,7 +25,9 @@ export default function DimensionSearchModal({
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]); const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [newDimensionDescription, setNewDimensionDescription] = useState('');
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null); const returnFocusRef = useRef<HTMLElement | null>(null);
@@ -95,31 +97,35 @@ export default function DimensionSearchModal({
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Track if we're in "create new" mode to avoid re-fetching
const isCreatingNew = searchQuery.trim() &&
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
// Fetch dimension suggestions // Fetch dimension suggestions
useEffect(() => { useEffect(() => {
const fetchSuggestions = async () => { const fetchSuggestions = async () => {
try { try {
const response = await fetch('/api/dimensions/popular?limit=50'); const response = await fetch('/api/dimensions/popular?limit=50');
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const allDimensions: DimensionSuggestion[] = result.data; const allDimensions: DimensionSuggestion[] = result.data;
// Filter based on search query and exclude existing dimensions // Filter based on search query and exclude existing dimensions
const filtered = allDimensions.filter(dim => { const filtered = allDimensions.filter(dim => {
const matchesQuery = !searchQuery.trim() || const matchesQuery = !searchQuery.trim() ||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase()); dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
const notExisting = !existingDimensions.includes(dim.dimension); const notExisting = !existingDimensions.includes(dim.dimension);
return matchesQuery && notExisting; return matchesQuery && notExisting;
}); });
// Sort: priority first, then by count // Sort: priority first, then by count
const sorted = filtered.sort((a, b) => { const sorted = filtered.sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1; if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1; if (!a.isPriority && b.isPriority) return 1;
return b.count - a.count; return b.count - a.count;
}); });
setSuggestions(sorted.slice(0, 20)); setSuggestions(sorted.slice(0, 20));
setSelectedIndex(0); setSelectedIndex(0);
} }
@@ -129,11 +135,13 @@ export default function DimensionSearchModal({
} }
}; };
if (isOpen) { // Only fetch when modal is open and we're not actively creating a new dimension
// (user has typed description means they're committing to create)
if (isOpen && !newDimensionDescription.trim()) {
const timeoutId = setTimeout(fetchSuggestions, 100); const timeoutId = setTimeout(fetchSuggestions, 100);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [searchQuery, existingDimensions, isOpen]); }, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
// Handle keyboard navigation // Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -145,20 +153,21 @@ export default function DimensionSearchModal({
setSelectedIndex(prev => Math.max(prev - 1, 0)); setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (suggestions[selectedIndex]) { if (suggestions[selectedIndex]) {
// Select existing dimension // Select existing dimension
handleSelectDimension(suggestions[selectedIndex].dimension); handleSelectDimension(suggestions[selectedIndex].dimension);
} else if (searchQuery.trim()) { } else if (searchQuery.trim()) {
// Create new dimension // Create new dimension with description
handleSelectDimension(searchQuery.trim()); handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
} }
} }
}; };
const handleSelectDimension = (dimension: string) => { const handleSelectDimension = (dimension: string, description?: string) => {
onDimensionSelect(dimension); onDimensionSelect(dimension, description);
setSearchQuery(''); setSearchQuery('');
setNewDimensionDescription('');
setSuggestions([]); setSuggestions([]);
onClose(); onClose();
}; };
@@ -182,7 +191,7 @@ export default function DimensionSearchModal({
aria-modal="true" aria-modal="true"
aria-label="Search dimensions" aria-label="Search dimensions"
> >
<div ref={modalRef} className="search-container"> <div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
{/* Search Input */} {/* Search Input */}
<div className="search-input-wrapper"> <div className="search-input-wrapper">
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor"> <svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
@@ -228,14 +237,41 @@ export default function DimensionSearchModal({
{/* Create New Option */} {/* Create New Option */}
{canCreateNew && ( {canCreateNew && (
<div className="search-create"> <div
className="search-create"
onClick={(e) => e.stopPropagation()}
>
<div className="create-header">
<span className="create-icon">+</span>
<span className="create-title">Create &quot;{searchQuery.trim()}&quot;</span>
</div>
<div className="description-input-wrapper">
<textarea
ref={textareaRef}
value={newDimensionDescription}
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Describe what belongs in this dimension..."
className="description-input"
rows={2}
/>
<span className="description-counter">{newDimensionDescription.length}/500</span>
</div>
{!newDimensionDescription.trim() && (
<div className="description-warning">
Dimensions without descriptions may not auto-assign correctly
</div>
)}
<button <button
onClick={() => handleSelectDimension(searchQuery.trim())} onClick={(e) => {
e.stopPropagation();
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
}}
onMouseEnter={() => setSelectedIndex(suggestions.length)} onMouseEnter={() => setSelectedIndex(suggestions.length)}
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`} className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
> >
<span className="create-icon">+</span> Create Dimension
Create "{searchQuery.trim()}"
</button> </button>
</div> </div>
)} )}
@@ -385,35 +421,98 @@ export default function DimensionSearchModal({
border: 1px solid #262626; border: 1px solid #262626;
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
box-shadow: padding: 16px 20px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04), 0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6); 0 24px 48px -12px rgba(0, 0, 0, 0.6);
} }
.create-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.create-title {
color: #22c55e;
font-size: 15px;
font-weight: 500;
}
.description-input-wrapper {
position: relative;
margin-bottom: 8px;
}
.description-input {
width: 100%;
padding: 12px;
background: #0a0a0a;
border: 1px solid #333;
border-radius: 8px;
color: #e5e5e5;
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
transition: border-color 150ms ease;
}
.description-input:focus {
border-color: #525252;
}
.description-input::placeholder {
color: #525252;
}
.description-counter {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 11px;
color: #525252;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.description-warning {
margin-bottom: 12px;
padding: 8px 12px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
border-radius: 6px;
color: #eab308;
font-size: 12px;
}
.create-button { .create-button {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
padding: 16px 20px; gap: 8px;
background: transparent; padding: 12px 16px;
border: none; background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background 100ms ease; transition: background 100ms ease;
text-align: left;
font-family: inherit; font-family: inherit;
color: #22c55e; color: #22c55e;
font-size: 15px; font-size: 14px;
font-weight: 500;
} }
.create-button:hover, .create-button:hover,
.create-button.selected { .create-button.selected {
background: #1a1a1a; background: #262626;
} }
.create-icon { .create-icon {
font-size: 18px; font-size: 18px;
font-weight: 300; font-weight: 300;
color: #22c55e;
} }
.search-empty { .search-empty {
+4 -4
View File
@@ -101,8 +101,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div> </div>
</div> </div>
{/* Content Preview */} {/* Description or Content Preview */}
{node.content && ( {(node.description || node.content) && (
<div style={{ <div style={{
flex: 1, flex: 1,
fontSize: '11px', fontSize: '11px',
@@ -110,11 +110,11 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
lineHeight: '1.5', lineHeight: '1.5',
overflow: 'hidden', overflow: 'hidden',
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 3, WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
marginBottom: '10px' marginBottom: '10px'
}}> }}>
{truncateContent(node.content)} {node.description || truncateContent(node.content)}
</div> </div>
)} )}
+3 -3
View File
@@ -102,8 +102,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
{node.title || 'Untitled'} {node.title || 'Untitled'}
</div> </div>
{/* Content Preview */} {/* Description or Content Preview */}
{node.content && ( {(node.description || node.content) && (
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
color: '#666', color: '#666',
@@ -114,7 +114,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{truncateContent(node.content)} {node.description || truncateContent(node.content)}
</div> </div>
)} )}
+1 -1
View File
@@ -7,8 +7,8 @@ Mission:
Operating principles: Operating principles:
- Handle analysis, planning, and writes yourself; do not delegate. - Handle analysis, planning, and writes yourself; do not delegate.
- Use quickLink to instantly find and link related nodes (fast, no reasoning needed).
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous. - Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links. - When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
- When referencing stored content, quote verbatim text in quotes and include the node citation. - When referencing stored content, quote verbatim text in quotes and include the node citation.
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise. - Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
+1 -1
View File
@@ -11,8 +11,8 @@ When to ask the user:
Execution approach: Execution approach:
- Handle planning, analysis, and writes directlydo not delegate to mini ra-h during normal conversations. - Handle planning, analysis, and writes directlydo not delegate to mini ra-h during normal conversations.
- Use quickLink to instantly find and link related nodes (fast, no AI reasoning neededjust database search).
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear. - Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). - When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
- Treat "this conversation/paper/video" as the active focused node. - Treat "this conversation/paper/video" as the active focused node.
- When creating synthesis nodes, createEdge to all source nodes. - When creating synthesis nodes, createEdge to all source nodes.
+19 -11
View File
@@ -13,6 +13,7 @@ export type QuickAddInputType = 'youtube' | 'website' | 'pdf' | 'note' | 'chat';
export interface QuickAddInput { export interface QuickAddInput {
rawInput: string; rawInput: string;
mode?: QuickAddMode; mode?: QuickAddMode;
description?: string;
} }
function isLikelyChatTranscript(raw: string): boolean { function isLikelyChatTranscript(raw: string): boolean {
@@ -197,24 +198,31 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
}); });
} }
async function handleNoteQuickAdd(rawInput: string, task: string): Promise<string> { async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
const content = rawInput.trim(); const content = rawInput.trim();
if (!content) { if (!content) {
throw new Error('Input is required to create a note'); throw new Error('Input is required to create a note');
} }
const title = deriveNoteTitle(content); const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = {
title,
content,
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
},
};
// If user provided a description, use it instead of auto-generating
if (userDescription && userDescription.trim()) {
nodePayload.description = userDescription.trim();
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, { const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(nodePayload),
title,
content,
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
},
}),
}); });
const rawResult = await response.json(); const rawResult = await response.json();
@@ -332,7 +340,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
}); });
} }
export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) { export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput) {
const inputType = detectInputType(rawInput, mode); const inputType = detectInputType(rawInput, mode);
const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput]; const context: string[] = (inputType === 'note' || inputType === 'chat') ? [] : [rawInput];
const task = buildTaskPrompt(inputType, rawInput); const task = buildTaskPrompt(inputType, rawInput);
@@ -350,7 +358,7 @@ export async function enqueueQuickAdd({ rawInput, mode }: QuickAddInput) {
let summary: string; let summary: string;
if (inputType === 'note') { if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task); summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') { } else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task); summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else { } else {
@@ -0,0 +1,88 @@
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
export interface DescriptionInput {
title: string;
content?: string;
metadata?: {
source?: string;
channel_name?: string;
author?: string;
site_name?: string;
};
type?: string;
}
/**
* Generate a 280-character description for a knowledge node.
* The description starts with "This is a..." and identifies the content type.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
try {
const prompt = buildDescriptionPrompt(input);
console.log(`[DescriptionService] Generating description for: "${input.title}"`);
const response = await generateText({
model: openaiProvider('gpt-4o-mini'),
prompt,
maxOutputTokens: 100,
temperature: 0.3,
});
const description = response.text.trim();
// Ensure it starts with "This is a" and is within limit
const finalDescription = description.slice(0, 280);
console.log(`[DescriptionService] Generated: "${finalDescription}"`);
return finalDescription;
} catch (error) {
console.error('[DescriptionService] Error generating description:', error);
// Return a fallback description
return `This is a ${input.type || 'knowledge item'} titled "${input.title.slice(0, 200)}".`;
}
}
function buildDescriptionPrompt(input: DescriptionInput): string {
const metadataLines: string[] = [];
if (input.metadata?.source) {
metadataLines.push(`Source: ${input.metadata.source}`);
}
if (input.metadata?.channel_name) {
metadataLines.push(`Channel: ${input.metadata.channel_name}`);
}
if (input.metadata?.author) {
metadataLines.push(`Author: ${input.metadata.author}`);
}
if (input.metadata?.site_name) {
metadataLines.push(`Site: ${input.metadata.site_name}`);
}
if (input.type) {
metadataLines.push(`Type: ${input.type}`);
}
const contentPreview = input.content?.slice(0, 500) || '';
return `Generate a concise description (max 280 characters) for this knowledge item.
CRITICAL REQUIREMENTS:
- Start with "This is a..."
- Identify the content type (article, video, paper, podcast episode, tweet, book, tutorial, etc.)
- Be specific about what the content covers
- Maximum 280 characters total
=== KNOWLEDGE ITEM ===
Title: ${input.title}
${metadataLines.length > 0 ? metadataLines.join('\n') : ''}
${contentPreview ? `\nContent preview: ${contentPreview}${input.content && input.content.length > 500 ? '...' : ''}` : ''}
=== YOUR RESPONSE ===
Write ONLY the description, nothing else. Start with "This is a..."`;
}
export const descriptionService = {
generateDescription
};
+22 -14
View File
@@ -53,6 +53,7 @@ export class DimensionService {
title: string; title: string;
content?: string; content?: string;
link?: string; link?: string;
description?: string;
}): Promise<{ locked: string[]; keywords: string[] }> { }): Promise<{ locked: string[]; keywords: string[] }> {
try { try {
const lockedDimensions = await this.getLockedDimensions(); const lockedDimensions = await this.getLockedDimensions();
@@ -69,7 +70,7 @@ export class DimensionService {
const response = await generateText({ const response = await generateText({
model: openaiProvider('gpt-4o-mini'), model: openaiProvider('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 150, // Increased for two-part response maxOutputTokens: 300, // Increased to accommodate more dimensions
temperature: 0.1, temperature: 0.1,
}); });
@@ -145,16 +146,29 @@ export class DimensionService {
* Build AI prompt for dimension assignment (locked + keyword dimensions) * Build AI prompt for dimension assignment (locked + keyword dimensions)
*/ */
private static buildAssignmentPrompt( private static buildAssignmentPrompt(
nodeData: { title: string; content?: string; link?: string }, nodeData: { title: string; content?: string; link?: string; description?: string },
lockedDimensions: LockedDimension[] lockedDimensions: LockedDimension[]
): string { ): string {
const contentPreview = nodeData.content?.slice(0, 1000) || ''; // Use description as primary context, content as fallback
let nodeContextSection: string;
if (nodeData.description) {
const contentPreview = nodeData.content?.slice(0, 500) || '';
nodeContextSection = `DESCRIPTION: ${nodeData.description}
// Only include dimensions that have descriptions CONTENT PREVIEW: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}`;
const dimensionsWithDescriptions = lockedDimensions.filter(d => d.description && d.description.trim().length > 0); } else {
const contentPreview = nodeData.content?.slice(0, 2000) || '';
nodeContextSection = `CONTENT: ${contentPreview}${nodeData.content && nodeData.content.length > 2000 ? '...' : ''}`;
}
const dimensionsList = dimensionsWithDescriptions // Include ALL locked dimensions, using fallback text for those without descriptions
.map(d => `DIMENSION: "${d.name}"\nDESCRIPTION: ${d.description}`) const dimensionsList = lockedDimensions
.map(d => {
const description = d.description && d.description.trim().length > 0
? d.description
: '(none - infer from name)';
return `DIMENSION: "${d.name}"\nDESCRIPTION: ${description}`;
})
.join('\n---\n'); .join('\n---\n');
return `You are categorizing a knowledge node. You will: return `You are categorizing a knowledge node. You will:
@@ -163,7 +177,7 @@ export class DimensionService {
=== NODE TO CATEGORIZE === === NODE TO CATEGORIZE ===
Title: ${nodeData.title} Title: ${nodeData.title}
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 1000 ? '...' : ''} ${nodeContextSection}
URL: ${nodeData.link || 'none'} URL: ${nodeData.link || 'none'}
=== PART 1: LOCKED DIMENSIONS === === PART 1: LOCKED DIMENSIONS ===
@@ -171,7 +185,6 @@ CRITICAL: Read each dimension's DESCRIPTION carefully.
The description defines what belongs in that dimension. The description defines what belongs in that dimension.
Only assign if the content CLEARLY matches the description. Only assign if the content CLEARLY matches the description.
If unsure, skip it better to miss than assign incorrectly. If unsure, skip it better to miss than assign incorrectly.
Maximum 3 locked dimensions.
AVAILABLE LOCKED DIMENSIONS: AVAILABLE LOCKED DIMENSIONS:
${dimensionsList} ${dimensionsList}
@@ -224,11 +237,6 @@ KEYWORDS:
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) { if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
lockedDimensions.push(matchedDimension.name); lockedDimensions.push(matchedDimension.name);
// Limit to 3 locked dimensions
if (lockedDimensions.length >= 3) {
break;
}
} }
} }
} }
+11 -9
View File
@@ -36,25 +36,27 @@ export class NodeService {
params.push(...dimensions); params.push(...dimensions);
} }
// Text search in title and content (SQLite LIKE with COLLATE NOCASE) // Text search in title, description, and content (SQLite LIKE with COLLATE NOCASE)
if (search) { if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`; query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`, `%${search}%`);
} }
// Sorting logic // Sorting logic
if (search) { if (search) {
// For search queries, prioritize by relevance: exact title → starts with → contains in title → content // For search queries, prioritize by relevance: exact title → starts with → contains in title → description → content
query += ` ORDER BY query += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 5 END, CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 5 END, CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 5 END, CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 4 ELSE 5 END, CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.content LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC`; n.updated_at DESC`;
params.push( params.push(
search, // Exact match (case-insensitive) search, // Exact match (case-insensitive)
`${search}%`, // Starts with search term `${search}%`, // Starts with search term
`%${search}%`, // Contains in title `%${search}%`, // Contains in title
`%${search}%`, // Contains in description
`%${search}%` // Contains in content `%${search}%` // Contains in content
); );
} else if (sortBy === 'edges') { } else if (sortBy === 'edges') {
+6 -4
View File
@@ -17,6 +17,7 @@ interface NodeRecord {
id: number; id: number;
title: string; title: string;
content: string | null; content: string | null;
description: string | null;
dimensions_json: string; dimensions_json: string;
embedding?: Buffer | null; embedding?: Buffer | null;
embedding_updated_at?: string | null; embedding_updated_at?: string | null;
@@ -106,7 +107,8 @@ Focus on the main concepts, key relationships, and practical implications.`;
let embeddingText = formatEmbeddingText( let embeddingText = formatEmbeddingText(
node.title, node.title,
node.content || '', node.content || '',
dimensions dimensions,
node.description
); );
// Add AI analysis if content exists // Add AI analysis if content exists
@@ -175,7 +177,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
if (nodeId) { if (nodeId) {
// Single node // Single node
query = ` query = `
SELECT n.id, n.title, n.content, SELECT n.id, n.title, n.content, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
@@ -186,7 +188,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else if (forceReEmbed) { } else if (forceReEmbed) {
// All nodes // All nodes
query = ` query = `
SELECT n.id, n.title, n.content, SELECT n.id, n.title, n.content, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
@@ -196,7 +198,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else { } else {
// Only nodes without embeddings // Only nodes without embeddings
query = ` query = `
SELECT n.id, n.title, n.content, SELECT n.id, n.title, n.content, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
+4 -2
View File
@@ -83,10 +83,12 @@ export function createDatabaseConnection(): Database.Database {
export function formatEmbeddingText( export function formatEmbeddingText(
title: string, title: string,
content: string, content: string,
dimensions: string[] dimensions: string[],
description?: string | null
): string { ): string {
const descriptionText = description && description.trim() ? description.trim() : 'none';
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none'; const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
return `Title: ${title}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`; return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
} }
/** /**
+3 -3
View File
@@ -2,10 +2,10 @@ import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
export const createDimensionTool = tool({ export const createDimensionTool = tool({
description: 'Create a new dimension or update existing dimension', description: 'Create a new dimension or update existing dimension. IMPORTANT: Always ask the user for a description explaining what belongs in this dimension before creating it. Dimensions without descriptions cannot be auto-assigned.',
inputSchema: z.object({ inputSchema: z.object({
name: z.string().describe('Dimension name'), name: z.string().describe('Dimension name'),
description: z.string().max(500).optional().describe('Dimension description (max 500 characters)'), description: z.string().min(1).max(500).describe('Dimension description explaining what content belongs in this dimension (required, max 500 characters)'),
isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)') isPriority: z.boolean().optional().describe('Whether to lock this dimension for auto-assignment (default: false)')
}), }),
execute: async (params) => { execute: async (params) => {
@@ -26,7 +26,7 @@ export const createDimensionTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: trimmedName, name: trimmedName,
description: params.description?.trim() || null, description: params.description.trim(),
isPriority: params.isPriority || false isPriority: params.isPriority || false
}) })
}); });
-159
View File
@@ -1,159 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { nodeService } from '@/services/database/nodes';
import { edgeService } from '@/services/database/edges';
/**
* Quick Link Tool - Creates edges to related nodes in ONE operation
* No agent loop, no LLM reasoning - just fast database operations
*/
export const quickLinkTool = tool({
description: 'Instantly find and link related nodes based on title/content matches. Fast - no AI reasoning, just database search and edge creation.',
inputSchema: z.object({
node_id: z.number().describe('The node ID to find connections for'),
max_edges: z.number().optional().default(3).describe('Maximum edges to create (default 3)'),
}),
execute: async (params) => {
const { node_id, max_edges = 3 } = params;
try {
// 1. Get the source node
const sourceNode = await nodeService.getNodeById(node_id);
if (!sourceNode) {
return { success: false, error: `Node ${node_id} not found`, edgesCreated: 0 };
}
// 2. Extract search terms from title (split on common separators)
const title = sourceNode.title || '';
const searchTerms = extractSearchTerms(title);
if (searchTerms.length === 0) {
return {
success: true,
message: 'No searchable terms found in title',
edgesCreated: 0,
searchTerms: []
};
}
// 3. Search for related nodes using each term
const foundNodeIds = new Set<number>();
const matchDetails: Array<{ nodeId: number; title: string; matchedTerm: string }> = [];
for (const term of searchTerms.slice(0, 3)) { // Max 3 search terms
const results = await nodeService.getNodes({
search: term,
limit: 10
});
for (const node of results) {
// Skip self and already found
if (node.id === node_id || foundNodeIds.has(node.id)) continue;
foundNodeIds.add(node.id);
matchDetails.push({
nodeId: node.id,
title: node.title || `Node ${node.id}`,
matchedTerm: term
});
}
}
// 4. Get existing edges to avoid duplicates
const existingConnections = await edgeService.getNodeConnections(node_id);
const existingTargets = new Set(existingConnections.map(c => c.connected_node.id));
// 5. Create edges for top matches (excluding existing)
const edgesToCreate = matchDetails
.filter(m => !existingTargets.has(m.nodeId))
.slice(0, max_edges);
const createdEdges: Array<{ toNodeId: number; toTitle: string }> = [];
for (const match of edgesToCreate) {
try {
await edgeService.createEdge({
from_node_id: node_id,
to_node_id: match.nodeId,
source: 'helper_name',
context: { quickLink: true, matchedTerm: match.matchedTerm }
});
createdEdges.push({ toNodeId: match.nodeId, toTitle: match.title });
} catch (err) {
// Edge might already exist, continue
console.warn(`[quickLink] Failed to create edge to ${match.nodeId}:`, err);
}
}
// 6. Return summary
const skippedCount = matchDetails.filter(m => existingTargets.has(m.nodeId)).length;
return {
success: true,
sourceNode: { id: node_id, title: sourceNode.title },
searchTerms,
edgesCreated: createdEdges.length,
edges: createdEdges,
skippedExisting: skippedCount,
totalMatches: matchDetails.length,
message: createdEdges.length > 0
? `Linked to ${createdEdges.length} nodes: ${createdEdges.map(e => e.toTitle).join(', ')}`
: skippedCount > 0
? `Found ${skippedCount} matches but edges already exist`
: `No related nodes found for: ${searchTerms.join(', ')}`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Quick link failed',
edgesCreated: 0
};
}
},
});
/**
* Extract meaningful search terms from a title
* Looks for: names, quoted phrases, capitalized words, etc.
*/
function extractSearchTerms(title: string): string[] {
const terms: string[] = [];
// 1. Extract quoted phrases
const quotedMatches = title.match(/["']([^"']+)["']/g);
if (quotedMatches) {
terms.push(...quotedMatches.map(m => m.replace(/["']/g, '').trim()));
}
// 2. Extract potential names (2-3 capitalized words in sequence)
const namePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g;
let match;
while ((match = namePattern.exec(title)) !== null) {
const name = match[1];
// Skip common non-name phrases
if (!isCommonPhrase(name)) {
terms.push(name);
}
}
// 3. Extract standalone capitalized words (potential company/project names)
const words = title.split(/\s+/);
for (const word of words) {
const cleaned = word.replace(/[^a-zA-Z0-9]/g, '');
if (cleaned.length > 2 && /^[A-Z]/.test(cleaned) && !terms.includes(cleaned)) {
terms.push(cleaned);
}
}
// Dedupe and limit
const unique = [...new Set(terms)].filter(t => t.length > 2);
return unique.slice(0, 5);
}
function isCommonPhrase(phrase: string): boolean {
const common = [
'The', 'How', 'What', 'Why', 'When', 'Where', 'Which',
'New', 'First', 'Last', 'Next', 'This', 'That',
'Part', 'Chapter', 'Section', 'Episode'
];
return common.some(c => phrase.startsWith(c + ' ') || phrase === c);
}
-1
View File
@@ -56,7 +56,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
updateNode: 'execution', updateNode: 'execution',
createEdge: 'execution', createEdge: 'execution',
updateEdge: 'execution', updateEdge: 'execution',
quickLink: 'execution',
embedContent: 'execution', embedContent: 'execution',
youtubeExtract: 'execution', youtubeExtract: 'execution',
websiteExtract: 'execution', websiteExtract: 'execution',
-4
View File
@@ -6,7 +6,6 @@ import { updateNodeTool } from '../database/updateNode';
import { createEdgeTool } from '../database/createEdge'; import { createEdgeTool } from '../database/createEdge';
import { queryEdgeTool } from '../database/queryEdge'; import { queryEdgeTool } from '../database/queryEdge';
import { updateEdgeTool } from '../database/updateEdge'; import { updateEdgeTool } from '../database/updateEdge';
import { quickLinkTool } from '../database/quickLink';
import { createDimensionTool } from '../database/createDimension'; import { createDimensionTool } from '../database/createDimension';
import { updateDimensionTool } from '../database/updateDimension'; import { updateDimensionTool } from '../database/updateDimension';
// lockDimension and unlockDimension consolidated into updateDimension (use isPriority param) // lockDimension and unlockDimension consolidated into updateDimension (use isPriority param)
@@ -56,7 +55,6 @@ const EXECUTION_TOOLS: Record<string, any> = {
updateNode: updateNodeTool, updateNode: updateNodeTool,
createEdge: createEdgeTool, createEdge: createEdgeTool,
updateEdge: updateEdgeTool, updateEdge: updateEdgeTool,
quickLink: quickLinkTool,
createDimension: createDimensionTool, createDimension: createDimensionTool,
updateDimension: updateDimensionTool, updateDimension: updateDimensionTool,
deleteDimension: deleteDimensionTool, deleteDimension: deleteDimensionTool,
@@ -89,7 +87,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
'updateNode', 'updateNode',
'createEdge', 'createEdge',
'updateEdge', 'updateEdge',
'quickLink',
'createDimension', 'createDimension',
'updateDimension', 'updateDimension',
'deleteDimension', 'deleteDimension',
@@ -114,7 +111,6 @@ const PLANNER_TOOL_NAMES = [
'think', 'think',
'updateNode', 'updateNode',
'createEdge', 'createEdge',
'quickLink',
'updateDimension', 'updateDimension',
]; ];
+5 -3
View File
@@ -173,10 +173,12 @@ export const paperExtractTool = tool({
console.log('🎯 PaperExtract completed successfully'); console.log('🎯 PaperExtract completed successfully');
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
const formattedNode = createResult.data?.id const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] }) ? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle; : nodeTitle;
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none'; const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
return { return {
success: true, success: true,
@@ -186,7 +188,7 @@ export const paperExtractTool = tool({
title: nodeTitle, title: nodeTitle,
contentLength: (result.chunk || result.content || '').length, contentLength: (result.chunk || result.content || '').length,
url: url, url: url,
dimensions: trimmedDimensions dimensions: actualDimensions
} }
}; };
} catch (error) { } catch (error) {
+5 -3
View File
@@ -163,10 +163,12 @@ export const websiteExtractTool = tool({
console.log('🎯 WebsiteExtract completed successfully'); console.log('🎯 WebsiteExtract completed successfully');
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
const formattedNode = createResult.data?.id const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] }) ? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle; : nodeTitle;
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none'; const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
return { return {
success: true, success: true,
@@ -176,7 +178,7 @@ export const websiteExtractTool = tool({
title: nodeTitle, title: nodeTitle,
contentLength: (result.chunk || result.content || '').length, contentLength: (result.chunk || result.content || '').length,
url: url, url: url,
dimensions: trimmedDimensions dimensions: actualDimensions
} }
}; };
} catch (error) { } catch (error) {
+5 -3
View File
@@ -225,10 +225,12 @@ export const youtubeExtractTool = tool({
console.log('🎯 YouTubeExtract completed successfully'); console.log('🎯 YouTubeExtract completed successfully');
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
const formattedNode = createResult.data?.id const formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: trimmedDimensions || [] }) ? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle; : nodeTitle;
const dimsDisplay = trimmedDimensions.length > 0 ? trimmedDimensions.join(', ') : 'none'; const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
return { return {
success: true, success: true,
@@ -238,7 +240,7 @@ export const youtubeExtractTool = tool({
title: nodeTitle, title: nodeTitle,
contentLength: (result.chunk || result.content || '').length, contentLength: (result.chunk || result.content || '').length,
url: url, url: url,
dimensions: trimmedDimensions dimensions: actualDimensions
} }
}; };
} catch (error) { } catch (error) {