From a40f8bf816fc184d6a3201d39fb4cbfacdb7d79b Mon Sep 17 00:00:00 2001 From: Soumyadas15 Date: Tue, 24 Jun 2025 16:00:01 +0530 Subject: [PATCH 1/2] feat: introduce knowledge base --- backend/agent/run.py | 24 +- backend/api.py | 5 +- backend/knowledge_base/__init__.py | 1 + backend/knowledge_base/api.py | 322 +++++++++++ .../20250624093857_knowledge_base.sql | 215 ++++++++ .../knowledge-base/knowledge-base-manager.tsx | 521 ++++++++++++++++++ .../components/thread/thread-site-header.tsx | 50 +- frontend/src/hooks/react-query/index.ts | 4 +- .../use-knowledge-base-queries.ts | 224 ++++++++ 9 files changed, 1360 insertions(+), 6 deletions(-) create mode 100644 backend/knowledge_base/__init__.py create mode 100644 backend/knowledge_base/api.py create mode 100644 backend/supabase/migrations/20250624093857_knowledge_base.sql create mode 100644 frontend/src/components/thread/knowledge-base/knowledge-base-manager.tsx create mode 100644 frontend/src/hooks/react-query/knowledge-base/use-knowledge-base-queries.ts diff --git a/backend/agent/run.py b/backend/agent/run.py index e4e0382a..8d396cde 100644 --- a/backend/agent/run.py +++ b/backend/agent/run.py @@ -11,7 +11,7 @@ from agent.tools.sb_expose_tool import SandboxExposeTool from agent.tools.web_search_tool import SandboxWebSearchTool from dotenv import load_dotenv from utils.config import config - +from flags.flags import is_enabled from agent.agent_builder_prompt import get_agent_builder_prompt from agentpress.thread_manager import ThreadManager from agentpress.response_processor import ProcessorConfig @@ -222,7 +222,27 @@ async def run_agent( system_content = default_system_content logger.info("Using default system prompt only") - # Add MCP tool information to system prompt if MCP tools are configured + if await is_enabled("knowledge_base"): + try: + from services.supabase import DBConnection + kb_db = DBConnection() + kb_client = await kb_db.client + + kb_result = await kb_client.rpc('get_knowledge_base_context', { + 'p_thread_id': thread_id, + 'p_max_tokens': 4000 + }).execute() + + if kb_result.data and kb_result.data.strip(): + logger.info(f"Adding knowledge base context to system prompt for thread {thread_id}") + system_content += "Here is the user's knowledge base context for this thread:\n\n" + kb_result.data + else: + logger.debug(f"No knowledge base context found for thread {thread_id}") + + except Exception as e: + logger.error(f"Error retrieving knowledge base context for thread {thread_id}: {e}") + + if agent_config and (agent_config.get('configured_mcps') or agent_config.get('custom_mcps')) and mcp_wrapper_instance and mcp_wrapper_instance._initialized: mcp_info = "\n\n--- MCP Tools Available ---\n" mcp_info += "You have access to external MCP (Model Context Protocol) server tools.\n" diff --git a/backend/api.py b/backend/api.py index 0ec89e86..a9254136 100644 --- a/backend/api.py +++ b/backend/api.py @@ -152,9 +152,7 @@ from mcp_local import secure_api as secure_mcp_api app.include_router(mcp_api.router, prefix="/api") app.include_router(secure_mcp_api.router, prefix="/api/secure-mcp") - app.include_router(transcription_api.router, prefix="/api") - app.include_router(email_api.router, prefix="/api") from workflows import api as workflows_api @@ -168,6 +166,9 @@ app.include_router(webhooks_api.router, prefix="/api") from scheduling import api as scheduling_api app.include_router(scheduling_api.router) +from knowledge_base import api as knowledge_base_api +app.include_router(knowledge_base_api.router, prefix="/api") + @app.get("/api/health") async def health_check(): """Health check endpoint to verify API is working.""" diff --git a/backend/knowledge_base/__init__.py b/backend/knowledge_base/__init__.py new file mode 100644 index 00000000..132506b9 --- /dev/null +++ b/backend/knowledge_base/__init__.py @@ -0,0 +1 @@ +# Knowledge Base Module \ No newline at end of file diff --git a/backend/knowledge_base/api.py b/backend/knowledge_base/api.py new file mode 100644 index 00000000..145b13ac --- /dev/null +++ b/backend/knowledge_base/api.py @@ -0,0 +1,322 @@ +import json +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, Field +from utils.auth_utils import get_current_user_id_from_jwt +from services.supabase import DBConnection +from utils.logger import logger +from flags.flags import is_enabled + +router = APIRouter(prefix="/knowledge-base", tags=["knowledge-base"]) + +class KnowledgeBaseEntry(BaseModel): + entry_id: Optional[str] = None + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + content: str = Field(..., min_length=1) + usage_context: str = Field(default="always", pattern="^(always|on_request|contextual)$") + is_active: bool = True + +class KnowledgeBaseEntryResponse(BaseModel): + entry_id: str + name: str + description: Optional[str] + content: str + usage_context: str + is_active: bool + content_tokens: Optional[int] + created_at: str + updated_at: str + +class KnowledgeBaseListResponse(BaseModel): + entries: List[KnowledgeBaseEntryResponse] + total_count: int + total_tokens: int + +class CreateKnowledgeBaseEntryRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + content: str = Field(..., min_length=1) + usage_context: str = Field(default="always", pattern="^(always|on_request|contextual)$") + +class UpdateKnowledgeBaseEntryRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + content: Optional[str] = Field(None, min_length=1) + usage_context: Optional[str] = Field(None, pattern="^(always|on_request|contextual)$") + is_active: Optional[bool] = None + +db = DBConnection() + +@router.get("/threads/{thread_id}", response_model=KnowledgeBaseListResponse) +async def get_thread_knowledge_base( + thread_id: str, + include_inactive: bool = False, + user_id: str = Depends(get_current_user_id_from_jwt) +): + if not await is_enabled("knowledge_base"): + raise HTTPException( + status_code=403, + detail="This feature is not available at the moment." + ) + + """Get all knowledge base entries for a thread""" + try: + client = await db.client + + thread_result = await client.table('threads').select('*').eq('thread_id', thread_id).execute() + if not thread_result.data: + raise HTTPException(status_code=404, detail="Thread not found") + + result = await client.rpc('get_thread_knowledge_base', { + 'p_thread_id': thread_id, + 'p_include_inactive': include_inactive + }).execute() + + entries = [] + total_tokens = 0 + + for entry_data in result.data or []: + entry = KnowledgeBaseEntryResponse( + entry_id=entry_data['entry_id'], + name=entry_data['name'], + description=entry_data['description'], + content=entry_data['content'], + usage_context=entry_data['usage_context'], + is_active=entry_data['is_active'], + content_tokens=entry_data.get('content_tokens'), + created_at=entry_data['created_at'], + updated_at=entry_data.get('updated_at', entry_data['created_at']) + ) + entries.append(entry) + total_tokens += entry_data.get('content_tokens', 0) or 0 + + return KnowledgeBaseListResponse( + entries=entries, + total_count=len(entries), + total_tokens=total_tokens + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting knowledge base for thread {thread_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to retrieve knowledge base") + +@router.post("/threads/{thread_id}", response_model=KnowledgeBaseEntryResponse) +async def create_knowledge_base_entry( + thread_id: str, + entry_data: CreateKnowledgeBaseEntryRequest, + user_id: str = Depends(get_current_user_id_from_jwt) +): + if not await is_enabled("knowledge_base"): + raise HTTPException( + status_code=403, + detail="This feature is not available at the moment." + ) + + """Create a new knowledge base entry for a thread""" + try: + client = await db.client + thread_result = await client.table('threads').select('account_id').eq('thread_id', thread_id).execute() + if not thread_result.data: + raise HTTPException(status_code=404, detail="Thread not found") + + account_id = thread_result.data[0]['account_id'] + + insert_data = { + 'thread_id': thread_id, + 'account_id': account_id, + 'name': entry_data.name, + 'description': entry_data.description, + 'content': entry_data.content, + 'usage_context': entry_data.usage_context + } + + result = await client.table('knowledge_base_entries').insert(insert_data).execute() + + if not result.data: + raise HTTPException(status_code=500, detail="Failed to create knowledge base entry") + + created_entry = result.data[0] + + return KnowledgeBaseEntryResponse( + entry_id=created_entry['entry_id'], + name=created_entry['name'], + description=created_entry['description'], + content=created_entry['content'], + usage_context=created_entry['usage_context'], + is_active=created_entry['is_active'], + content_tokens=created_entry.get('content_tokens'), + created_at=created_entry['created_at'], + updated_at=created_entry['updated_at'] + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating knowledge base entry for thread {thread_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create knowledge base entry") + +@router.put("/{entry_id}", response_model=KnowledgeBaseEntryResponse) +async def update_knowledge_base_entry( + entry_id: str, + entry_data: UpdateKnowledgeBaseEntryRequest, + user_id: str = Depends(get_current_user_id_from_jwt) +): + if not await is_enabled("knowledge_base"): + raise HTTPException( + status_code=403, + detail="This feature is not available at the moment." + ) + + """Update a knowledge base entry""" + try: + client = await db.client + entry_result = await client.table('knowledge_base_entries').select('*').eq('entry_id', entry_id).execute() + if not entry_result.data: + raise HTTPException(status_code=404, detail="Knowledge base entry not found") + + update_data = {} + if entry_data.name is not None: + update_data['name'] = entry_data.name + if entry_data.description is not None: + update_data['description'] = entry_data.description + if entry_data.content is not None: + update_data['content'] = entry_data.content + if entry_data.usage_context is not None: + update_data['usage_context'] = entry_data.usage_context + if entry_data.is_active is not None: + update_data['is_active'] = entry_data.is_active + + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + result = await client.table('knowledge_base_entries').update(update_data).eq('entry_id', entry_id).execute() + + if not result.data: + raise HTTPException(status_code=500, detail="Failed to update knowledge base entry") + + updated_entry = result.data[0] + + return KnowledgeBaseEntryResponse( + entry_id=updated_entry['entry_id'], + name=updated_entry['name'], + description=updated_entry['description'], + content=updated_entry['content'], + usage_context=updated_entry['usage_context'], + is_active=updated_entry['is_active'], + content_tokens=updated_entry.get('content_tokens'), + created_at=updated_entry['created_at'], + updated_at=updated_entry['updated_at'] + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating knowledge base entry {entry_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to update knowledge base entry") + +@router.delete("/{entry_id}") +async def delete_knowledge_base_entry( + entry_id: str, + user_id: str = Depends(get_current_user_id_from_jwt) +): + if not await is_enabled("knowledge_base"): + raise HTTPException( + status_code=403, + detail="This feature is not available at the moment." + ) + + """Delete a knowledge base entry""" + try: + client = await db.client + entry_result = await client.table('knowledge_base_entries').select('entry_id').eq('entry_id', entry_id).execute() + if not entry_result.data: + raise HTTPException(status_code=404, detail="Knowledge base entry not found") + + result = await client.table('knowledge_base_entries').delete().eq('entry_id', entry_id).execute() + + return {"message": "Knowledge base entry deleted successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting knowledge base entry {entry_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to delete knowledge base entry") + +@router.get("/{entry_id}", response_model=KnowledgeBaseEntryResponse) +async def get_knowledge_base_entry( + entry_id: str, + user_id: str = Depends(get_current_user_id_from_jwt) +): + if not await is_enabled("knowledge_base"): + raise HTTPException( + status_code=403, + detail="This feature is not available at the moment." + ) + """Get a specific knowledge base entry""" + try: + client = await db.client + result = await client.table('knowledge_base_entries').select('*').eq('entry_id', entry_id).execute() + + if not result.data: + raise HTTPException(status_code=404, detail="Knowledge base entry not found") + + entry = result.data[0] + + return KnowledgeBaseEntryResponse( + entry_id=entry['entry_id'], + name=entry['name'], + description=entry['description'], + content=entry['content'], + usage_context=entry['usage_context'], + is_active=entry['is_active'], + content_tokens=entry.get('content_tokens'), + created_at=entry['created_at'], + updated_at=entry['updated_at'] + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting knowledge base entry {entry_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to retrieve knowledge base entry") + +@router.get("/threads/{thread_id}/context") +async def get_knowledge_base_context( + thread_id: str, + max_tokens: int = 4000, + user_id: str = Depends(get_current_user_id_from_jwt) +): + if not await is_enabled("knowledge_base"): + raise HTTPException( + status_code=403, + detail="This feature is not available at the moment." + ) + + """Get knowledge base context for agent prompts""" + try: + client = await db.client + thread_result = await client.table('threads').select('thread_id').eq('thread_id', thread_id).execute() + if not thread_result.data: + raise HTTPException(status_code=404, detail="Thread not found") + + result = await client.rpc('get_knowledge_base_context', { + 'p_thread_id': thread_id, + 'p_max_tokens': max_tokens + }).execute() + + context = result.data if result.data else None + + return { + "context": context, + "max_tokens": max_tokens, + "thread_id": thread_id + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting knowledge base context for thread {thread_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to retrieve knowledge base context") \ No newline at end of file diff --git a/backend/supabase/migrations/20250624093857_knowledge_base.sql b/backend/supabase/migrations/20250624093857_knowledge_base.sql new file mode 100644 index 00000000..cea85aab --- /dev/null +++ b/backend/supabase/migrations/20250624093857_knowledge_base.sql @@ -0,0 +1,215 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS knowledge_base_entries ( + entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id UUID NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES basejump.accounts(id) ON DELETE CASCADE, + + name VARCHAR(255) NOT NULL, + description TEXT, + + content TEXT NOT NULL, + content_tokens INTEGER, -- Token count for content management + + usage_context VARCHAR(100) DEFAULT 'always', -- 'always', 'on_request', 'contextual' + + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + last_accessed_at TIMESTAMPTZ, + + CONSTRAINT kb_entries_valid_usage_context CHECK ( + usage_context IN ('always', 'on_request', 'contextual') + ), + CONSTRAINT kb_entries_content_not_empty CHECK ( + content IS NOT NULL AND LENGTH(TRIM(content)) > 0 + ) +); + + +CREATE TABLE IF NOT EXISTS knowledge_base_usage_log ( + log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entry_id UUID NOT NULL REFERENCES knowledge_base_entries(entry_id) ON DELETE CASCADE, + thread_id UUID NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + + usage_type VARCHAR(50) NOT NULL, -- 'context_injection', 'manual_reference' + tokens_used INTEGER, -- How many tokens were used + + -- Timestamps + used_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_kb_entries_thread_id ON knowledge_base_entries(thread_id); +CREATE INDEX IF NOT EXISTS idx_kb_entries_account_id ON knowledge_base_entries(account_id); +CREATE INDEX IF NOT EXISTS idx_kb_entries_is_active ON knowledge_base_entries(is_active); +CREATE INDEX IF NOT EXISTS idx_kb_entries_usage_context ON knowledge_base_entries(usage_context); +CREATE INDEX IF NOT EXISTS idx_kb_entries_created_at ON knowledge_base_entries(created_at); + +CREATE INDEX IF NOT EXISTS idx_kb_usage_entry_id ON knowledge_base_usage_log(entry_id); +CREATE INDEX IF NOT EXISTS idx_kb_usage_thread_id ON knowledge_base_usage_log(thread_id); +CREATE INDEX IF NOT EXISTS idx_kb_usage_used_at ON knowledge_base_usage_log(used_at); + +ALTER TABLE knowledge_base_entries ENABLE ROW LEVEL SECURITY; +ALTER TABLE knowledge_base_usage_log ENABLE ROW LEVEL SECURITY; + +CREATE POLICY kb_entries_user_access ON knowledge_base_entries + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM threads t + LEFT JOIN projects p ON t.project_id = p.project_id + WHERE t.thread_id = knowledge_base_entries.thread_id + AND ( + basejump.has_role_on_account(t.account_id) = true OR + basejump.has_role_on_account(p.account_id) = true OR + basejump.has_role_on_account(knowledge_base_entries.account_id) = true + ) + ) + ); + +CREATE POLICY kb_usage_log_user_access ON knowledge_base_usage_log + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM threads t + LEFT JOIN projects p ON t.project_id = p.project_id + WHERE t.thread_id = knowledge_base_usage_log.thread_id + AND ( + basejump.has_role_on_account(t.account_id) = true OR + basejump.has_role_on_account(p.account_id) = true + ) + ) + ); + +CREATE OR REPLACE FUNCTION get_thread_knowledge_base( + p_thread_id UUID, + p_include_inactive BOOLEAN DEFAULT FALSE +) +RETURNS TABLE ( + entry_id UUID, + name VARCHAR(255), + description TEXT, + content TEXT, + usage_context VARCHAR(100), + is_active BOOLEAN, + created_at TIMESTAMPTZ +) +SECURITY DEFINER +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + kbe.entry_id, + kbe.name, + kbe.description, + kbe.content, + kbe.usage_context, + kbe.is_active, + kbe.created_at + FROM knowledge_base_entries kbe + WHERE kbe.thread_id = p_thread_id + AND (p_include_inactive OR kbe.is_active = TRUE) + ORDER BY kbe.created_at DESC; +END; +$$; + +CREATE OR REPLACE FUNCTION get_knowledge_base_context( + p_thread_id UUID, + p_max_tokens INTEGER DEFAULT 4000 +) +RETURNS TEXT +SECURITY DEFINER +LANGUAGE plpgsql +AS $$ +DECLARE + context_text TEXT := ''; + entry_record RECORD; + current_tokens INTEGER := 0; + estimated_tokens INTEGER; +BEGIN + FOR entry_record IN + SELECT + name, + description, + content, + content_tokens + FROM knowledge_base_entries + WHERE thread_id = p_thread_id + AND is_active = TRUE + AND usage_context IN ('always', 'contextual') + ORDER BY created_at DESC + LOOP + estimated_tokens := COALESCE(entry_record.content_tokens, LENGTH(entry_record.content) / 4); + + IF current_tokens + estimated_tokens > p_max_tokens THEN + EXIT; + END IF; + + context_text := context_text || E'\n\n## Knowledge Base: ' || entry_record.name || E'\n'; + + IF entry_record.description IS NOT NULL AND entry_record.description != '' THEN + context_text := context_text || entry_record.description || E'\n\n'; + END IF; + + context_text := context_text || entry_record.content; + + current_tokens := current_tokens + estimated_tokens; + + INSERT INTO knowledge_base_usage_log (entry_id, thread_id, usage_type, tokens_used) + SELECT entry_id, p_thread_id, 'context_injection', estimated_tokens + FROM knowledge_base_entries + WHERE thread_id = p_thread_id AND name = entry_record.name + LIMIT 1; + END LOOP; + + RETURN CASE + WHEN context_text = '' THEN NULL + ELSE E'# KNOWLEDGE BASE CONTEXT\n\nThe following information is from your knowledge base and should be used as reference when responding to the user:' || context_text + END; +END; +$$; + +CREATE OR REPLACE FUNCTION update_kb_entry_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + IF NEW.content != OLD.content THEN + NEW.content_tokens = LENGTH(NEW.content) / 4; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_kb_entries_updated_at + BEFORE UPDATE ON knowledge_base_entries + FOR EACH ROW + EXECUTE FUNCTION update_kb_entry_timestamp(); + +CREATE OR REPLACE FUNCTION calculate_kb_entry_tokens() +RETURNS TRIGGER AS $$ +BEGIN + NEW.content_tokens = LENGTH(NEW.content) / 4; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_kb_entries_calculate_tokens + BEFORE INSERT ON knowledge_base_entries + FOR EACH ROW + EXECUTE FUNCTION calculate_kb_entry_tokens(); + +GRANT ALL PRIVILEGES ON TABLE knowledge_base_entries TO authenticated, service_role; +GRANT ALL PRIVILEGES ON TABLE knowledge_base_usage_log TO authenticated, service_role; + +GRANT EXECUTE ON FUNCTION get_thread_knowledge_base TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION get_knowledge_base_context TO authenticated, service_role; + +COMMENT ON TABLE knowledge_base_entries IS 'Stores manual knowledge base entries for threads, similar to ChatGPT custom instructions'; +COMMENT ON TABLE knowledge_base_usage_log IS 'Logs when and how knowledge base entries are used'; + +COMMENT ON FUNCTION get_thread_knowledge_base IS 'Retrieves all knowledge base entries for a specific thread'; +COMMENT ON FUNCTION get_knowledge_base_context IS 'Generates knowledge base context text for agent prompts'; + +COMMIT; \ No newline at end of file diff --git a/frontend/src/components/thread/knowledge-base/knowledge-base-manager.tsx b/frontend/src/components/thread/knowledge-base/knowledge-base-manager.tsx new file mode 100644 index 00000000..11314019 --- /dev/null +++ b/frontend/src/components/thread/knowledge-base/knowledge-base-manager.tsx @@ -0,0 +1,521 @@ +'use client'; + +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Plus, + Edit2, + Trash2, + Clock, + MoreVertical, + AlertCircle, + FileText, + Eye, + EyeOff, + Globe, + Search, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + useKnowledgeBaseEntries, + useCreateKnowledgeBaseEntry, + useUpdateKnowledgeBaseEntry, + useDeleteKnowledgeBaseEntry, + type KnowledgeBaseEntry, + type CreateKnowledgeBaseEntryRequest, + type UpdateKnowledgeBaseEntryRequest +} from '@/hooks/react-query/knowledge-base/use-knowledge-base-queries'; +import { cn } from '@/lib/utils'; + +interface KnowledgeBaseManagerProps { + threadId: string; +} + +interface EditDialogData { + entry?: KnowledgeBaseEntry; + isOpen: boolean; +} + +const USAGE_CONTEXT_OPTIONS = [ + { + value: 'always', + label: 'Always Active', + icon: Globe, + color: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800' + }, +// { +// value: 'contextual', +// label: 'Smart Context', +// description: 'Included when contextually relevant', +// icon: Target, +// color: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800' +// }, +// { +// value: 'on_request', +// label: 'On Demand', +// description: 'Only when explicitly requested', +// icon: Zap, +// color: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800' +// }, +] as const; + +const KnowledgeBaseSkeleton = () => ( +
+
+
+ +
+ +
+ +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ + + +
+ +
+ + +
+
+
+ + +
+ +
+
+ +
+
+ ))} +
+
+ ); + +export const KnowledgeBaseManager = ({ threadId }: KnowledgeBaseManagerProps) => { + const [editDialog, setEditDialog] = useState({ isOpen: false }); + const [deleteEntryId, setDeleteEntryId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [formData, setFormData] = useState({ + name: '', + description: '', + content: '', + usage_context: 'always', + }); + + const { data: knowledgeBase, isLoading, error } = useKnowledgeBaseEntries(threadId); + const createMutation = useCreateKnowledgeBaseEntry(); + const updateMutation = useUpdateKnowledgeBaseEntry(); + const deleteMutation = useDeleteKnowledgeBaseEntry(); + + const handleOpenCreateDialog = () => { + setFormData({ + name: '', + description: '', + content: '', + usage_context: 'always', + }); + setEditDialog({ isOpen: true }); + }; + + const handleOpenEditDialog = (entry: KnowledgeBaseEntry) => { + setFormData({ + name: entry.name, + description: entry.description || '', + content: entry.content, + usage_context: entry.usage_context, + }); + setEditDialog({ entry, isOpen: true }); + }; + + const handleCloseDialog = () => { + setEditDialog({ isOpen: false }); + setFormData({ + name: '', + description: '', + content: '', + usage_context: 'always', + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name.trim() || !formData.content.trim()) { + return; + } + + try { + if (editDialog.entry) { + const updateData: UpdateKnowledgeBaseEntryRequest = { + name: formData.name !== editDialog.entry.name ? formData.name : undefined, + description: formData.description !== editDialog.entry.description ? formData.description : undefined, + content: formData.content !== editDialog.entry.content ? formData.content : undefined, + usage_context: formData.usage_context !== editDialog.entry.usage_context ? formData.usage_context : undefined, + }; + const hasChanges = Object.values(updateData).some(value => value !== undefined); + if (hasChanges) { + await updateMutation.mutateAsync({ entryId: editDialog.entry.entry_id, data: updateData }); + } + } else { + await createMutation.mutateAsync({ threadId, data: formData }); + } + + handleCloseDialog(); + } catch (error) { + console.error('Error saving knowledge base entry:', error); + } + }; + + const handleDelete = async (entryId: string) => { + try { + await deleteMutation.mutateAsync(entryId); + setDeleteEntryId(null); + } catch (error) { + console.error('Error deleting knowledge base entry:', error); + } + }; + + const handleToggleActive = async (entry: KnowledgeBaseEntry) => { + try { + await updateMutation.mutateAsync({ + entryId: entry.entry_id, + data: { is_active: !entry.is_active } + }); + } catch (error) { + console.error('Error toggling entry status:', error); + } + }; + + const getUsageContextConfig = (context: string) => { + return USAGE_CONTEXT_OPTIONS.find(option => option.value === context) || USAGE_CONTEXT_OPTIONS[0]; + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+
+ +

Failed to load knowledge base

+
+
+ ); + } + + const entries = knowledgeBase?.entries || []; + const filteredEntries = entries.filter(entry => + entry.name.toLowerCase().includes(searchQuery.toLowerCase()) || + entry.content.toLowerCase().includes(searchQuery.toLowerCase()) || + (entry.description && entry.description.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + return ( +
+ {entries.length > 0 && ( + <> +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+
+ {filteredEntries.length === 0 ? ( +
+ +

No entries match your search

+
+ ) : ( + filteredEntries.map((entry) => { + const contextConfig = getUsageContextConfig(entry.usage_context); + const ContextIcon = contextConfig.icon; + + return ( +
+
+
+
+ +

{entry.name}

+ {!entry.is_active && ( + + + Disabled + + )} +
+ {entry.description && ( +

+ {entry.description} +

+ )} +

+ {entry.content} +

+
+
+ + + {contextConfig.label} + + + + {new Date(entry.created_at).toLocaleDateString()} + +
+ {entry.content_tokens && ( + + ~{entry.content_tokens.toLocaleString()} tokens + + )} +
+
+ + + + + + handleOpenEditDialog(entry)}> + + Edit + + handleToggleActive(entry)}> + {entry.is_active ? ( + <> + + Disable + + ) : ( + <> + + Enable + + )} + + + setDeleteEntryId(entry.entry_id)} + className="text-destructive focus:bg-destructive/10 focus:text-destructive" + > + + Delete + + + +
+
+ ); + }) + )} +
+ + )} + + {entries.length === 0 && ( +
+
+ +
+

No Knowledge Entries

+

+ Add knowledge entries to provide your agent with context, guidelines, and information it should always remember. +

+ +
+ )} + + + + + + + {editDialog.entry ? 'Edit Knowledge Entry' : 'Add Knowledge Entry'} + + + +
+
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Company Guidelines, API Documentation" + required + className="w-full" + /> +
+ +
+ + +
+ +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="Brief description of this knowledge (optional)" + /> +
+ +
+ +