sync: Phase 3 Agent Features from private repo
Features synced: - LucideIconPicker component (115 icons for dimension customization) - Dimension editing modal (descriptions + icons) - Compound filters (per-column AND filters) - Improved dimension assignment with keyword tagging - queryDimensions and getDimension agent tools New files: - src/components/common/LucideIconPicker.tsx - src/tools/database/queryDimensions.ts - src/tools/database/getDimension.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
57a20899a5
commit
c9d85998c1
@@ -66,15 +66,20 @@ export async function POST(request: NextRequest) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|
||||||
// Auto-assign locked dimensions for all new nodes
|
// Auto-assign locked dimensions + keyword dimensions for all new nodes
|
||||||
const autoAssignedDimensions = await DimensionService.assignLockedDimensions({
|
const { locked, keywords } = await DimensionService.assignDimensions({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
content: rawContent || undefined,
|
content: rawContent || undefined,
|
||||||
link: body.link
|
link: body.link
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine provided and auto-assigned dimensions, remove duplicates
|
// Ensure keyword dimensions exist in the database (create if new)
|
||||||
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...autoAssignedDimensions])]
|
for (const keyword of keywords) {
|
||||||
|
await DimensionService.ensureKeywordDimension(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine provided, locked, and keyword dimensions, remove duplicates
|
||||||
|
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...locked, ...keywords])]
|
||||||
.slice(0, 5); // Ensure we don't exceed 5 total dimensions
|
.slice(0, 5); // Ensure we don't exceed 5 total dimensions
|
||||||
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
|
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
|
||||||
let chunkToStore = rawChunk;
|
let chunkToStore = rawChunk;
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
// Core
|
||||||
|
Folder,
|
||||||
|
BookOpen,
|
||||||
|
Lightbulb,
|
||||||
|
Star,
|
||||||
|
Heart,
|
||||||
|
Briefcase,
|
||||||
|
Code,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Hash,
|
||||||
|
Inbox,
|
||||||
|
Layers,
|
||||||
|
Link,
|
||||||
|
MessageSquare,
|
||||||
|
Music,
|
||||||
|
Paperclip,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
Video,
|
||||||
|
Zap,
|
||||||
|
Archive,
|
||||||
|
Award,
|
||||||
|
Bell,
|
||||||
|
Bookmark,
|
||||||
|
Calendar,
|
||||||
|
Camera,
|
||||||
|
Clock,
|
||||||
|
Cloud,
|
||||||
|
Coffee,
|
||||||
|
Compass,
|
||||||
|
Database,
|
||||||
|
Flag,
|
||||||
|
Gift,
|
||||||
|
Home,
|
||||||
|
Image,
|
||||||
|
Key,
|
||||||
|
Mail,
|
||||||
|
Map,
|
||||||
|
Mic,
|
||||||
|
Package,
|
||||||
|
Smile,
|
||||||
|
Target,
|
||||||
|
// Thinking & Ideas
|
||||||
|
Brain,
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
FlaskConical,
|
||||||
|
Atom,
|
||||||
|
// Content & Media
|
||||||
|
Newspaper,
|
||||||
|
Podcast,
|
||||||
|
Youtube,
|
||||||
|
Twitter,
|
||||||
|
Rss,
|
||||||
|
Radio,
|
||||||
|
Tv,
|
||||||
|
Film,
|
||||||
|
// Work & Business
|
||||||
|
Rocket,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
Presentation,
|
||||||
|
LineChart,
|
||||||
|
BarChart3,
|
||||||
|
CircleDollarSign,
|
||||||
|
Wallet,
|
||||||
|
// People & Social
|
||||||
|
Users,
|
||||||
|
UserCircle,
|
||||||
|
Contact,
|
||||||
|
MessagesSquare,
|
||||||
|
Share2,
|
||||||
|
// Nature & Environment
|
||||||
|
Leaf,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Mountain,
|
||||||
|
Trees,
|
||||||
|
Flower2,
|
||||||
|
// Objects & Tools
|
||||||
|
Wrench,
|
||||||
|
Hammer,
|
||||||
|
Paintbrush,
|
||||||
|
Pencil,
|
||||||
|
Scissors,
|
||||||
|
Ruler,
|
||||||
|
// Tech & Development
|
||||||
|
Terminal,
|
||||||
|
GitBranch,
|
||||||
|
Bug,
|
||||||
|
Cpu,
|
||||||
|
Server,
|
||||||
|
Wifi,
|
||||||
|
Smartphone,
|
||||||
|
Laptop,
|
||||||
|
// Documents & Files
|
||||||
|
FileCode,
|
||||||
|
FilePlus,
|
||||||
|
FolderOpen,
|
||||||
|
ClipboardList,
|
||||||
|
ScrollText,
|
||||||
|
StickyNote,
|
||||||
|
// Health & Wellness
|
||||||
|
HeartPulse,
|
||||||
|
Activity,
|
||||||
|
Dumbbell,
|
||||||
|
Apple,
|
||||||
|
// Navigation & Location
|
||||||
|
Navigation,
|
||||||
|
MapPin,
|
||||||
|
Route,
|
||||||
|
Signpost,
|
||||||
|
// Misc Popular
|
||||||
|
Crown,
|
||||||
|
Diamond,
|
||||||
|
Gem,
|
||||||
|
Shield,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
ThumbsUp,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
HelpCircle,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipForward,
|
||||||
|
type LucideIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Static icon registry - no dynamic imports, works perfectly in Tauri WebView
|
||||||
|
export const ICON_REGISTRY: Record<string, LucideIcon> = {
|
||||||
|
// Core
|
||||||
|
Folder,
|
||||||
|
BookOpen,
|
||||||
|
Lightbulb,
|
||||||
|
Star,
|
||||||
|
Heart,
|
||||||
|
Briefcase,
|
||||||
|
Code,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Hash,
|
||||||
|
Inbox,
|
||||||
|
Layers,
|
||||||
|
Link,
|
||||||
|
MessageSquare,
|
||||||
|
Music,
|
||||||
|
Paperclip,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
Video,
|
||||||
|
Zap,
|
||||||
|
Archive,
|
||||||
|
Award,
|
||||||
|
Bell,
|
||||||
|
Bookmark,
|
||||||
|
Calendar,
|
||||||
|
Camera,
|
||||||
|
Clock,
|
||||||
|
Cloud,
|
||||||
|
Coffee,
|
||||||
|
Compass,
|
||||||
|
Database,
|
||||||
|
Flag,
|
||||||
|
Gift,
|
||||||
|
Home,
|
||||||
|
Image,
|
||||||
|
Key,
|
||||||
|
Mail,
|
||||||
|
Map,
|
||||||
|
Mic,
|
||||||
|
Package,
|
||||||
|
Smile,
|
||||||
|
Target,
|
||||||
|
// Thinking & Ideas
|
||||||
|
Brain,
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
FlaskConical,
|
||||||
|
Atom,
|
||||||
|
// Content & Media
|
||||||
|
Newspaper,
|
||||||
|
Podcast,
|
||||||
|
Youtube,
|
||||||
|
Twitter,
|
||||||
|
Rss,
|
||||||
|
Radio,
|
||||||
|
Tv,
|
||||||
|
Film,
|
||||||
|
// Work & Business
|
||||||
|
Rocket,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
Presentation,
|
||||||
|
LineChart,
|
||||||
|
BarChart3,
|
||||||
|
CircleDollarSign,
|
||||||
|
Wallet,
|
||||||
|
// People & Social
|
||||||
|
Users,
|
||||||
|
UserCircle,
|
||||||
|
Contact,
|
||||||
|
MessagesSquare,
|
||||||
|
Share2,
|
||||||
|
// Nature & Environment
|
||||||
|
Leaf,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Mountain,
|
||||||
|
Trees,
|
||||||
|
Flower2,
|
||||||
|
// Objects & Tools
|
||||||
|
Wrench,
|
||||||
|
Hammer,
|
||||||
|
Paintbrush,
|
||||||
|
Pencil,
|
||||||
|
Scissors,
|
||||||
|
Ruler,
|
||||||
|
// Tech & Development
|
||||||
|
Terminal,
|
||||||
|
GitBranch,
|
||||||
|
Bug,
|
||||||
|
Cpu,
|
||||||
|
Server,
|
||||||
|
Wifi,
|
||||||
|
Smartphone,
|
||||||
|
Laptop,
|
||||||
|
// Documents & Files
|
||||||
|
FileCode,
|
||||||
|
FilePlus,
|
||||||
|
FolderOpen,
|
||||||
|
ClipboardList,
|
||||||
|
ScrollText,
|
||||||
|
StickyNote,
|
||||||
|
// Health & Wellness
|
||||||
|
HeartPulse,
|
||||||
|
Activity,
|
||||||
|
Dumbbell,
|
||||||
|
Apple,
|
||||||
|
// Navigation & Location
|
||||||
|
Navigation,
|
||||||
|
MapPin,
|
||||||
|
Route,
|
||||||
|
Signpost,
|
||||||
|
// Misc Popular
|
||||||
|
Crown,
|
||||||
|
Diamond,
|
||||||
|
Gem,
|
||||||
|
Shield,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
ThumbsUp,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
HelpCircle,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipForward,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get icon component by name
|
||||||
|
export function getIconByName(name: string): LucideIcon {
|
||||||
|
return ICON_REGISTRY[name] || Folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render icon by name - useful for dynamic rendering
|
||||||
|
export function DynamicIcon({
|
||||||
|
name,
|
||||||
|
size = 16,
|
||||||
|
className,
|
||||||
|
style
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const IconComponent = getIconByName(name);
|
||||||
|
return <IconComponent size={size} className={className} style={style} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LucideIconPickerProps {
|
||||||
|
selectedIcon: string;
|
||||||
|
onSelect: (iconName: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LucideIconPicker({ selectedIcon, onSelect, onClose }: LucideIconPickerProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const iconNames = Object.keys(ICON_REGISTRY);
|
||||||
|
|
||||||
|
const filteredIcons = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return iconNames;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return iconNames.filter(name => name.toLowerCase().includes(query));
|
||||||
|
}, [searchQuery, iconNames]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
maxHeight: '300px'
|
||||||
|
}}>
|
||||||
|
{/* Search input */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '10px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search icons..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px 8px 32px',
|
||||||
|
background: '#111',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#e5e5e5',
|
||||||
|
fontSize: '13px',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#22c55e';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#333';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon grid */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(8, 1fr)',
|
||||||
|
gap: '4px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: '240px',
|
||||||
|
padding: '4px'
|
||||||
|
}}>
|
||||||
|
{filteredIcons.map((iconName) => {
|
||||||
|
const IconComponent = ICON_REGISTRY[iconName];
|
||||||
|
const isSelected = selectedIcon === iconName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iconName}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(iconName);
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
title={iconName}
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'transparent',
|
||||||
|
border: isSelected ? '1px solid #22c55e' : '1px solid transparent',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: isSelected ? '#22c55e' : '#888',
|
||||||
|
transition: 'all 0.15s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.background = '#1a1a1a';
|
||||||
|
e.currentTarget.style.color = '#e5e5e5';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.color = '#888';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredIcons.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}>
|
||||||
|
No icons match "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import AddNodeButton from './AddNodeButton';
|
|||||||
import Chip from '../common/Chip';
|
import Chip from '../common/Chip';
|
||||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||||
import SearchModal from './SearchModal';
|
import SearchModal from './SearchModal';
|
||||||
|
import { DynamicIcon } from '../common/LucideIconPicker';
|
||||||
|
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||||
|
|
||||||
interface NodesPanelProps {
|
interface NodesPanelProps {
|
||||||
selectedNodes: Set<number>;
|
selectedNodes: Set<number>;
|
||||||
@@ -47,6 +49,9 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated,
|
|||||||
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
|
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
|
||||||
const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null);
|
const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null);
|
||||||
|
|
||||||
|
// Dimension icons (shared with FolderViewOverlay via localStorage)
|
||||||
|
const [dimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNodes();
|
fetchNodes();
|
||||||
fetchLockedDimensions();
|
fetchLockedDimensions();
|
||||||
@@ -774,7 +779,13 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated,
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flex: 1 }}>
|
||||||
{isExpanded ? (
|
{dimensionIcons[lockedDim.dimension] ? (
|
||||||
|
<DynamicIcon
|
||||||
|
name={dimensionIcons[lockedDim.dimension]}
|
||||||
|
size={16}
|
||||||
|
style={{ color: isExpanded ? '#7de8a5' : '#64748b' }}
|
||||||
|
/>
|
||||||
|
) : isExpanded ? (
|
||||||
<FolderOpen size={16} color="#7de8a5" />
|
<FolderOpen size={16} color="#7de8a5" />
|
||||||
) : (
|
) : (
|
||||||
<Folder size={16} color="#64748b" />
|
<Folder size={16} color="#64748b" />
|
||||||
|
|||||||
@@ -46,39 +46,59 @@ export class DimensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatically assign locked dimensions to a node based on its content
|
* Automatically assign locked dimensions + suggest keyword dimensions
|
||||||
|
* Returns { locked: string[], keywords: string[] }
|
||||||
|
*/
|
||||||
|
static async assignDimensions(nodeData: {
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
link?: string;
|
||||||
|
}): Promise<{ locked: string[]; keywords: string[] }> {
|
||||||
|
try {
|
||||||
|
const lockedDimensions = await this.getLockedDimensions();
|
||||||
|
|
||||||
|
if (lockedDimensions.length === 0) {
|
||||||
|
console.log('[DimensionAssignment] No locked dimensions available');
|
||||||
|
return { locked: [], keywords: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = this.buildAssignmentPrompt(nodeData, lockedDimensions);
|
||||||
|
|
||||||
|
console.log(`[DimensionAssignment] Processing: "${nodeData.title}"`);
|
||||||
|
|
||||||
|
const response = await generateText({
|
||||||
|
model: openaiProvider('gpt-4o-mini'),
|
||||||
|
prompt,
|
||||||
|
maxOutputTokens: 150, // Increased for two-part response
|
||||||
|
temperature: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[DimensionAssignment] AI Response:\n${response.text}`);
|
||||||
|
|
||||||
|
const result = this.parseAssignmentResponse(response.text, lockedDimensions);
|
||||||
|
|
||||||
|
console.log(`[DimensionAssignment] Locked: ${result.locked.join(', ') || 'none'}`);
|
||||||
|
console.log(`[DimensionAssignment] Keywords: ${result.keywords.join(', ') || 'none'}`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DimensionAssignment] Error:', error);
|
||||||
|
return { locked: [], keywords: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy method for backwards compatibility
|
||||||
|
* @deprecated Use assignDimensions() instead
|
||||||
*/
|
*/
|
||||||
static async assignLockedDimensions(nodeData: {
|
static async assignLockedDimensions(nodeData: {
|
||||||
title: string;
|
title: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
try {
|
const result = await this.assignDimensions(nodeData);
|
||||||
const lockedDimensions = await this.getLockedDimensions();
|
return result.locked;
|
||||||
|
|
||||||
if (lockedDimensions.length === 0) {
|
|
||||||
console.log('No locked dimensions available for assignment');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = this.buildAssignmentPrompt(nodeData, lockedDimensions);
|
|
||||||
|
|
||||||
const response = await generateText({
|
|
||||||
model: openaiProvider('gpt-4o-mini'),
|
|
||||||
prompt,
|
|
||||||
maxOutputTokens: 100,
|
|
||||||
temperature: 0.1, // Low temperature for consistent results
|
|
||||||
});
|
|
||||||
|
|
||||||
const assignedDimensions = this.parseAssignmentResponse(response.text, lockedDimensions);
|
|
||||||
|
|
||||||
console.log(`Assigned dimensions for "${nodeData.title}": ${assignedDimensions.join(', ')}`);
|
|
||||||
return assignedDimensions;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error assigning dimensions:', error);
|
|
||||||
return []; // Graceful fallback
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,62 +142,79 @@ export class DimensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build AI prompt for dimension assignment
|
* 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 },
|
||||||
lockedDimensions: LockedDimension[]
|
lockedDimensions: LockedDimension[]
|
||||||
): string {
|
): string {
|
||||||
const contentPreview = nodeData.content?.slice(0, 500) || '';
|
const contentPreview = nodeData.content?.slice(0, 1000) || '';
|
||||||
const dimensionsList = lockedDimensions
|
|
||||||
.map(d => `- ${d.name}: ${d.description || `Infer purpose from name: ${d.name}`}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return `Analyze this node content and assign appropriate dimensions from the available locked dimensions.
|
// Only include dimensions that have descriptions
|
||||||
|
const dimensionsWithDescriptions = lockedDimensions.filter(d => d.description && d.description.trim().length > 0);
|
||||||
|
|
||||||
Node:
|
const dimensionsList = dimensionsWithDescriptions
|
||||||
|
.map(d => `DIMENSION: "${d.name}"\nDESCRIPTION: ${d.description}`)
|
||||||
|
.join('\n---\n');
|
||||||
|
|
||||||
|
return `You are categorizing a knowledge node. You will:
|
||||||
|
1. Assign LOCKED dimensions (from a provided list)
|
||||||
|
2. Suggest KEYWORD dimensions (to aid searchability)
|
||||||
|
|
||||||
|
=== NODE TO CATEGORIZE ===
|
||||||
Title: ${nodeData.title}
|
Title: ${nodeData.title}
|
||||||
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}
|
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 1000 ? '...' : ''}
|
||||||
URL: ${nodeData.link || 'none'}
|
URL: ${nodeData.link || 'none'}
|
||||||
|
|
||||||
Available Locked Dimensions:
|
=== PART 1: LOCKED DIMENSIONS ===
|
||||||
|
CRITICAL: Read each dimension's DESCRIPTION carefully.
|
||||||
|
The description defines what belongs in that dimension.
|
||||||
|
Only assign if the content CLEARLY matches the description.
|
||||||
|
If unsure, skip it — better to miss than assign incorrectly.
|
||||||
|
Maximum 3 locked dimensions.
|
||||||
|
|
||||||
|
AVAILABLE LOCKED DIMENSIONS:
|
||||||
${dimensionsList}
|
${dimensionsList}
|
||||||
|
|
||||||
Return only the dimension names that best match this content, maximum 3 dimensions.
|
=== PART 2: KEYWORD DIMENSIONS ===
|
||||||
Respond with just the dimension names, one per line.
|
Suggest 1-3 simple, lowercase keywords that:
|
||||||
If no dimensions are appropriate, respond with "none".
|
- Capture the ESSENCE of this content
|
||||||
|
- Make it easily SEARCHABLE
|
||||||
|
- Are thoughtful and aid organization
|
||||||
|
|
||||||
Examples of good responses:
|
Good keywords: ai, podcast, paper, productivity, machine-learning, book, video, interview, tutorial, framework
|
||||||
Work
|
If unsure what keywords fit, respond with "none" — no noise is better than bad tags.
|
||||||
Learning
|
|
||||||
|
|
||||||
or
|
=== RESPONSE FORMAT ===
|
||||||
|
LOCKED:
|
||||||
|
[dimension names from the list above, one per line, or "none"]
|
||||||
|
|
||||||
Research
|
KEYWORDS:
|
||||||
Ideas
|
[lowercase keyword suggestions, one per line, or "none"]`;
|
||||||
Tech
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
none`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse AI response and validate dimension names
|
* Parse AI response and extract locked + keyword dimensions
|
||||||
*/
|
*/
|
||||||
private static parseAssignmentResponse(
|
private static parseAssignmentResponse(
|
||||||
response: string,
|
response: string,
|
||||||
availableDimensions: LockedDimension[]
|
availableDimensions: LockedDimension[]
|
||||||
): string[] {
|
): { locked: string[]; keywords: string[] } {
|
||||||
const lines = response.trim().toLowerCase().split('\n');
|
const lockedDimensions: string[] = [];
|
||||||
const availableNames = availableDimensions.map(d => d.name.toLowerCase());
|
const keywordDimensions: string[] = [];
|
||||||
const validDimensions: string[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
// Split response into LOCKED and KEYWORDS sections
|
||||||
const dimensionName = line.trim();
|
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)(?=KEYWORDS:|$)/i);
|
||||||
|
const keywordsMatch = response.match(/KEYWORDS:\s*([\s\S]*?)$/i);
|
||||||
|
|
||||||
if (dimensionName === 'none') {
|
// Parse LOCKED section
|
||||||
break;
|
if (lockedMatch) {
|
||||||
|
const lockedLines = lockedMatch[1].trim().split('\n');
|
||||||
|
for (const line of lockedLines) {
|
||||||
|
const dimensionName = line.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (dimensionName === 'none' || dimensionName === '') {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching dimension (case-insensitive)
|
// Find matching dimension (case-insensitive)
|
||||||
@@ -185,17 +222,55 @@ none`;
|
|||||||
d => d.name.toLowerCase() === dimensionName
|
d => d.name.toLowerCase() === dimensionName
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchedDimension && !validDimensions.includes(matchedDimension.name)) {
|
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
|
||||||
validDimensions.push(matchedDimension.name);
|
lockedDimensions.push(matchedDimension.name);
|
||||||
|
|
||||||
// Limit to 3 dimensions max
|
// Limit to 3 locked dimensions
|
||||||
if (validDimensions.length >= 3) {
|
if (lockedDimensions.length >= 3) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return validDimensions;
|
// Parse KEYWORDS section
|
||||||
|
if (keywordsMatch) {
|
||||||
|
const keywordLines = keywordsMatch[1].trim().split('\n');
|
||||||
|
for (const line of keywordLines) {
|
||||||
|
// Clean and normalize keyword
|
||||||
|
const keyword = line.trim().toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '') // Only allow lowercase letters, numbers, hyphens
|
||||||
|
.slice(0, 30); // Max 30 chars per keyword
|
||||||
|
|
||||||
|
if (keyword === 'none' || keyword === '' || keyword.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keywordDimensions.includes(keyword)) {
|
||||||
|
keywordDimensions.push(keyword);
|
||||||
|
|
||||||
|
// Limit to 3 keywords
|
||||||
|
if (keywordDimensions.length >= 3) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locked: lockedDimensions, keywords: keywordDimensions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or get a keyword dimension (unlocked)
|
||||||
|
*/
|
||||||
|
static async ensureKeywordDimension(keyword: string): Promise<void> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
|
||||||
|
// INSERT OR IGNORE - if dimension exists, do nothing
|
||||||
|
sqlite.query(`
|
||||||
|
INSERT OR IGNORE INTO dimensions(name, description, is_priority, updated_at)
|
||||||
|
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
|
||||||
|
`, [keyword, null]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { DimensionService } from '@/services/database/dimensionService';
|
||||||
|
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||||
|
|
||||||
|
export const getDimensionTool = tool({
|
||||||
|
description: 'Get detailed information about a specific dimension by name, including its description, priority status, and node count.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
name: z.string().describe('The exact name of the dimension to retrieve')
|
||||||
|
}),
|
||||||
|
execute: async ({ name }) => {
|
||||||
|
console.log('📁 GetDimension tool called for:', name);
|
||||||
|
try {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Dimension name is required',
|
||||||
|
data: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dimension details from service
|
||||||
|
const dimension = await DimensionService.getDimensionByName(trimmedName);
|
||||||
|
|
||||||
|
// Get node count for this dimension
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const countResult = sqlite.query(`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM node_dimensions
|
||||||
|
WHERE dimension = ?
|
||||||
|
`, [trimmedName]);
|
||||||
|
|
||||||
|
const nodeCount = countResult.rows.length > 0
|
||||||
|
? Number((countResult.rows[0] as { count: number }).count)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (!dimension) {
|
||||||
|
// Dimension might exist in node_dimensions but not in dimensions table
|
||||||
|
if (nodeCount > 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
name: trimmedName,
|
||||||
|
description: null,
|
||||||
|
isPriority: false,
|
||||||
|
nodeCount,
|
||||||
|
exists: true,
|
||||||
|
hasMetadata: false
|
||||||
|
},
|
||||||
|
message: `Dimension "${trimmedName}" exists with ${nodeCount} nodes but has no metadata (description/priority not set).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Dimension "${trimmedName}" not found`,
|
||||||
|
data: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
name: dimension.name,
|
||||||
|
description: dimension.description,
|
||||||
|
isPriority: dimension.is_priority,
|
||||||
|
nodeCount,
|
||||||
|
updatedAt: dimension.updated_at,
|
||||||
|
exists: true,
|
||||||
|
hasMetadata: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build descriptive message
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`Dimension: ${result.name}`);
|
||||||
|
if (result.isPriority) parts.push('Status: 🔒 Priority (locked)');
|
||||||
|
parts.push(`Nodes: ${result.nodeCount}`);
|
||||||
|
if (result.description) parts.push(`Description: ${result.description}`);
|
||||||
|
parts.push(`Last updated: ${result.updatedAt}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: parts.join('\n')
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GetDimension tool error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to get dimension',
|
||||||
|
data: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const queryDimensionsTool = tool({
|
||||||
|
description: 'Query dimensions with optional filtering by name, priority status, or search term. Returns dimensions with their node counts.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
filters: z.object({
|
||||||
|
search: z.string().describe('Search term to match against dimension names').optional(),
|
||||||
|
isPriority: z.boolean().describe('Filter by priority (locked) status').optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(50).describe('Maximum number of results to return')
|
||||||
|
}).optional()
|
||||||
|
}),
|
||||||
|
execute: async ({ filters = {} }) => {
|
||||||
|
console.log('📁 QueryDimensions tool called with filters:', JSON.stringify(filters, null, 2));
|
||||||
|
try {
|
||||||
|
const limit = filters.limit || 50;
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Use existing API endpoint for dimension listing
|
||||||
|
const response = await fetch(`${baseUrl}/api/dimensions/popular`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = 'Failed to query dimensions';
|
||||||
|
try {
|
||||||
|
const errorResult = await response.json();
|
||||||
|
errorMessage = errorResult.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Failed to query dimensions: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
data: { dimensions: [], count: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid response from dimensions API',
|
||||||
|
data: { dimensions: [], count: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
let dimensions = result.data as Array<{
|
||||||
|
dimension: string;
|
||||||
|
count: number;
|
||||||
|
isPriority: boolean;
|
||||||
|
description: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
dimensions = dimensions.filter(d =>
|
||||||
|
d.dimension.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority status
|
||||||
|
if (filters.isPriority !== undefined) {
|
||||||
|
dimensions = dimensions.filter(d => d.isPriority === filters.isPriority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
const limitedDimensions = dimensions.slice(0, limit);
|
||||||
|
|
||||||
|
// Format for display
|
||||||
|
const formattedDimensions = limitedDimensions.map(d => ({
|
||||||
|
name: d.dimension,
|
||||||
|
count: d.count,
|
||||||
|
isPriority: d.isPriority,
|
||||||
|
description: d.description
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Build message
|
||||||
|
const filterParts: string[] = [];
|
||||||
|
if (filters.search) filterParts.push(`matching "${filters.search}"`);
|
||||||
|
if (filters.isPriority !== undefined) filterParts.push(filters.isPriority ? 'priority only' : 'non-priority only');
|
||||||
|
const filterDesc = filterParts.length > 0 ? ` (${filterParts.join(', ')})` : '';
|
||||||
|
|
||||||
|
const dimensionList = formattedDimensions
|
||||||
|
.map(d => `• ${d.name}${d.isPriority ? ' 🔒' : ''} (${d.count} nodes)${d.description ? ` - ${d.description}` : ''}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
dimensions: formattedDimensions,
|
||||||
|
count: formattedDimensions.length,
|
||||||
|
total_available: dimensions.length,
|
||||||
|
filters_applied: filters
|
||||||
|
},
|
||||||
|
message: `Found ${formattedDimensions.length} dimensions${filterDesc}:\n${dimensionList || 'No dimensions found'}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QueryDimensions tool error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to query dimensions',
|
||||||
|
data: { dimensions: [], count: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,6 +11,8 @@ import { updateDimensionTool } from '../database/updateDimension';
|
|||||||
import { lockDimensionTool } from '../database/lockDimension';
|
import { lockDimensionTool } from '../database/lockDimension';
|
||||||
import { unlockDimensionTool } from '../database/unlockDimension';
|
import { unlockDimensionTool } from '../database/unlockDimension';
|
||||||
import { deleteDimensionTool } from '../database/deleteDimension';
|
import { deleteDimensionTool } from '../database/deleteDimension';
|
||||||
|
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||||
|
import { getDimensionTool } from '../database/getDimension';
|
||||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||||
import { webSearchTool } from '../other/webSearch';
|
import { webSearchTool } from '../other/webSearch';
|
||||||
import { thinkTool } from '../other/think';
|
import { thinkTool } from '../other/think';
|
||||||
@@ -27,6 +29,8 @@ const CORE_TOOLS: Record<string, any> = {
|
|||||||
queryNodes: queryNodesTool,
|
queryNodes: queryNodesTool,
|
||||||
getNodesById: getNodesByIdTool,
|
getNodesById: getNodesByIdTool,
|
||||||
queryEdge: queryEdgeTool,
|
queryEdge: queryEdgeTool,
|
||||||
|
queryDimensions: queryDimensionsTool,
|
||||||
|
getDimension: getDimensionTool,
|
||||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user