diff --git a/archon-ui-main/src/components/knowledge-base/EditKnowledgeItemModal.tsx b/archon-ui-main/src/components/knowledge-base/EditKnowledgeItemModal.tsx index 242cce0463..7422b84686 100644 --- a/archon-ui-main/src/components/knowledge-base/EditKnowledgeItemModal.tsx +++ b/archon-ui-main/src/components/knowledge-base/EditKnowledgeItemModal.tsx @@ -8,6 +8,7 @@ import { Card } from '../ui/Card'; import { KnowledgeItem } from '../../services/knowledgeBaseService'; import { knowledgeBaseService } from '../../services/knowledgeBaseService'; import { useToast } from '../../contexts/ToastContext'; +import { EditableTags } from './EditableTags'; interface EditKnowledgeItemModalProps { item: KnowledgeItem; @@ -26,7 +27,9 @@ export const EditKnowledgeItemModal: React.FC = ({ const [formData, setFormData] = useState({ title: item.title, description: item.metadata?.description || '', + tags: item.metadata?.tags || [], }); + const [isUpdatingTags, setIsUpdatingTags] = useState(false); const isInGroup = Boolean(item.metadata?.group_name); @@ -62,6 +65,15 @@ export const EditKnowledgeItemModal: React.FC = ({ if (formData.description !== (item.metadata?.description || '')) { updates.description = formData.description; } + + // Only include tags if they have changed (using immutable comparison) + const originalTags = item.metadata?.tags || []; + const sortedFormTags = [...formData.tags].sort(); + const sortedOriginalTags = [...originalTags].sort(); + const tagsChanged = JSON.stringify(sortedFormTags) !== JSON.stringify(sortedOriginalTags); + if (tagsChanged) { + updates.tags = formData.tags; + } await knowledgeBaseService.updateKnowledgeItem(item.source_id, updates); @@ -76,6 +88,27 @@ export const EditKnowledgeItemModal: React.FC = ({ } }; + const handleTagsUpdate = async (tags: string[]) => { + setIsUpdatingTags(true); + try { + setFormData(prev => ({ ...prev, tags })); + } catch (error) { + const errorMessage = error instanceof Error + ? `Failed to update tags: ${error.message}` + : 'Failed to update tags: Unknown error occurred'; + + console.error('Tag update error:', error); + showToast(errorMessage, 'error'); + throw error; + } finally { + setIsUpdatingTags(false); + } + }; + + const handleTagError = (error: string) => { + showToast(error, 'error'); + }; + const handleRemoveFromGroup = async () => { if (!isInGroup) return; @@ -187,6 +220,22 @@ export const EditKnowledgeItemModal: React.FC = ({ + {/* Tags field */} +
+ +
+ +
+
+ {/* Group info and remove button */} {isInGroup && (
diff --git a/archon-ui-main/src/components/knowledge-base/EditableTags.tsx b/archon-ui-main/src/components/knowledge-base/EditableTags.tsx new file mode 100644 index 0000000000..ff314c6f5a --- /dev/null +++ b/archon-ui-main/src/components/knowledge-base/EditableTags.tsx @@ -0,0 +1,391 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Plus } from 'lucide-react'; +import { Badge } from '../ui/Badge'; +import { Input } from '../../features/ui/primitives/input'; +import { cn } from '../../lib/utils'; + +// Validation constants +const MAX_TAG_LENGTH = 50; +const MAX_TAGS = 20; + +interface EditableTagsProps { + tags: string[]; + onTagsUpdate: (tags: string[]) => Promise; + maxVisibleTags?: number; + className?: string; + isUpdating?: boolean; + onError?: (error: string) => void; +} + +export const EditableTags: React.FC = ({ + tags = [], + onTagsUpdate, + maxVisibleTags = 4, + className, + isUpdating = false, + onError, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [newTagValue, setNewTagValue] = useState(''); + const [localTags, setLocalTags] = useState(tags); + const [isSaving, setIsSaving] = useState(false); + const [showTooltip, setShowTooltip] = useState(false); + const [validationError, setValidationError] = useState(null); + + const inputRef = useRef(null); + const addInputRef = useRef(null); + const tooltipRef = useRef(null); + const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null); + // Prevent concurrent save operations + const saveInProgress = useRef(false); + + // Update local tags when props change + useEffect(() => { + setLocalTags(tags); + }, [tags]); + + // Focus input when editing starts + useEffect(() => { + if (isEditing && editingIndex !== null && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing, editingIndex]); + + // Focus add input when adding + useEffect(() => { + if (isEditing && editingIndex === null && addInputRef.current) { + addInputRef.current.focus(); + } + }, [isEditing, editingIndex]); + + const validateTag = (tag: string): { isValid: boolean; error?: string } => { + const trimmedTag = tag.trim(); + + if (!trimmedTag) { + return { isValid: false, error: 'Tag cannot be empty' }; + } + + if (trimmedTag.length > MAX_TAG_LENGTH) { + return { isValid: false, error: `Tag must be ${MAX_TAG_LENGTH} characters or less` }; + } + + if (localTags.includes(trimmedTag)) { + return { isValid: false, error: 'Tag already exists' }; + } + + if (localTags.length >= MAX_TAGS) { + return { isValid: false, error: `Maximum of ${MAX_TAGS} tags allowed` }; + } + + return { isValid: true }; + }; + + const saveChanges = async (tagsToSave?: string[]) => { + if (isSaving || saveInProgress.current) return; + + const finalTags = tagsToSave || localTags; + saveInProgress.current = true; + setIsSaving(true); + setValidationError(null); + + try { + await onTagsUpdate(finalTags); + setIsEditing(false); + setEditingIndex(null); + setNewTagValue(''); + } catch (error) { + const errorMessage = error instanceof Error + ? `Failed to save tags: ${error.message}` + : 'Failed to save tags: Unknown error occurred'; + + console.error('Tag save error:', error); + setValidationError(errorMessage); + + // Notify parent component of error + if (onError) { + onError(errorMessage); + } + + // Reset local tags to last known good state + setLocalTags(tags); + throw error; // Re-throw to allow caller to handle + } finally { + setIsSaving(false); + saveInProgress.current = false; + } + }; + + const handleTagEdit = async (index: number, newValue: string) => { + const trimmedValue = newValue.trim(); + setValidationError(null); + + if (!trimmedValue) { + // Remove tag if empty + const updatedTags = localTags.filter((_, i) => i !== index); + setLocalTags(updatedTags); + try { + await saveChanges(updatedTags); + } catch (error) { + // Error already handled in saveChanges + } + return; + } + + // Check if tag changed + if (trimmedValue === localTags[index]) { + setIsEditing(false); + setEditingIndex(null); + return; + } + + // Validate against other tags (excluding current index) + const otherTags = localTags.filter((_, i) => i !== index); + + if (otherTags.includes(trimmedValue)) { + setValidationError('Tag already exists'); + return; + } + + if (trimmedValue.length > MAX_TAG_LENGTH) { + setValidationError(`Tag must be ${MAX_TAG_LENGTH} characters or less`); + return; + } + + const updatedTags = [...localTags]; + updatedTags[index] = trimmedValue; + setLocalTags(updatedTags); + + try { + await saveChanges(updatedTags); + } catch (error) { + // Error already handled in saveChanges + } + }; + + const handleTagAdd = async () => { + const trimmedValue = newTagValue.trim(); + setValidationError(null); + + const validation = validateTag(trimmedValue); + + if (!validation.isValid) { + setValidationError(validation.error || 'Invalid tag'); + return; + } + + const updatedTags = [...localTags, trimmedValue]; + setLocalTags(updatedTags); + setNewTagValue(''); + + try { + await saveChanges(updatedTags); + } catch (error) { + // Error already handled in saveChanges + setNewTagValue(trimmedValue); // Restore the value for user to see + } + }; + + const handleTagRemove = async (index: number) => { + const updatedTags = localTags.filter((_, i) => i !== index); + setLocalTags(updatedTags); + try { + await saveChanges(updatedTags); + } catch (error) { + // Error already handled in saveChanges + } + }; + + const handleCancel = () => { + setLocalTags(tags); + setIsEditing(false); + setEditingIndex(null); + setNewTagValue(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent, index?: number) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (index !== undefined) { + handleTagEdit(index, (e.target as HTMLInputElement).value); + } else { + handleTagAdd(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } else if (e.key === 'Tab' && index !== undefined) { + // Allow natural tab behavior + setEditingIndex(null); + } + }; + + if (localTags.length === 0 && !isEditing) { + return ( +
+ +
+ ); + } + + const visibleTags = localTags.slice(0, maxVisibleTags); + const remainingTags = localTags.slice(maxVisibleTags); + const hasMoreTags = remainingTags.length > 0; + + return ( +
+
+ {visibleTags.map((tag, index) => ( +
+ {isEditing && editingIndex === index ? ( + handleTagEdit(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, index)} + disabled={isSaving} + className={cn( + 'h-6 text-xs px-2 py-0 w-20 min-w-[60px]', + 'border-cyan-400 dark:border-cyan-600', + 'focus:ring-1 focus:ring-cyan-400', + )} + /> + ) : ( + { + if (!isUpdating && !isSaving) { + setIsEditing(true); + setEditingIndex(index); + } + }} + > + {tag} + + + )} +
+ ))} + + {/* Add new tag input */} + {isEditing && editingIndex === null && ( + setNewTagValue(e.target.value)} + onBlur={handleTagAdd} + onKeyDown={(e) => handleKeyDown(e)} + placeholder="New tag" + disabled={isSaving} + className={cn( + 'h-6 text-xs px-2 py-0 w-20 min-w-[60px]', + 'border-cyan-400 dark:border-cyan-600', + 'focus:ring-1 focus:ring-cyan-400', + )} + /> + )} + + {/* Add button */} + {!isEditing && ( + + )} + + {/* More tags tooltip */} + {hasMoreTags && ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPosition({ + x: rect.left + rect.width / 2, + y: rect.bottom + 8 + }); + setShowTooltip(true); + }} + onMouseLeave={() => { + setShowTooltip(false); + setTooltipPosition(null); + }} + > + + +{remainingTags.length} more... + +
+ )} +
+ + {/* Portal tooltip for proper z-index layering */} + {showTooltip && tooltipPosition && createPortal( +
+
+ Additional Tags: +
+ {remainingTags.map((tag, index) => ( +
+ • {tag} +
+ ))} +
+
, + document.body + )} + + {/* Error display */} + {validationError && ( +
+ {validationError} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx b/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx index 0c6589dec1..882b42257e 100644 --- a/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx +++ b/archon-ui-main/src/components/knowledge-base/KnowledgeItemCard.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { Link as LinkIcon, Upload, Trash2, RefreshCw, Code, FileText, Brain, BoxIcon, Pencil } from 'lucide-react'; import { Card } from '../ui/Card'; import { Badge } from '../ui/Badge'; -import { Checkbox } from '../ui/Checkbox'; import { KnowledgeItem, knowledgeBaseService } from '../../services/knowledgeBaseService'; import { useCardTilt } from '../../hooks/useCardTilt'; import { CodeViewerModal, CodeExample } from '../code/CodeViewerModal'; import { EditKnowledgeItemModal } from './EditKnowledgeItemModal'; +import { EditableTags } from './EditableTags'; +import { useToast } from '../../contexts/ToastContext'; import '../../styles/card-animations.css'; // Helper function to guess language from title @@ -22,65 +23,6 @@ const guessLanguageFromTitle = (title: string = ''): string => { return 'javascript'; // Default }; -// Tags display component -interface TagsDisplayProps { - tags: string[]; -} - -const TagsDisplay = ({ tags }: TagsDisplayProps) => { - const [showTooltip, setShowTooltip] = useState(false); - - if (!tags || tags.length === 0) return null; - - const visibleTags = tags.slice(0, 4); - const remainingTags = tags.slice(4); - const hasMoreTags = remainingTags.length > 0; - - return ( -
-
- {visibleTags.map((tag, index) => ( - - {tag} - - ))} - {hasMoreTags && ( -
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - > - - +{remainingTags.length} more... - - {showTooltip && ( -
-
- Additional Tags: -
- {remainingTags.map((tag, index) => ( -
- • {tag} -
- ))} -
-
- )} -
- )} -
-
- ); -}; // Delete confirmation modal component interface DeleteConfirmModalProps { @@ -130,9 +72,6 @@ interface KnowledgeItemCardProps { onUpdate?: () => void; onRefresh?: (sourceId: string) => void; onBrowseDocuments?: (sourceId: string) => void; - isSelectionMode?: boolean; - isSelected?: boolean; - onToggleSelection?: (event: React.MouseEvent) => void; } export const KnowledgeItemCard = ({ @@ -141,9 +80,6 @@ export const KnowledgeItemCard = ({ onUpdate, onRefresh, onBrowseDocuments, - isSelectionMode = false, - isSelected = false, - onToggleSelection }: KnowledgeItemCardProps) => { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showCodeModal, setShowCodeModal] = useState(false); @@ -154,6 +90,9 @@ export const KnowledgeItemCard = ({ const [loadedCodeExamples, setLoadedCodeExamples] = useState(null); const [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false); const [isRecrawling, setIsRecrawling] = useState(false); + const [isUpdatingTags, setIsUpdatingTags] = useState(false); + + const { showToast } = useToast(); const statusColorMap = { active: 'green', @@ -196,10 +135,10 @@ export const KnowledgeItemCard = ({ const sourceIconColor = getSourceIconColor(); const typeIconColor = getTypeIconColor(); - // Use the tilt effect hook - disable in selection mode + // Use the tilt effect hook const { cardRef, tiltStyles, handlers } = useCardTilt({ - max: isSelectionMode ? 0 : 10, - scale: isSelectionMode ? 1 : 1.02, + max: 10, + scale: 1.02, perspective: 1200, }); @@ -224,6 +163,31 @@ export const KnowledgeItemCard = ({ } }; + const handleTagsUpdate = async (tags: string[]) => { + setIsUpdatingTags(true); + try { + await knowledgeBaseService.updateKnowledgeItem(item.source_id, { tags }); + if (onUpdate) { + onUpdate(); + } + showToast('Tags updated successfully', 'success'); + } catch (error) { + const errorMessage = error instanceof Error + ? `Failed to update tags: ${error.message}` + : 'Failed to update tags: Unknown error occurred'; + + console.error('Tag update error for card:', error); + showToast(errorMessage, 'error'); + throw error; // Re-throw to let EditableTags handle the error display + } finally { + setIsUpdatingTags(false); + } + }; + + const handleTagError = (error: string) => { + showToast(error, 'error'); + }; + // Get code examples count from metadata const codeExamplesCount = item.metadata.code_examples_count || 0; @@ -270,26 +234,8 @@ export const KnowledgeItemCard = ({ > { - if (isSelectionMode && onToggleSelection) { - e.stopPropagation(); - onToggleSelection(e); - } - }} + className="relative h-full flex flex-col overflow-hidden" > - {/* Checkbox for selection mode */} - {isSelectionMode && ( -
- {}} - className="pointer-events-none" - /> -
- )} {/* Reflection overlay */}
{item.title} - {!isSelectionMode && ( -
+
- )}
{/* Description section - fixed height */} @@ -368,7 +312,13 @@ export const KnowledgeItemCard = ({ {/* Tags section - flexible height with flex-1 */}
- +
{/* Footer section - anchored to bottom */} diff --git a/archon-ui-main/src/components/knowledge-base/tests/EditableTags.test.tsx b/archon-ui-main/src/components/knowledge-base/tests/EditableTags.test.tsx new file mode 100644 index 0000000000..c36bbccb6d --- /dev/null +++ b/archon-ui-main/src/components/knowledge-base/tests/EditableTags.test.tsx @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../../features/testing/test-utils'; +import { EditableTags } from '../EditableTags'; + +describe('EditableTags', () => { + const mockOnTagsUpdate = vi.fn(); + + const defaultProps = { + tags: ['react', 'typescript', 'testing'], + onTagsUpdate: mockOnTagsUpdate, + maxVisibleTags: 4, + isUpdating: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render all tags when count is within maxVisibleTags', () => { + render(); + + expect(screen.getByText('react')).toBeInTheDocument(); + expect(screen.getByText('typescript')).toBeInTheDocument(); + expect(screen.getByText('testing')).toBeInTheDocument(); + }); + + it('should show "Add tags..." button when no tags exist', () => { + render(); + + expect(screen.getByText('Add tags...')).toBeInTheDocument(); + }); + + it('should show add button when tags exist', () => { + render(); + + const addButton = screen.getByRole('button'); + expect(addButton).toBeInTheDocument(); + }); + + it('should enter editing mode when clicking on a tag', async () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.click(reactTag); + + await waitFor(() => { + const input = screen.getByDisplayValue('react'); + expect(input).toBeInTheDocument(); + }); + }); + + it('should save tag changes on blur', async () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.click(reactTag); + + await waitFor(() => { + const input = screen.getByDisplayValue('react'); + fireEvent.change(input, { target: { value: 'vue' } }); + fireEvent.blur(input); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['vue', 'typescript', 'testing']); + }); + }); + + it('should save tag changes on Enter key', async () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.click(reactTag); + + await waitFor(() => { + const input = screen.getByDisplayValue('react'); + fireEvent.change(input, { target: { value: 'angular' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['angular', 'typescript', 'testing']); + }); + }); + + it('should cancel editing on Escape key', async () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.click(reactTag); + + await waitFor(() => { + const input = screen.getByDisplayValue('react'); + fireEvent.change(input, { target: { value: 'should-not-save' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + }); + + // Should not call onTagsUpdate + expect(mockOnTagsUpdate).not.toHaveBeenCalled(); + + // Should show original tag text + await waitFor(() => { + expect(screen.getByText('react')).toBeInTheDocument(); + }); + }); + + it('should remove tag when clicking remove button', async () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.mouseEnter(reactTag.closest('.group')!); + + await waitFor(() => { + const removeButton = screen.getByRole('button', { name: /remove/i }); + fireEvent.click(removeButton); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['typescript', 'testing']); + }); + }); + + it('should add new tag when using add button', async () => { + render(); + + const addButton = screen.getByRole('button'); + fireEvent.click(addButton); + + await waitFor(() => { + const input = screen.getByPlaceholderText('New tag'); + fireEvent.change(input, { target: { value: 'newtag' } }); + fireEvent.blur(input); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['react', 'typescript', 'testing', 'newtag']); + }); + }); + + it('should prevent duplicate tags', async () => { + render(); + + const addButton = screen.getByRole('button'); + fireEvent.click(addButton); + + await waitFor(() => { + const input = screen.getByPlaceholderText('New tag'); + fireEvent.change(input, { target: { value: 'react' } }); // Duplicate tag + fireEvent.blur(input); + }); + + // Should not call onTagsUpdate for duplicate + expect(mockOnTagsUpdate).not.toHaveBeenCalled(); + }); + + it('should trim whitespace and reject empty tags', async () => { + render(); + + const addButton = screen.getByRole('button'); + fireEvent.click(addButton); + + await waitFor(() => { + const input = screen.getByPlaceholderText('New tag'); + fireEvent.change(input, { target: { value: ' ' } }); // Only whitespace + fireEvent.blur(input); + }); + + // Should not call onTagsUpdate for empty tag + expect(mockOnTagsUpdate).not.toHaveBeenCalled(); + }); + + it('should handle long tags correctly', async () => { + render(); + + const addButton = screen.getByRole('button'); + fireEvent.click(addButton); + + const longTag = 'a'.repeat(60); // Exceeds 50 char limit + + await waitFor(() => { + const input = screen.getByPlaceholderText('New tag'); + fireEvent.change(input, { target: { value: longTag } }); + fireEvent.blur(input); + }); + + // Should not call onTagsUpdate for tag exceeding length limit + expect(mockOnTagsUpdate).not.toHaveBeenCalled(); + }); + + it('should show more tags tooltip when exceeding maxVisibleTags', () => { + const manyTags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6']; + render(); + + expect(screen.getByText('+3 more...')).toBeInTheDocument(); + }); + + it('should disable editing when isUpdating is true', () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.click(reactTag); + + // Should not enter editing mode + expect(screen.queryByDisplayValue('react')).not.toBeInTheDocument(); + }); + + it('should handle remove tag during editing', async () => { + render(); + + const reactTag = screen.getByText('react'); + fireEvent.click(reactTag); + + await waitFor(() => { + const input = screen.getByDisplayValue('react'); + fireEvent.change(input, { target: { value: '' } }); // Clear the tag + fireEvent.blur(input); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['typescript', 'testing']); + }); + }); + + it('should add new tag with Enter key', async () => { + render(); + + const addButton = screen.getByRole('button'); + fireEvent.click(addButton); + + await waitFor(() => { + const input = screen.getByPlaceholderText('New tag'); + fireEvent.change(input, { target: { value: 'newtag' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['react', 'typescript', 'testing', 'newtag']); + }); + }); + + it('should handle valid trimmed tag addition', async () => { + render(); + + const addButton = screen.getByRole('button'); + fireEvent.click(addButton); + + await waitFor(() => { + const input = screen.getByPlaceholderText('New tag'); + fireEvent.change(input, { target: { value: ' validtag ' } }); // With whitespace + fireEvent.blur(input); + }); + + await waitFor(() => { + expect(mockOnTagsUpdate).toHaveBeenCalledWith(['react', 'typescript', 'testing', 'validtag']); + }); + }); +}); \ No newline at end of file diff --git a/archon-ui-main/src/pages/KnowledgeBasePage.tsx b/archon-ui-main/src/pages/KnowledgeBasePage.tsx index 9b1c96d7a7..40c70ef6ba 100644 --- a/archon-ui-main/src/pages/KnowledgeBasePage.tsx +++ b/archon-ui-main/src/pages/KnowledgeBasePage.tsx @@ -1,10 +1,8 @@ -import { useEffect, useState, useRef, useMemo } from 'react'; -import { Search, Grid, Plus, Filter, BoxIcon, List, BookOpen, CheckSquare, Brain } from 'lucide-react'; +import { useEffect, useState, useMemo } from 'react'; +import { Search, Grid, Plus, Filter, BoxIcon, List, BookOpen, Brain } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; -import { Badge } from '../components/ui/Badge'; import { useStaggeredEntrance } from '../hooks/useStaggeredEntrance'; import { useToast } from '../contexts/ToastContext'; import { knowledgeBaseService, KnowledgeItem, KnowledgeItemMetadata } from '../services/knowledgeBaseService'; @@ -36,7 +34,6 @@ export const KnowledgeBasePage = () => { const [typeFilter, setTypeFilter] = useState<'all' | 'technical' | 'business'>('all'); const [knowledgeItems, setKnowledgeItems] = useState([]); const [loading, setLoading] = useState(true); - const [totalItems, setTotalItems] = useState(0); const [progressItems, setProgressItemsRaw] = useState([]); const [showCrawlingTab, setShowCrawlingTab] = useState(false); @@ -49,10 +46,6 @@ export const KnowledgeBasePage = () => { }); }; - // Selection state - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [isSelectionMode, setIsSelectionMode] = useState(false); - const [lastSelectedIndex, setLastSelectedIndex] = useState(null); // Document browser state const [documentBrowserSourceId, setDocumentBrowserSourceId] = useState(null); @@ -69,7 +62,6 @@ export const KnowledgeBasePage = () => { per_page: 100 }); setKnowledgeItems(response.items); - setTotalItems(response.total); } catch (error) { console.error('Failed to load knowledge items:', error); showToast('Failed to load knowledge items', 'error'); @@ -278,96 +270,7 @@ export const KnowledgeBasePage = () => { setIsDocumentBrowserOpen(true); }; - const toggleSelectionMode = () => { - setIsSelectionMode(!isSelectionMode); - if (isSelectionMode) { - setSelectedItems(new Set()); - setLastSelectedIndex(null); - } - }; - - const toggleItemSelection = (itemId: string, index: number, event: React.MouseEvent) => { - const newSelected = new Set(selectedItems); - - if (event.shiftKey && lastSelectedIndex !== null) { - const start = Math.min(lastSelectedIndex, index); - const end = Math.max(lastSelectedIndex, index); - - for (let i = start; i <= end; i++) { - if (filteredItems[i]) { - newSelected.add(filteredItems[i].id); - } - } - } else if (event.ctrlKey || event.metaKey) { - if (newSelected.has(itemId)) { - newSelected.delete(itemId); - } else { - newSelected.add(itemId); - } - } else { - if (newSelected.has(itemId)) { - newSelected.delete(itemId); - } else { - newSelected.add(itemId); - } - } - - setSelectedItems(newSelected); - setLastSelectedIndex(index); - }; - - const selectAll = () => { - const allIds = new Set(filteredItems.map(item => item.id)); - setSelectedItems(allIds); - }; - const deselectAll = () => { - setSelectedItems(new Set()); - setLastSelectedIndex(null); - }; - - const deleteSelectedItems = async () => { - if (selectedItems.size === 0) return; - - const count = selectedItems.size; - const confirmed = window.confirm(`Are you sure you want to delete ${count} selected item${count > 1 ? 's' : ''}?`); - - if (!confirmed) return; - - try { - const deletePromises = Array.from(selectedItems).map(itemId => - knowledgeBaseService.deleteKnowledgeItem(itemId) - ); - - await Promise.all(deletePromises); - - setKnowledgeItems(prev => prev.filter(item => !selectedItems.has(item.id))); - setSelectedItems(new Set()); - setIsSelectionMode(false); - - showToast(`Successfully deleted ${count} item${count > 1 ? 's' : ''}`, 'success'); - } catch (error) { - console.error('Failed to delete selected items:', error); - showToast('Failed to delete some items', 'error'); - } - }; - - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'a' && isSelectionMode) { - e.preventDefault(); - selectAll(); - } - - if (e.key === 'Escape' && isSelectionMode) { - toggleSelectionMode(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isSelectionMode, filteredItems]); const handleRefreshItem = async (sourceId: string) => { try { @@ -654,16 +557,6 @@ export const KnowledgeBasePage = () => {
- - - -
-
- - -
- - - - )} - {/* Active Crawls Tab */} {showCrawlingTab && progressItems.length > 0 && ( @@ -752,7 +610,7 @@ export const KnowledgeBasePage = () => { ))} - {ungroupedItems.map((item, index) => ( + {ungroupedItems.map((item, _index) => ( { onUpdate={loadKnowledgeItems} onRefresh={handleRefreshItem} onBrowseDocuments={handleBrowseDocuments} - isSelectionMode={isSelectionMode} - isSelected={selectedItems.has(item.id)} - onToggleSelection={(e) => toggleItemSelection(item.id, index, e)} /> ))} @@ -792,11 +647,10 @@ export const KnowledgeBasePage = () => { {isGroupModalOpen && ( selectedItems.has(item.id))} + selectedItems={[]} onClose={() => setIsGroupModalOpen(false)} onSuccess={() => { setIsGroupModalOpen(false); - toggleSelectionMode(); loadKnowledgeItems(); }} /> @@ -813,6 +667,7 @@ export const KnowledgeBasePage = () => { }} /> )} + ); }; \ No newline at end of file diff --git a/archon-ui-main/src/services/knowledgeBaseService.ts b/archon-ui-main/src/services/knowledgeBaseService.ts index 10ab75274e..995fb56d8e 100644 --- a/archon-ui-main/src/services/knowledgeBaseService.ts +++ b/archon-ui-main/src/services/knowledgeBaseService.ts @@ -194,6 +194,28 @@ class KnowledgeBaseService { }) } + /** + * Update tags for a knowledge item (optimized method for tag updates) + */ + async updateKnowledgeItemTags(sourceId: string, tags: string[]): Promise { + try { + console.log(`🏷️ [KnowledgeBase] Updating tags for ${sourceId}:`, tags); + + await this.updateKnowledgeItem(sourceId, { tags }); + + console.log(`✅ [KnowledgeBase] Tags updated successfully for ${sourceId}`); + } catch (error) { + console.error(`❌ [KnowledgeBase] Failed to update tags for ${sourceId}:`, error); + + // Provide more descriptive error message + const errorMessage = error instanceof Error + ? `Failed to update tags: ${error.message}` + : 'Failed to update tags due to an unknown error'; + + throw new Error(errorMessage); + } + } + /** * Refresh a knowledge item by re-crawling its URL */