Merge pull request #822 from escapade-mckv/knowledge-base

Knowledge base
This commit is contained in:
Bobbie 2025-06-24 16:47:09 +05:30 committed by GitHub
commit ccb04a2116
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1533 additions and 28 deletions

View File

@ -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"

View File

@ -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."""

View File

@ -0,0 +1 @@
# Knowledge Base Module

View File

@ -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")

View File

@ -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;

View File

@ -18,9 +18,5 @@ export default async function AgentsLayout({
}: {
children: React.ReactNode;
}) {
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
if (!agentPlaygroundEnabled) {
redirect('/dashboard');
}
return <>{children}</>;
}

View File

@ -1,6 +1,6 @@
'use client';
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { Plus, AlertCircle, Loader2, File } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@ -16,8 +16,9 @@ import { Pagination } from './_components/pagination';
import { useRouter } from 'next/navigation';
import { DEFAULT_AGENTPRESS_TOOLS } from './_data/tools';
import { AgentsParams } from '@/hooks/react-query/agents/utils';
import { useFeatureFlags } from '@/lib/feature-flags';
import { useFeatureFlag, useFeatureFlags } from '@/lib/feature-flags';
import { generateRandomAvatar } from './_utils/_avatar-generator';
import { Skeleton } from '@/components/ui/skeleton';
type ViewMode = 'grid' | 'list';
type SortOption = 'name' | 'created_at' | 'updated_at' | 'tools_count';
@ -31,7 +32,14 @@ interface FilterOptions {
}
export default function AgentsPage() {
const { enabled: customAgentsEnabled, loading: flagLoading } = useFeatureFlag("custom_agents");
const router = useRouter();
useEffect(() => {
if (!flagLoading && !customAgentsEnabled) {
router.replace("/dashboard");
}
}, [flagLoading, customAgentsEnabled, router]);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingAgentId, setEditingAgentId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
@ -182,6 +190,39 @@ export default function AgentsPage() {
}
};
if (flagLoading) {
return (
<div className="container max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Your Agents
</h1>
<p className="text-md text-muted-foreground max-w-2xl">
Create and manage your AI agents with custom instructions and tools
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="p-2 bg-neutral-100 dark:bg-sidebar rounded-2xl overflow-hidden group">
<div className="h-24 flex items-center justify-center relative bg-gradient-to-br from-opacity-90 to-opacity-100">
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<div className="space-y-2 mt-4 mb-4">
<Skeleton className="h-6 w-32 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</div>
))}
</div>
</div>
);
}
if (!customAgentsEnabled) {
return null;
}
if (error) {
return (
<div className="container mx-auto max-w-7xl px-4 py-8">

View File

@ -17,9 +17,5 @@ export default async function MarketplaceLayout({
}: {
children: React.ReactNode;
}) {
const marketplaceEnabled = await isFlagEnabled('agent_marketplace');
if (!marketplaceEnabled) {
redirect('/dashboard');
}
return <>{children}</>;
}

View File

@ -1,6 +1,6 @@
'use client';
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Shield, CheckCircle, Loader2, Settings, Wrench, AlertTriangle, GitBranch, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -20,6 +20,8 @@ import {
} from '@/hooks/react-query/secure-mcp/use-secure-mcp';
import { useCredentialProfilesForMcp } from '@/hooks/react-query/mcp/use-credential-profiles';
import { createClient } from '@/lib/supabase/client';
import { useFeatureFlag } from '@/lib/feature-flags';
import { useRouter } from 'next/navigation';
type SortOption = 'newest' | 'popular' | 'most_downloaded' | 'name';
@ -516,6 +518,14 @@ const InstallDialog: React.FC<InstallDialogProps> = ({
};
export default function MarketplacePage() {
const { enabled: agentMarketplaceEnabled, loading: flagLoading } = useFeatureFlag("agent_marketplace");
const router = useRouter();
useEffect(() => {
if (!flagLoading && !agentMarketplaceEnabled) {
router.replace("/dashboard");
}
}, [flagLoading, agentMarketplaceEnabled, router]);
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@ -654,6 +664,40 @@ export default function MarketplacePage() {
return Array.from(tags);
}, [marketplaceItems]);
if (flagLoading) {
return (
<div className="container max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Agent Marketplace
</h1>
<p className="text-md text-muted-foreground max-w-2xl">
Discover and install secure AI agent templates created by the community
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="p-2 bg-neutral-100 dark:bg-sidebar rounded-2xl overflow-hidden group">
<div className="h-24 flex items-center justify-center relative bg-gradient-to-br from-opacity-90 to-opacity-100">
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<div className="space-y-2 mt-4 mb-4">
<Skeleton className="h-6 w-32 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</div>
))}
</div>
</div>
);
}
if (!agentMarketplaceEnabled) {
return null;
}
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="space-y-8">

View File

@ -1,12 +1,11 @@
'use client';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Plus,
Key,
Shield,
Trash2,
CheckCircle,
Loader2,
AlertTriangle,
Star,
@ -35,6 +34,8 @@ import {
} from '@/hooks/react-query/mcp/use-credential-profiles';
import { EnhancedAddCredentialDialog } from './_components/enhanced-add-credential-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useRouter } from 'next/navigation';
import { useFeatureFlag } from '@/lib/feature-flags';
interface CredentialProfileCardProps {
profile: CredentialProfile;
@ -219,6 +220,15 @@ const DeleteConfirmationDialog: React.FC<DeleteConfirmationDialogProps> = ({
};
export default function CredentialsPage() {
const { enabled: customAgentsEnabled, loading: flagLoading } = useFeatureFlag("custom_agents");
const router = useRouter();
useEffect(() => {
if (!flagLoading && !customAgentsEnabled) {
router.replace("/dashboard");
}
}, [flagLoading, customAgentsEnabled, router]);
const [showAddDialog, setShowAddDialog] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
@ -279,6 +289,40 @@ export default function CredentialsPage() {
return acc;
}, {} as Record<string, { serverName: string; qualifiedName: string; profiles: CredentialProfile[] }>);
if (flagLoading) {
return (
<div className="h-screen max-w-7xl mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
<Shield className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">MCP Credential Profiles</h1>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="p-2 bg-neutral-100 dark:bg-sidebar rounded-2xl overflow-hidden group">
<div className="h-24 flex items-center justify-center relative bg-gradient-to-br from-opacity-90 to-opacity-100">
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<div className="space-y-2 mt-4 mb-4">
<Skeleton className="h-6 w-32 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</div>
))}
</div>
</div>
);
}
if (!customAgentsEnabled) {
return null;
}
if (error) {
return (
<div className="container mx-auto max-w-6xl px-6 py-6">

View File

@ -17,9 +17,5 @@ export default async function WorkflowsLayout({
}: {
children: React.ReactNode;
}) {
const workflowsEnabled = await isFlagEnabled('workflows');
if (!workflowsEnabled) {
redirect('/dashboard');
}
return <>{children}</>;
}

View File

@ -23,8 +23,17 @@ import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Skeleton } from "@/components/ui/skeleton";
import { useSidebar } from "@/components/ui/sidebar";
import { useFeatureFlag } from "@/lib/feature-flags";
export default function WorkflowsPage() {
const { enabled: workflowsEnabled, loading: flagLoading } = useFeatureFlag("workflows");
const router = useRouter();
useEffect(() => {
if (!flagLoading && !workflowsEnabled) {
router.replace("/dashboard");
}
}, [flagLoading, workflowsEnabled, router]);
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -36,7 +45,6 @@ export default function WorkflowsPage() {
const [updatingWorkflows, setUpdatingWorkflows] = useState<Set<string>>(new Set());
const [deletingWorkflows, setDeletingWorkflows] = useState<Set<string>>(new Set());
const [togglingWorkflows, setTogglingWorkflows] = useState<Set<string>>(new Set());
const router = useRouter();
const { state, setOpen, setOpenMobile } = useSidebar();
const updateWorkflowStatusMutation = useUpdateWorkflowStatus();
@ -64,7 +72,6 @@ export default function WorkflowsPage() {
setLoading(false);
}
};
loadData();
}, []);
@ -73,7 +80,6 @@ export default function WorkflowsPage() {
toast.error("No project selected");
return;
}
try {
setExecutingWorkflows(prev => new Set(prev).add(workflowId));
const result = await executeWorkflow(workflowId);
@ -273,10 +279,40 @@ export default function WorkflowsPage() {
return "#8b5cf6";
}
};
if (flagLoading) {
return (
<div className="container max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-xl font-bold">Workflows</h1>
<p className="text-muted-foreground">
Create and manage automated agent workflows
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="p-2 bg-neutral-100 dark:bg-sidebar rounded-2xl overflow-hidden group">
<div className="h-24 flex items-center justify-center relative bg-gradient-to-br from-opacity-90 to-opacity-100">
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<div className="space-y-2 mt-4 mb-4">
<Skeleton className="h-6 w-32 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</div>
))}
</div>
</div>
);
}
if (!workflowsEnabled) {
return null;
}
if (loading) {
return (
<div className="h-screen max-w-7xl mx-auto p-6 space-y-6">
<div className="max-w-7xl mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-xl font-bold">Workflows</h1>

View File

@ -0,0 +1,519 @@
'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,
} from '@/hooks/react-query/knowledge-base/use-knowledge-base-queries';
import { cn } from '@/lib/utils';
import { CreateKnowledgeBaseEntryRequest, KnowledgeBaseEntry, UpdateKnowledgeBaseEntryRequest } from '@/hooks/react-query/knowledge-base/types';
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 = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="relative w-full">
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-10 w-32 ml-4" />
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="border rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-20" />
</div>
<Skeleton className="h-4 w-64" />
<div className="space-y-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="h-4 w-16" />
</div>
</div>
<Skeleton className="h-8 w-8" />
</div>
</div>
))}
</div>
</div>
);
export const KnowledgeBaseManager = ({ threadId }: KnowledgeBaseManagerProps) => {
const [editDialog, setEditDialog] = useState<EditDialogData>({ isOpen: false });
const [deleteEntryId, setDeleteEntryId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [formData, setFormData] = useState<CreateKnowledgeBaseEntryRequest>({
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 <KnowledgeBaseSkeleton />;
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-4" />
<p className="text-sm text-red-600 dark:text-red-400">Failed to load knowledge base</p>
</div>
</div>
);
}
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 (
<div className="space-y-6">
{entries.length > 0 && (
<>
<div className="flex items-center justify-between">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search knowledge entries..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button onClick={handleOpenCreateDialog} className="gap-2 ml-4">
<Plus className="h-4 w-4" />
Add Knowledge
</Button>
</div>
<div className="space-y-3">
{filteredEntries.length === 0 ? (
<div className="text-center py-8">
<Search className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No entries match your search</p>
</div>
) : (
filteredEntries.map((entry) => {
const contextConfig = getUsageContextConfig(entry.usage_context);
const ContextIcon = contextConfig.icon;
return (
<div
key={entry.entry_id}
className={cn(
"group border rounded-lg p-4 transition-all hover:shadow-sm",
entry.is_active
? "border-border bg-card hover:border-border/80"
: "border-border/50 bg-muted/30 opacity-70"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h3 className="font-medium truncate">{entry.name}</h3>
{!entry.is_active && (
<Badge variant="secondary" className="text-xs">
<EyeOff className="h-3 w-3 mr-1" />
Disabled
</Badge>
)}
</div>
{entry.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{entry.description}
</p>
)}
<p className="text-sm text-foreground/80 line-clamp-2 leading-relaxed">
{entry.content}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="outline" className={cn("text-xs gap-1", contextConfig.color)}>
<ContextIcon className="h-3 w-3" />
{contextConfig.label}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(entry.created_at).toLocaleDateString()}
</span>
</div>
{entry.content_tokens && (
<span className="text-xs text-muted-foreground">
~{entry.content_tokens.toLocaleString()} tokens
</span>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => handleOpenEditDialog(entry)}>
<Edit2 className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggleActive(entry)}>
{entry.is_active ? (
<>
<EyeOff className="h-4 w-4" />
Disable
</>
) : (
<>
<Eye className="h-4 w-4" />
Enable
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteEntryId(entry.entry_id)}
className="text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<Trash2 className="h-4 w-4 text-destructive" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})
)}
</div>
</>
)}
{entries.length === 0 && (
<div className="text-center py-12">
<div className="mx-auto w-24 h-24 bg-muted/50 rounded-full flex items-center justify-center mb-4">
<FileText className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No Knowledge Entries</h3>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Add knowledge entries to provide your agent with context, guidelines, and information it should always remember.
</p>
<Button onClick={handleOpenCreateDialog} className="gap-2">
<Plus className="h-4 w-4" />
Create Your First Entry
</Button>
</div>
)}
<Dialog open={editDialog.isOpen} onOpenChange={handleCloseDialog}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{editDialog.entry ? 'Edit Knowledge Entry' : 'Add Knowledge Entry'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<form onSubmit={handleSubmit} className="space-y-6 p-1">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="e.g., Company Guidelines, API Documentation"
required
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage_context" className="text-sm font-medium">Usage Context</Label>
<Select
value={formData.usage_context}
onValueChange={(value: 'always' | 'on_request' | 'contextual') =>
setFormData(prev => ({ ...prev, usage_context: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{USAGE_CONTEXT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
<div>
<div className="font-medium">{option.label}</div>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Brief description of this knowledge (optional)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="content" className="text-sm font-medium">Content *</Label>
<Textarea
id="content"
value={formData.content}
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
placeholder="Enter the knowledge content that your agent should know..."
className="min-h-[200px] resize-y"
required
/>
<div className="text-xs text-muted-foreground">
Approximately {Math.ceil(formData.content.length / 4).toLocaleString()} tokens
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button type="button" variant="outline" onClick={handleCloseDialog}>
Cancel
</Button>
<Button
type="submit"
disabled={!formData.name.trim() || !formData.content.trim() ||
createMutation.isPending || updateMutation.isPending}
className="gap-2"
>
{createMutation.isPending || updateMutation.isPending ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
) : (
<Plus className="h-4 w-4" />
)}
{editDialog.entry ? 'Save Changes' : 'Add Knowledge'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteEntryId} onOpenChange={() => setDeleteEntryId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
Delete Knowledge Entry
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this knowledge entry. Your agent will no longer have access to this information.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteEntryId && handleDelete(deleteEntryId)}
className="bg-destructive hover:bg-destructive/90"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2" />
) : (
<Trash2 className="h-4 w-4" />
)}
Delete Entry
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import { Button } from "@/components/ui/button"
import { FolderOpen, Link, PanelRightOpen, Check, X, Menu, Share2 } from "lucide-react"
import { FolderOpen, Link, PanelRightOpen, Check, X, Menu, Share2, Book } from "lucide-react"
import { usePathname } from "next/navigation"
import { toast } from "sonner"
import {
@ -21,6 +21,14 @@ import { ShareModal } from "@/components/sidebar/share-modal"
import { useQueryClient } from "@tanstack/react-query";
import { projectKeys } from "@/hooks/react-query/sidebar/keys";
import { threadKeys } from "@/hooks/react-query/threads/keys";
import { KnowledgeBaseManager } from "@/components/thread/knowledge-base/knowledge-base-manager";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useFeatureFlags } from "@/lib/feature-flags";
interface ThreadSiteHeaderProps {
threadId: string;
@ -48,7 +56,10 @@ export function SiteHeader({
const [editName, setEditName] = useState(projectName)
const inputRef = useRef<HTMLInputElement>(null)
const [showShareModal, setShowShareModal] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const queryClient = useQueryClient();
const { flags, loading: flagsLoading } = useFeatureFlags(['knowledge_base']);
const knowledgeBaseEnabled = flags.knowledge_base;
const isMobile = useIsMobile() || isMobileView
const { setOpenMobile } = useSidebar()
@ -58,6 +69,10 @@ export function SiteHeader({
setShowShareModal(true)
}
const openKnowledgeBase = () => {
setShowKnowledgeBase(true)
}
const startEditing = () => {
setEditName(projectName);
setIsEditing(true);
@ -216,6 +231,23 @@ export function SiteHeader({
</TooltipContent>
</Tooltip>
{knowledgeBaseEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={openKnowledgeBase}
className="h-9 w-9 cursor-pointer"
>
<Book className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Knowledge Base</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -257,6 +289,22 @@ export function SiteHeader({
threadId={threadId}
projectId={projectId}
/>
<Dialog open={showKnowledgeBase} onOpenChange={setShowKnowledgeBase}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden p-0">
<div className="flex flex-col h-full">
<DialogHeader className="px-6 py-4">
<DialogTitle className="flex items-center gap-2 text-lg">
<Book className="h-5 w-5" />
Knowledge Base
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<KnowledgeBaseManager threadId={threadId} />
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -21,4 +21,6 @@ export * from './dashboard/use-initiate-agent';
export * from './usage/use-health';
export * from './scheduling/use-scheduling';
export * from './scheduling/use-scheduling';
export * from './knowledge-base/use-knowledge-base-queries';

View File

@ -0,0 +1,7 @@
export const knowledgeBaseKeys = {
all: ['knowledge-base'] as const,
threads: () => [...knowledgeBaseKeys.all, 'threads'] as const,
thread: (threadId: string) => [...knowledgeBaseKeys.threads(), threadId] as const,
entry: (entryId: string) => [...knowledgeBaseKeys.all, 'entry', entryId] as const,
context: (threadId: string) => [...knowledgeBaseKeys.all, 'context', threadId] as const,
};

View File

@ -0,0 +1,32 @@
export interface KnowledgeBaseEntry {
entry_id: string;
name: string;
description?: string;
content: string;
usage_context: 'always' | 'on_request' | 'contextual';
is_active: boolean;
content_tokens?: number;
created_at: string;
updated_at: string;
}
export interface KnowledgeBaseListResponse {
entries: KnowledgeBaseEntry[];
total_count: number;
total_tokens: number;
}
export interface CreateKnowledgeBaseEntryRequest {
name: string;
description?: string;
content: string;
usage_context?: 'always' | 'on_request' | 'contextual';
}
export interface UpdateKnowledgeBaseEntryRequest {
name?: string;
description?: string;
content?: string;
usage_context?: 'always' | 'on_request' | 'contextual';
is_active?: boolean;
}

View File

@ -0,0 +1,185 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { createClient } from '@/lib/supabase/client';
import { knowledgeBaseKeys } from './keys';
import { CreateKnowledgeBaseEntryRequest, KnowledgeBaseEntry, KnowledgeBaseListResponse, UpdateKnowledgeBaseEntryRequest } from './types';
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
const useAuthHeaders = () => {
const getHeaders = async () => {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
return {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
};
};
return { getHeaders };
};
export function useKnowledgeBaseEntries(threadId: string, includeInactive = false) {
const { getHeaders } = useAuthHeaders();
return useQuery({
queryKey: knowledgeBaseKeys.thread(threadId),
queryFn: async (): Promise<KnowledgeBaseListResponse> => {
const headers = await getHeaders();
const url = new URL(`${API_URL}/knowledge-base/threads/${threadId}`);
url.searchParams.set('include_inactive', includeInactive.toString());
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to fetch knowledge base entries');
}
return await response.json();
},
enabled: !!threadId,
});
}
export function useKnowledgeBaseEntry(entryId: string) {
const { getHeaders } = useAuthHeaders();
return useQuery({
queryKey: knowledgeBaseKeys.entry(entryId),
queryFn: async (): Promise<KnowledgeBaseEntry> => {
const headers = await getHeaders();
const response = await fetch(`${API_URL}/knowledge-base/${entryId}`, { headers });
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to fetch knowledge base entry');
}
return await response.json();
},
enabled: !!entryId,
});
}
export function useKnowledgeBaseContext(threadId: string, maxTokens = 4000) {
const { getHeaders } = useAuthHeaders();
return useQuery({
queryKey: knowledgeBaseKeys.context(threadId),
queryFn: async () => {
const headers = await getHeaders();
const url = new URL(`${API_URL}/knowledge-base/threads/${threadId}/context`);
url.searchParams.set('max_tokens', maxTokens.toString());
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to fetch knowledge base context');
}
return await response.json();
},
enabled: !!threadId,
});
}
export function useCreateKnowledgeBaseEntry() {
const queryClient = useQueryClient();
const { getHeaders } = useAuthHeaders();
return useMutation({
mutationFn: async ({ threadId, data }: { threadId: string; data: CreateKnowledgeBaseEntryRequest }): Promise<KnowledgeBaseEntry> => {
const headers = await getHeaders();
const response = await fetch(`${API_URL}/knowledge-base/threads/${threadId}`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to create knowledge base entry');
}
return await response.json();
},
onSuccess: (_, { threadId }) => {
queryClient.invalidateQueries({ queryKey: knowledgeBaseKeys.thread(threadId) });
queryClient.invalidateQueries({ queryKey: knowledgeBaseKeys.context(threadId) });
toast.success('Knowledge base entry created successfully');
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
toast.error(`Failed to create knowledge base entry: ${message}`);
},
});
}
export function useUpdateKnowledgeBaseEntry() {
const queryClient = useQueryClient();
const { getHeaders } = useAuthHeaders();
return useMutation({
mutationFn: async ({ entryId, data }: { entryId: string; data: UpdateKnowledgeBaseEntryRequest }): Promise<KnowledgeBaseEntry> => {
const headers = await getHeaders();
const response = await fetch(`${API_URL}/knowledge-base/${entryId}`, {
method: 'PUT',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to update knowledge base entry');
}
return await response.json();
},
onSuccess: (updatedEntry) => {
queryClient.setQueryData(knowledgeBaseKeys.entry(updatedEntry.entry_id), updatedEntry);
queryClient.invalidateQueries({ queryKey: knowledgeBaseKeys.threads() });
queryClient.invalidateQueries({ queryKey: knowledgeBaseKeys.all });
toast.success('Knowledge base entry updated successfully');
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
toast.error(`Failed to update knowledge base entry: ${message}`);
},
});
}
export function useDeleteKnowledgeBaseEntry() {
const queryClient = useQueryClient();
const { getHeaders } = useAuthHeaders();
return useMutation({
mutationFn: async (entryId: string): Promise<void> => {
const headers = await getHeaders();
const response = await fetch(`${API_URL}/knowledge-base/${entryId}`, {
method: 'DELETE',
headers,
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to delete knowledge base entry');
}
},
onSuccess: (_, entryId) => {
queryClient.removeQueries({ queryKey: knowledgeBaseKeys.entry(entryId) });
queryClient.invalidateQueries({ queryKey: knowledgeBaseKeys.threads() });
queryClient.invalidateQueries({ queryKey: knowledgeBaseKeys.all });
toast.success('Knowledge base entry deleted successfully');
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
toast.error(`Failed to delete knowledge base entry: ${message}`);
},
});
}

View File

@ -31,7 +31,7 @@ export class FeatureFlagManager {
}
return FeatureFlagManager.instance;
}
async isEnabled(flagName: string): Promise<boolean> {
try {
const cached = flagCache.get(flagName);