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)
|
||||
.slice(0, 5);
|
||||
|
||||
// Auto-assign locked dimensions for all new nodes
|
||||
const autoAssignedDimensions = await DimensionService.assignLockedDimensions({
|
||||
// Auto-assign locked dimensions + keyword dimensions for all new nodes
|
||||
const { locked, keywords } = await DimensionService.assignDimensions({
|
||||
title: body.title,
|
||||
content: rawContent || undefined,
|
||||
link: body.link
|
||||
});
|
||||
|
||||
// Combine provided and auto-assigned dimensions, remove duplicates
|
||||
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...autoAssignedDimensions])]
|
||||
// Ensure keyword dimensions exist in the database (create if new)
|
||||
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
|
||||
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
|
||||
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 { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import SearchModal from './SearchModal';
|
||||
import { DynamicIcon } from '../common/LucideIconPicker';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
|
||||
interface NodesPanelProps {
|
||||
selectedNodes: Set<number>;
|
||||
@@ -47,6 +49,9 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated,
|
||||
const [dragHoverDimension, setDragHoverDimension] = useState<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(() => {
|
||||
fetchNodes();
|
||||
fetchLockedDimensions();
|
||||
@@ -774,7 +779,13 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated,
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
) : (
|
||||
<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: {
|
||||
title: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
}): Promise<string[]> {
|
||||
try {
|
||||
const lockedDimensions = await this.getLockedDimensions();
|
||||
|
||||
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
|
||||
}
|
||||
const result = await this.assignDimensions(nodeData);
|
||||
return result.locked;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,80 +142,135 @@ export class DimensionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AI prompt for dimension assignment
|
||||
* Build AI prompt for dimension assignment (locked + keyword dimensions)
|
||||
*/
|
||||
private static buildAssignmentPrompt(
|
||||
nodeData: { title: string; content?: string; link?: string },
|
||||
lockedDimensions: LockedDimension[]
|
||||
): string {
|
||||
const contentPreview = nodeData.content?.slice(0, 500) || '';
|
||||
const dimensionsList = lockedDimensions
|
||||
.map(d => `- ${d.name}: ${d.description || `Infer purpose from name: ${d.name}`}`)
|
||||
.join('\n');
|
||||
const contentPreview = nodeData.content?.slice(0, 1000) || '';
|
||||
|
||||
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}
|
||||
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 500 ? '...' : ''}
|
||||
Content: ${contentPreview}${nodeData.content && nodeData.content.length > 1000 ? '...' : ''}
|
||||
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}
|
||||
|
||||
Return only the dimension names that best match this content, maximum 3 dimensions.
|
||||
Respond with just the dimension names, one per line.
|
||||
If no dimensions are appropriate, respond with "none".
|
||||
=== PART 2: KEYWORD DIMENSIONS ===
|
||||
Suggest 1-3 simple, lowercase keywords that:
|
||||
- Capture the ESSENCE of this content
|
||||
- Make it easily SEARCHABLE
|
||||
- Are thoughtful and aid organization
|
||||
|
||||
Examples of good responses:
|
||||
Work
|
||||
Learning
|
||||
Good keywords: ai, podcast, paper, productivity, machine-learning, book, video, interview, tutorial, framework
|
||||
If unsure what keywords fit, respond with "none" — no noise is better than bad tags.
|
||||
|
||||
or
|
||||
=== RESPONSE FORMAT ===
|
||||
LOCKED:
|
||||
[dimension names from the list above, one per line, or "none"]
|
||||
|
||||
Research
|
||||
Ideas
|
||||
Tech
|
||||
|
||||
or
|
||||
|
||||
none`;
|
||||
KEYWORDS:
|
||||
[lowercase keyword suggestions, one per line, or "none"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response and validate dimension names
|
||||
* Parse AI response and extract locked + keyword dimensions
|
||||
*/
|
||||
private static parseAssignmentResponse(
|
||||
response: string,
|
||||
response: string,
|
||||
availableDimensions: LockedDimension[]
|
||||
): string[] {
|
||||
const lines = response.trim().toLowerCase().split('\n');
|
||||
const availableNames = availableDimensions.map(d => d.name.toLowerCase());
|
||||
const validDimensions: string[] = [];
|
||||
): { locked: string[]; keywords: string[] } {
|
||||
const lockedDimensions: string[] = [];
|
||||
const keywordDimensions: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const dimensionName = line.trim();
|
||||
|
||||
if (dimensionName === 'none') {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find matching dimension (case-insensitive)
|
||||
const matchedDimension = availableDimensions.find(
|
||||
d => d.name.toLowerCase() === dimensionName
|
||||
);
|
||||
|
||||
if (matchedDimension && !validDimensions.includes(matchedDimension.name)) {
|
||||
validDimensions.push(matchedDimension.name);
|
||||
|
||||
// Limit to 3 dimensions max
|
||||
if (validDimensions.length >= 3) {
|
||||
break;
|
||||
// Split response into LOCKED and KEYWORDS sections
|
||||
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)(?=KEYWORDS:|$)/i);
|
||||
const keywordsMatch = response.match(/KEYWORDS:\s*([\s\S]*?)$/i);
|
||||
|
||||
// Parse LOCKED section
|
||||
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)
|
||||
const matchedDimension = availableDimensions.find(
|
||||
d => d.name.toLowerCase() === dimensionName
|
||||
);
|
||||
|
||||
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
|
||||
lockedDimensions.push(matchedDimension.name);
|
||||
|
||||
// Limit to 3 locked dimensions
|
||||
if (lockedDimensions.length >= 3) {
|
||||
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 { unlockDimensionTool } from '../database/unlockDimension';
|
||||
import { deleteDimensionTool } from '../database/deleteDimension';
|
||||
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||
import { getDimensionTool } from '../database/getDimension';
|
||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||
import { webSearchTool } from '../other/webSearch';
|
||||
import { thinkTool } from '../other/think';
|
||||
@@ -27,6 +29,8 @@ const CORE_TOOLS: Record<string, any> = {
|
||||
queryNodes: queryNodesTool,
|
||||
getNodesById: getNodesByIdTool,
|
||||
queryEdge: queryEdgeTool,
|
||||
queryDimensions: queryDimensionsTool,
|
||||
getDimension: getDimensionTool,
|
||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user