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
+2 -2
View File
@@ -168,12 +168,12 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
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 {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode })
body: JSON.stringify({ input, mode, description })
});
if (!response.ok) {
+55 -2
View File
@@ -9,6 +9,7 @@ type QuickAddIntent = 'link' | 'note' | 'chat';
interface QuickAddSubmitPayload {
input: string;
mode: QuickAddIntent;
description?: string;
}
interface QuickAddInputProps {
@@ -60,6 +61,7 @@ const MODE_CONFIG: Array<{
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);
@@ -108,8 +110,13 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
setIsPosting(true);
try {
await onSubmit({ input: input.trim(), mode: effectiveMode });
await onSubmit({
input: input.trim(),
mode: effectiveMode,
description: description.trim() || undefined
});
setInput('');
setDescription('');
setManualMode(null);
setAutoMode('link');
setIsExpanded(false);
@@ -165,6 +172,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
e.currentTarget.style.background = 'transparent';
}}
>
CAPTURE
<span style={{
width: '22px',
height: '22px',
@@ -177,7 +185,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
fontSize: '14px',
fontWeight: 700
}}>+</span>
CAPTURE
</button>
);
}
@@ -243,6 +250,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
onClick={() => {
setIsExpanded(false);
setInput('');
setDescription('');
}}
style={{
marginLeft: 'auto',
@@ -299,6 +307,51 @@ 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' }}>
+145 -8
View File
@@ -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 &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
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 {
+4 -4
View File
@@ -101,8 +101,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div>
</div>
{/* Content Preview */}
{node.content && (
{/* Description or Content Preview */}
{(node.description || node.content) && (
<div style={{
flex: 1,
fontSize: '11px',
@@ -110,11 +110,11 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
lineHeight: '1.5',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
marginBottom: '10px'
}}>
{truncateContent(node.content)}
{node.description || truncateContent(node.content)}
</div>
)}
+3 -3
View File
@@ -102,8 +102,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
{node.title || 'Untitled'}
</div>
{/* Content Preview */}
{node.content && (
{/* Description or Content Preview */}
{(node.description || node.content) && (
<div style={{
fontSize: '12px',
color: '#666',
@@ -114,7 +114,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{truncateContent(node.content)}
{node.description || truncateContent(node.content)}
</div>
)}