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:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
|
||||
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
|
||||
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Description regeneration state
|
||||
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
|
||||
|
||||
// Fetch priority dimensions on mount
|
||||
useEffect(() => {
|
||||
fetchPriorityDimensions();
|
||||
@@ -387,6 +390,31 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
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 ---
|
||||
const [mentionActive, setMentionActive] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
@@ -814,7 +842,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
flexShrink: 0,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||
}}>
|
||||
#{suggestion.id}
|
||||
{suggestion.id}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '15px',
|
||||
@@ -1484,7 +1512,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
}}
|
||||
title="Drag to chat to reference this node"
|
||||
>
|
||||
#{activeTab}
|
||||
{activeTab}
|
||||
</span>
|
||||
|
||||
{editingField === 'title' ? (
|
||||
@@ -1559,10 +1587,119 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
|
||||
</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={{
|
||||
<div style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
position: 'relative'
|
||||
@@ -1687,7 +1824,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
onMouseDown={(e) => { e.preventDefault(); replaceMentionWithToken(n.id, n.title); }}
|
||||
onMouseEnter={() => setMentionIndex(idx)}
|
||||
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>
|
||||
</div>
|
||||
))
|
||||
@@ -2023,7 +2160,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
flexShrink: 0,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||
}}>
|
||||
#{suggestion.id}
|
||||
{suggestion.id}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
@@ -2123,7 +2260,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
flexShrink: 0,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||||
}}>
|
||||
#{connection.connected_node.id}
|
||||
{connection.connected_node.id}
|
||||
</span>
|
||||
<span
|
||||
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPortal } from 'react-dom';
|
||||
interface DimensionSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDimensionSelect: (dimension: string) => void;
|
||||
onDimensionSelect: (dimension: string, description?: string) => void;
|
||||
existingDimensions: string[];
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ export default function DimensionSearchModal({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [newDimensionDescription, setNewDimensionDescription] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -95,31 +97,35 @@ export default function DimensionSearchModal({
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Track if we're in "create new" mode to avoid re-fetching
|
||||
const isCreatingNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
|
||||
|
||||
// Fetch dimension suggestions
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular?limit=50');
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (result.success) {
|
||||
const allDimensions: DimensionSuggestion[] = result.data;
|
||||
|
||||
|
||||
// Filter based on search query and exclude existing dimensions
|
||||
const filtered = allDimensions.filter(dim => {
|
||||
const matchesQuery = !searchQuery.trim() ||
|
||||
const matchesQuery = !searchQuery.trim() ||
|
||||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const notExisting = !existingDimensions.includes(dim.dimension);
|
||||
return matchesQuery && notExisting;
|
||||
});
|
||||
|
||||
|
||||
// Sort: priority first, then by count
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
|
||||
setSuggestions(sorted.slice(0, 20));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
@@ -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);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [searchQuery, existingDimensions, isOpen]);
|
||||
}, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -145,20 +153,21 @@ export default function DimensionSearchModal({
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (suggestions[selectedIndex]) {
|
||||
// Select existing dimension
|
||||
handleSelectDimension(suggestions[selectedIndex].dimension);
|
||||
} else if (searchQuery.trim()) {
|
||||
// Create new dimension
|
||||
handleSelectDimension(searchQuery.trim());
|
||||
// Create new dimension with description
|
||||
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDimension = (dimension: string) => {
|
||||
onDimensionSelect(dimension);
|
||||
const handleSelectDimension = (dimension: string, description?: string) => {
|
||||
onDimensionSelect(dimension, description);
|
||||
setSearchQuery('');
|
||||
setNewDimensionDescription('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
@@ -182,7 +191,7 @@ export default function DimensionSearchModal({
|
||||
aria-modal="true"
|
||||
aria-label="Search dimensions"
|
||||
>
|
||||
<div ref={modalRef} className="search-container">
|
||||
<div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Search Input */}
|
||||
<div className="search-input-wrapper">
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
@@ -228,14 +237,41 @@ export default function DimensionSearchModal({
|
||||
|
||||
{/* Create New Option */}
|
||||
{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 "{searchQuery.trim()}"</span>
|
||||
</div>
|
||||
<div className="description-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={newDimensionDescription}
|
||||
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Describe what belongs in this dimension..."
|
||||
className="description-input"
|
||||
rows={2}
|
||||
/>
|
||||
<span className="description-counter">{newDimensionDescription.length}/500</span>
|
||||
</div>
|
||||
{!newDimensionDescription.trim() && (
|
||||
<div className="description-warning">
|
||||
Dimensions without descriptions may not auto-assign correctly
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSelectDimension(searchQuery.trim())}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
||||
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||
>
|
||||
<span className="create-icon">+</span>
|
||||
Create "{searchQuery.trim()}"
|
||||
Create Dimension
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -385,35 +421,98 @@ export default function DimensionSearchModal({
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
padding: 16px 20px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
|
||||
.create-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.create-title {
|
||||
color: #22c55e;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.description-input:focus {
|
||||
border-color: #525252;
|
||||
}
|
||||
|
||||
.description-input::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.description-counter {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.description-warning {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #eab308;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: #22c55e;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.create-button:hover,
|
||||
.create-button.selected {
|
||||
background: #1a1a1a;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
|
||||
.create-icon {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
|
||||
Reference in New Issue
Block a user