Compare commits

..

10 Commits

Author SHA1 Message Date
Lluis Agusti
104854a008 chore: review suggestions (2) 2026-01-14 19:44:04 +07:00
Lluis Agusti
e4b3d2d4ef chore: review suggestions (1) 2026-01-14 19:32:45 +07:00
Lluis Agusti
dc35acb4e6 chore: prettier 2026-01-14 19:23:26 +07:00
Lluis Agusti
2c70f11ec7 chore: cleanup 2026-01-14 19:03:18 +07:00
Lluis Agusti
be7e1b8635 Merge remote-tracking branch 'origin/dev' into fix/credentials-hell 2026-01-14 18:41:09 +07:00
Lluis Agusti
302f16c472 chore: wip 2026-01-13 23:26:25 +07:00
Lluis Agusti
4fede09fce chore: fixes 2026-01-13 21:27:03 +07:00
Lluis Agusti
4ce617d7e1 chore: wip 2026-01-13 20:07:32 +07:00
Lluis Agusti
8957ecb099 Merge remote-tracking branch 'origin/dev' into fix/run-modal-layout-fixes 2026-01-13 15:37:53 +07:00
Lluis Agusti
d2305d047d chore: wip 2026-01-13 15:37:38 +07:00
74 changed files with 2005 additions and 4289 deletions

View File

@@ -176,7 +176,7 @@ jobs:
}
- name: Run Database Migrations
run: poetry run prisma migrate deploy
run: poetry run prisma migrate dev --name updates
env:
DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }}
DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }}

View File

@@ -18,4 +18,3 @@ load-tests/results/
load-tests/*.json
load-tests/*.log
load-tests/node_modules/*
migrations/*/rollback*.sql

View File

@@ -1,5 +1,4 @@
import uuid
from unittest.mock import AsyncMock, patch
import orjson
import pytest
@@ -18,17 +17,6 @@ setup_test_data = setup_test_data
setup_firecrawl_test_data = setup_firecrawl_test_data
@pytest.fixture(scope="session", autouse=True)
def mock_embedding_functions():
"""Mock embedding functions for all tests to avoid database/API dependencies."""
with patch(
"backend.api.features.store.db.ensure_embedding",
new_callable=AsyncMock,
return_value=True,
):
yield
@pytest.mark.asyncio(scope="session")
async def test_run_agent(setup_test_data):
"""Test that the run_agent tool successfully executes an approved agent"""

View File

@@ -175,6 +175,7 @@ async def callback(
f"Successfully processed OAuth callback for user {user_id} "
f"and provider {provider.value}"
)
return CredentialsMetaResponse(
id=credentials.id,
provider=credentials.provider,
@@ -193,6 +194,7 @@ async def list_credentials(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
credentials = await creds_manager.store.get_all_creds(user_id)
return [
CredentialsMetaResponse(
id=cred.id,
@@ -215,6 +217,7 @@ async def list_credentials_by_provider(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
return [
CredentialsMetaResponse(
id=cred.id,
@@ -831,6 +834,18 @@ async def list_providers() -> List[str]:
return all_providers
@router.get("/providers/system", response_model=List[str])
async def list_system_providers() -> List[str]:
"""
Get a list of providers that have platform credits (system credentials) available.
These providers can be used without the user providing their own API keys.
"""
from backend.integrations.credentials_store import SYSTEM_PROVIDERS
return list(SYSTEM_PROVIDERS)
@router.get("/providers/names", response_model=ProviderNamesResponse)
async def get_provider_names() -> ProviderNamesResponse:
"""

View File

@@ -1,417 +0,0 @@
"""
Content Type Handlers for Unified Embeddings
Pluggable system for different content sources (store agents, blocks, docs).
Each handler knows how to fetch and process its content type for embedding.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from prisma.enums import ContentType
from backend.data.db import query_raw_with_schema
logger = logging.getLogger(__name__)
@dataclass
class ContentItem:
"""Represents a piece of content to be embedded."""
content_id: str # Unique identifier (DB ID or file path)
content_type: ContentType
searchable_text: str # Combined text for embedding
metadata: dict[str, Any] # Content-specific metadata
user_id: str | None = None # For user-scoped content
class ContentHandler(ABC):
"""Base handler for fetching and processing content for embeddings."""
@property
@abstractmethod
def content_type(self) -> ContentType:
"""The ContentType this handler manages."""
pass
@abstractmethod
async def get_missing_items(self, batch_size: int) -> list[ContentItem]:
"""
Fetch items that don't have embeddings yet.
Args:
batch_size: Maximum number of items to return
Returns:
List of ContentItem objects ready for embedding
"""
pass
@abstractmethod
async def get_stats(self) -> dict[str, int]:
"""
Get statistics about embedding coverage.
Returns:
Dict with keys: total, with_embeddings, without_embeddings
"""
pass
class StoreAgentHandler(ContentHandler):
"""Handler for marketplace store agent listings."""
@property
def content_type(self) -> ContentType:
return ContentType.STORE_AGENT
async def get_missing_items(self, batch_size: int) -> list[ContentItem]:
"""Fetch approved store listings without embeddings."""
from backend.api.features.store.embeddings import build_searchable_text
missing = await query_raw_with_schema(
"""
SELECT
slv.id,
slv.name,
slv.description,
slv."subHeading",
slv.categories
FROM {schema_prefix}"StoreListingVersion" slv
LEFT JOIN {schema_prefix}"UnifiedContentEmbedding" uce
ON slv.id = uce."contentId" AND uce."contentType" = 'STORE_AGENT'::{schema_prefix}"ContentType"
WHERE slv."submissionStatus" = 'APPROVED'
AND slv."isDeleted" = false
AND uce."contentId" IS NULL
LIMIT $1
""",
batch_size,
)
return [
ContentItem(
content_id=row["id"],
content_type=ContentType.STORE_AGENT,
searchable_text=build_searchable_text(
name=row["name"],
description=row["description"],
sub_heading=row["subHeading"],
categories=row["categories"] or [],
),
metadata={
"name": row["name"],
"categories": row["categories"] or [],
},
user_id=None, # Store agents are public
)
for row in missing
]
async def get_stats(self) -> dict[str, int]:
"""Get statistics about store agent embedding coverage."""
# Count approved versions
approved_result = await query_raw_with_schema(
"""
SELECT COUNT(*) as count
FROM {schema_prefix}"StoreListingVersion"
WHERE "submissionStatus" = 'APPROVED'
AND "isDeleted" = false
"""
)
total_approved = approved_result[0]["count"] if approved_result else 0
# Count versions with embeddings
embedded_result = await query_raw_with_schema(
"""
SELECT COUNT(*) as count
FROM {schema_prefix}"StoreListingVersion" slv
JOIN {schema_prefix}"UnifiedContentEmbedding" uce ON slv.id = uce."contentId" AND uce."contentType" = 'STORE_AGENT'::{schema_prefix}"ContentType"
WHERE slv."submissionStatus" = 'APPROVED'
AND slv."isDeleted" = false
"""
)
with_embeddings = embedded_result[0]["count"] if embedded_result else 0
return {
"total": total_approved,
"with_embeddings": with_embeddings,
"without_embeddings": total_approved - with_embeddings,
}
class BlockHandler(ContentHandler):
"""Handler for block definitions (Python classes)."""
@property
def content_type(self) -> ContentType:
return ContentType.BLOCK
async def get_missing_items(self, batch_size: int) -> list[ContentItem]:
"""Fetch blocks without embeddings."""
from backend.data.block import get_blocks
# Get all available blocks
all_blocks = get_blocks()
# Check which ones have embeddings
if not all_blocks:
return []
block_ids = list(all_blocks.keys())
# Query for existing embeddings
placeholders = ",".join([f"${i+1}" for i in range(len(block_ids))])
existing_result = await query_raw_with_schema(
f"""
SELECT "contentId"
FROM {{schema_prefix}}"UnifiedContentEmbedding"
WHERE "contentType" = 'BLOCK'::{{schema_prefix}}"ContentType"
AND "contentId" = ANY(ARRAY[{placeholders}])
""",
*block_ids,
)
existing_ids = {row["contentId"] for row in existing_result}
missing_blocks = [
(block_id, block_cls)
for block_id, block_cls in all_blocks.items()
if block_id not in existing_ids
]
# Convert to ContentItem
items = []
for block_id, block_cls in missing_blocks[:batch_size]:
try:
block_instance = block_cls()
# Build searchable text from block metadata
parts = []
if hasattr(block_instance, "name") and block_instance.name:
parts.append(block_instance.name)
if (
hasattr(block_instance, "description")
and block_instance.description
):
parts.append(block_instance.description)
if hasattr(block_instance, "categories") and block_instance.categories:
# Convert BlockCategory enum to strings
parts.append(
" ".join(str(cat.value) for cat in block_instance.categories)
)
# Add input/output schema info
if hasattr(block_instance, "input_schema"):
schema = block_instance.input_schema
if hasattr(schema, "model_json_schema"):
schema_dict = schema.model_json_schema()
if "properties" in schema_dict:
for prop_name, prop_info in schema_dict[
"properties"
].items():
if "description" in prop_info:
parts.append(
f"{prop_name}: {prop_info['description']}"
)
searchable_text = " ".join(parts)
items.append(
ContentItem(
content_id=block_id,
content_type=ContentType.BLOCK,
searchable_text=searchable_text,
metadata={
"name": getattr(block_instance, "name", ""),
"categories": getattr(block_instance, "categories", []),
},
user_id=None, # Blocks are public
)
)
except Exception as e:
logger.warning(f"Failed to process block {block_id}: {e}")
continue
return items
async def get_stats(self) -> dict[str, int]:
"""Get statistics about block embedding coverage."""
from backend.data.block import get_blocks
all_blocks = get_blocks()
total_blocks = len(all_blocks)
if total_blocks == 0:
return {"total": 0, "with_embeddings": 0, "without_embeddings": 0}
block_ids = list(all_blocks.keys())
placeholders = ",".join([f"${i+1}" for i in range(len(block_ids))])
embedded_result = await query_raw_with_schema(
f"""
SELECT COUNT(*) as count
FROM {{schema_prefix}}"UnifiedContentEmbedding"
WHERE "contentType" = 'BLOCK'::{{schema_prefix}}"ContentType"
AND "contentId" = ANY(ARRAY[{placeholders}])
""",
*block_ids,
)
with_embeddings = embedded_result[0]["count"] if embedded_result else 0
return {
"total": total_blocks,
"with_embeddings": with_embeddings,
"without_embeddings": total_blocks - with_embeddings,
}
class DocumentationHandler(ContentHandler):
"""Handler for documentation files (.md/.mdx)."""
@property
def content_type(self) -> ContentType:
return ContentType.DOCUMENTATION
def _get_docs_root(self) -> Path:
"""Get the documentation root directory."""
# Assuming docs are in /docs relative to project root
backend_root = Path(__file__).parent.parent.parent.parent
docs_root = backend_root.parent.parent / "docs"
return docs_root
def _extract_title_and_content(self, file_path: Path) -> tuple[str, str]:
"""Extract title and content from markdown file."""
try:
content = file_path.read_text(encoding="utf-8")
# Try to extract title from first # heading
lines = content.split("\n")
title = ""
body_lines = []
for line in lines:
if line.startswith("# ") and not title:
title = line[2:].strip()
else:
body_lines.append(line)
# If no title found, use filename
if not title:
title = file_path.stem.replace("-", " ").replace("_", " ").title()
body = "\n".join(body_lines)
return title, body
except Exception as e:
logger.warning(f"Failed to read {file_path}: {e}")
return file_path.stem, ""
async def get_missing_items(self, batch_size: int) -> list[ContentItem]:
"""Fetch documentation files without embeddings."""
docs_root = self._get_docs_root()
if not docs_root.exists():
logger.warning(f"Documentation root not found: {docs_root}")
return []
# Find all .md and .mdx files
all_docs = list(docs_root.rglob("*.md")) + list(docs_root.rglob("*.mdx"))
# Get relative paths for content IDs
doc_paths = [str(doc.relative_to(docs_root)) for doc in all_docs]
if not doc_paths:
return []
# Check which ones have embeddings
placeholders = ",".join([f"${i+1}" for i in range(len(doc_paths))])
existing_result = await query_raw_with_schema(
f"""
SELECT "contentId"
FROM {{schema_prefix}}"UnifiedContentEmbedding"
WHERE "contentType" = 'DOCUMENTATION'::{{schema_prefix}}"ContentType"
AND "contentId" = ANY(ARRAY[{placeholders}])
""",
*doc_paths,
)
existing_ids = {row["contentId"] for row in existing_result}
missing_docs = [
(doc_path, doc_file)
for doc_path, doc_file in zip(doc_paths, all_docs)
if doc_path not in existing_ids
]
# Convert to ContentItem
items = []
for doc_path, doc_file in missing_docs[:batch_size]:
try:
title, content = self._extract_title_and_content(doc_file)
# Build searchable text
searchable_text = f"{title} {content}"
items.append(
ContentItem(
content_id=doc_path,
content_type=ContentType.DOCUMENTATION,
searchable_text=searchable_text,
metadata={
"title": title,
"path": doc_path,
},
user_id=None, # Documentation is public
)
)
except Exception as e:
logger.warning(f"Failed to process doc {doc_path}: {e}")
continue
return items
async def get_stats(self) -> dict[str, int]:
"""Get statistics about documentation embedding coverage."""
docs_root = self._get_docs_root()
if not docs_root.exists():
return {"total": 0, "with_embeddings": 0, "without_embeddings": 0}
# Count all .md and .mdx files
all_docs = list(docs_root.rglob("*.md")) + list(docs_root.rglob("*.mdx"))
total_docs = len(all_docs)
if total_docs == 0:
return {"total": 0, "with_embeddings": 0, "without_embeddings": 0}
doc_paths = [str(doc.relative_to(docs_root)) for doc in all_docs]
placeholders = ",".join([f"${i+1}" for i in range(len(doc_paths))])
embedded_result = await query_raw_with_schema(
f"""
SELECT COUNT(*) as count
FROM {{schema_prefix}}"UnifiedContentEmbedding"
WHERE "contentType" = 'DOCUMENTATION'::{{schema_prefix}}"ContentType"
AND "contentId" = ANY(ARRAY[{placeholders}])
""",
*doc_paths,
)
with_embeddings = embedded_result[0]["count"] if embedded_result else 0
return {
"total": total_docs,
"with_embeddings": with_embeddings,
"without_embeddings": total_docs - with_embeddings,
}
# Content handler registry
CONTENT_HANDLERS: dict[ContentType, ContentHandler] = {
ContentType.STORE_AGENT: StoreAgentHandler(),
ContentType.BLOCK: BlockHandler(),
ContentType.DOCUMENTATION: DocumentationHandler(),
}

View File

@@ -1,214 +0,0 @@
"""
Integration tests for content handlers using real DB.
Run with: poetry run pytest backend/api/features/store/content_handlers_integration_test.py -xvs
These tests use the real database but mock OpenAI calls.
"""
from unittest.mock import patch
import pytest
from backend.api.features.store.content_handlers import (
CONTENT_HANDLERS,
BlockHandler,
DocumentationHandler,
StoreAgentHandler,
)
from backend.api.features.store.embeddings import (
backfill_all_content_types,
ensure_content_embedding,
get_embedding_stats,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_store_agent_handler_real_db():
"""Test StoreAgentHandler with real database queries."""
handler = StoreAgentHandler()
# Get stats from real DB
stats = await handler.get_stats()
# Stats should have correct structure
assert "total" in stats
assert "with_embeddings" in stats
assert "without_embeddings" in stats
assert stats["total"] >= 0
assert stats["with_embeddings"] >= 0
assert stats["without_embeddings"] >= 0
# Get missing items (max 1 to keep test fast)
items = await handler.get_missing_items(batch_size=1)
# Items should be list (may be empty if all have embeddings)
assert isinstance(items, list)
if items:
item = items[0]
assert item.content_id is not None
assert item.content_type.value == "STORE_AGENT"
assert item.searchable_text != ""
assert item.user_id is None
@pytest.mark.asyncio(loop_scope="session")
async def test_block_handler_real_db():
"""Test BlockHandler with real database queries."""
handler = BlockHandler()
# Get stats from real DB
stats = await handler.get_stats()
# Stats should have correct structure
assert "total" in stats
assert "with_embeddings" in stats
assert "without_embeddings" in stats
assert stats["total"] >= 0 # Should have at least some blocks
assert stats["with_embeddings"] >= 0
assert stats["without_embeddings"] >= 0
# Get missing items (max 1 to keep test fast)
items = await handler.get_missing_items(batch_size=1)
# Items should be list
assert isinstance(items, list)
if items:
item = items[0]
assert item.content_id is not None # Should be block UUID
assert item.content_type.value == "BLOCK"
assert item.searchable_text != ""
assert item.user_id is None
@pytest.mark.asyncio(loop_scope="session")
async def test_documentation_handler_real_fs():
"""Test DocumentationHandler with real filesystem."""
handler = DocumentationHandler()
# Get stats from real filesystem
stats = await handler.get_stats()
# Stats should have correct structure
assert "total" in stats
assert "with_embeddings" in stats
assert "without_embeddings" in stats
assert stats["total"] >= 0
assert stats["with_embeddings"] >= 0
assert stats["without_embeddings"] >= 0
# Get missing items (max 1 to keep test fast)
items = await handler.get_missing_items(batch_size=1)
# Items should be list
assert isinstance(items, list)
if items:
item = items[0]
assert item.content_id is not None # Should be relative path
assert item.content_type.value == "DOCUMENTATION"
assert item.searchable_text != ""
assert item.user_id is None
@pytest.mark.asyncio(loop_scope="session")
async def test_get_embedding_stats_all_types():
"""Test get_embedding_stats aggregates all content types."""
stats = await get_embedding_stats()
# Should have structure with by_type and totals
assert "by_type" in stats
assert "totals" in stats
# Check each content type is present
by_type = stats["by_type"]
assert "STORE_AGENT" in by_type
assert "BLOCK" in by_type
assert "DOCUMENTATION" in by_type
# Check totals are aggregated
totals = stats["totals"]
assert totals["total"] >= 0
assert totals["with_embeddings"] >= 0
assert totals["without_embeddings"] >= 0
assert "coverage_percent" in totals
@pytest.mark.asyncio(loop_scope="session")
@patch("backend.api.features.store.embeddings.generate_embedding")
async def test_ensure_content_embedding_blocks(mock_generate):
"""Test creating embeddings for blocks (mocked OpenAI)."""
# Mock OpenAI to return fake embedding
mock_generate.return_value = [0.1] * 1536
# Get one block without embedding
handler = BlockHandler()
items = await handler.get_missing_items(batch_size=1)
if not items:
pytest.skip("No blocks without embeddings")
item = items[0]
# Try to create embedding (OpenAI mocked)
result = await ensure_content_embedding(
content_type=item.content_type,
content_id=item.content_id,
searchable_text=item.searchable_text,
metadata=item.metadata,
user_id=item.user_id,
)
# Should succeed with mocked OpenAI
assert result is True
mock_generate.assert_called_once()
@pytest.mark.asyncio(loop_scope="session")
@patch("backend.api.features.store.embeddings.generate_embedding")
async def test_backfill_all_content_types_dry_run(mock_generate):
"""Test backfill_all_content_types processes all handlers in order."""
# Mock OpenAI to return fake embedding
mock_generate.return_value = [0.1] * 1536
# Run backfill with batch_size=1 to process max 1 per type
result = await backfill_all_content_types(batch_size=1)
# Should have results for all content types
assert "by_type" in result
assert "totals" in result
by_type = result["by_type"]
assert "BLOCK" in by_type
assert "STORE_AGENT" in by_type
assert "DOCUMENTATION" in by_type
# Each type should have correct structure
for content_type, type_result in by_type.items():
assert "processed" in type_result
assert "success" in type_result
assert "failed" in type_result
# Totals should aggregate
totals = result["totals"]
assert totals["processed"] >= 0
assert totals["success"] >= 0
assert totals["failed"] >= 0
@pytest.mark.asyncio(loop_scope="session")
async def test_content_handler_registry():
"""Test all handlers are registered in correct order."""
from prisma.enums import ContentType
# All three types should be registered
assert ContentType.STORE_AGENT in CONTENT_HANDLERS
assert ContentType.BLOCK in CONTENT_HANDLERS
assert ContentType.DOCUMENTATION in CONTENT_HANDLERS
# Check handler types
assert isinstance(CONTENT_HANDLERS[ContentType.STORE_AGENT], StoreAgentHandler)
assert isinstance(CONTENT_HANDLERS[ContentType.BLOCK], BlockHandler)
assert isinstance(CONTENT_HANDLERS[ContentType.DOCUMENTATION], DocumentationHandler)

View File

@@ -1,324 +0,0 @@
"""
E2E tests for content handlers (blocks, store agents, documentation).
Tests the full flow: discovering content → generating embeddings → storing.
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from prisma.enums import ContentType
from backend.api.features.store.content_handlers import (
CONTENT_HANDLERS,
BlockHandler,
DocumentationHandler,
StoreAgentHandler,
)
@pytest.mark.asyncio(loop_scope="session")
async def test_store_agent_handler_get_missing_items(mocker):
"""Test StoreAgentHandler fetches approved agents without embeddings."""
handler = StoreAgentHandler()
# Mock database query
mock_missing = [
{
"id": "agent-1",
"name": "Test Agent",
"description": "A test agent",
"subHeading": "Test heading",
"categories": ["AI", "Testing"],
}
]
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=mock_missing,
):
items = await handler.get_missing_items(batch_size=10)
assert len(items) == 1
assert items[0].content_id == "agent-1"
assert items[0].content_type == ContentType.STORE_AGENT
assert "Test Agent" in items[0].searchable_text
assert "A test agent" in items[0].searchable_text
assert items[0].metadata["name"] == "Test Agent"
assert items[0].user_id is None
@pytest.mark.asyncio(loop_scope="session")
async def test_store_agent_handler_get_stats(mocker):
"""Test StoreAgentHandler returns correct stats."""
handler = StoreAgentHandler()
# Mock approved count query
mock_approved = [{"count": 50}]
# Mock embedded count query
mock_embedded = [{"count": 30}]
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
side_effect=[mock_approved, mock_embedded],
):
stats = await handler.get_stats()
assert stats["total"] == 50
assert stats["with_embeddings"] == 30
assert stats["without_embeddings"] == 20
@pytest.mark.asyncio(loop_scope="session")
async def test_block_handler_get_missing_items(mocker):
"""Test BlockHandler discovers blocks without embeddings."""
handler = BlockHandler()
# Mock get_blocks to return test blocks
mock_block_class = MagicMock()
mock_block_instance = MagicMock()
mock_block_instance.name = "Calculator Block"
mock_block_instance.description = "Performs calculations"
mock_block_instance.categories = [MagicMock(value="MATH")]
mock_block_instance.input_schema.model_json_schema.return_value = {
"properties": {"expression": {"description": "Math expression to evaluate"}}
}
mock_block_class.return_value = mock_block_instance
mock_blocks = {"block-uuid-1": mock_block_class}
# Mock existing embeddings query (no embeddings exist)
mock_existing = []
with patch(
"backend.api.features.store.content_handlers.get_blocks",
return_value=mock_blocks,
):
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=mock_existing,
):
items = await handler.get_missing_items(batch_size=10)
assert len(items) == 1
assert items[0].content_id == "block-uuid-1"
assert items[0].content_type == ContentType.BLOCK
assert "Calculator Block" in items[0].searchable_text
assert "Performs calculations" in items[0].searchable_text
assert "MATH" in items[0].searchable_text
assert "expression: Math expression" in items[0].searchable_text
assert items[0].user_id is None
@pytest.mark.asyncio(loop_scope="session")
async def test_block_handler_get_stats(mocker):
"""Test BlockHandler returns correct stats."""
handler = BlockHandler()
# Mock get_blocks
mock_blocks = {
"block-1": MagicMock(),
"block-2": MagicMock(),
"block-3": MagicMock(),
}
# Mock embedded count query (2 blocks have embeddings)
mock_embedded = [{"count": 2}]
with patch(
"backend.api.features.store.content_handlers.get_blocks",
return_value=mock_blocks,
):
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=mock_embedded,
):
stats = await handler.get_stats()
assert stats["total"] == 3
assert stats["with_embeddings"] == 2
assert stats["without_embeddings"] == 1
@pytest.mark.asyncio(loop_scope="session")
async def test_documentation_handler_get_missing_items(tmp_path, mocker):
"""Test DocumentationHandler discovers docs without embeddings."""
handler = DocumentationHandler()
# Create temporary docs directory with test files
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "guide.md").write_text("# Getting Started\n\nThis is a guide.")
(docs_root / "api.mdx").write_text("# API Reference\n\nAPI documentation.")
# Mock _get_docs_root to return temp dir
with patch.object(handler, "_get_docs_root", return_value=docs_root):
# Mock existing embeddings query (no embeddings exist)
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=[],
):
items = await handler.get_missing_items(batch_size=10)
assert len(items) == 2
# Check guide.md
guide_item = next(
(item for item in items if item.content_id == "guide.md"), None
)
assert guide_item is not None
assert guide_item.content_type == ContentType.DOCUMENTATION
assert "Getting Started" in guide_item.searchable_text
assert "This is a guide" in guide_item.searchable_text
assert guide_item.metadata["title"] == "Getting Started"
assert guide_item.user_id is None
# Check api.mdx
api_item = next(
(item for item in items if item.content_id == "api.mdx"), None
)
assert api_item is not None
assert "API Reference" in api_item.searchable_text
@pytest.mark.asyncio(loop_scope="session")
async def test_documentation_handler_get_stats(tmp_path, mocker):
"""Test DocumentationHandler returns correct stats."""
handler = DocumentationHandler()
# Create temporary docs directory
docs_root = tmp_path / "docs"
docs_root.mkdir()
(docs_root / "doc1.md").write_text("# Doc 1")
(docs_root / "doc2.md").write_text("# Doc 2")
(docs_root / "doc3.mdx").write_text("# Doc 3")
# Mock embedded count query (1 doc has embedding)
mock_embedded = [{"count": 1}]
with patch.object(handler, "_get_docs_root", return_value=docs_root):
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=mock_embedded,
):
stats = await handler.get_stats()
assert stats["total"] == 3
assert stats["with_embeddings"] == 1
assert stats["without_embeddings"] == 2
@pytest.mark.asyncio(loop_scope="session")
async def test_documentation_handler_title_extraction(tmp_path):
"""Test DocumentationHandler extracts title from markdown heading."""
handler = DocumentationHandler()
# Test with heading
doc_with_heading = tmp_path / "with_heading.md"
doc_with_heading.write_text("# My Title\n\nContent here")
title, content = handler._extract_title_and_content(doc_with_heading)
assert title == "My Title"
assert "# My Title" not in content
assert "Content here" in content
# Test without heading
doc_without_heading = tmp_path / "no-heading.md"
doc_without_heading.write_text("Just content, no heading")
title, content = handler._extract_title_and_content(doc_without_heading)
assert title == "No Heading" # Uses filename
assert "Just content" in content
@pytest.mark.asyncio(loop_scope="session")
async def test_content_handlers_registry():
"""Test all content types are registered."""
assert ContentType.STORE_AGENT in CONTENT_HANDLERS
assert ContentType.BLOCK in CONTENT_HANDLERS
assert ContentType.DOCUMENTATION in CONTENT_HANDLERS
assert isinstance(CONTENT_HANDLERS[ContentType.STORE_AGENT], StoreAgentHandler)
assert isinstance(CONTENT_HANDLERS[ContentType.BLOCK], BlockHandler)
assert isinstance(CONTENT_HANDLERS[ContentType.DOCUMENTATION], DocumentationHandler)
@pytest.mark.asyncio(loop_scope="session")
async def test_block_handler_handles_missing_attributes():
"""Test BlockHandler gracefully handles blocks with missing attributes."""
handler = BlockHandler()
# Mock block with minimal attributes
mock_block_class = MagicMock()
mock_block_instance = MagicMock()
mock_block_instance.name = "Minimal Block"
# No description, categories, or schema
del mock_block_instance.description
del mock_block_instance.categories
del mock_block_instance.input_schema
mock_block_class.return_value = mock_block_instance
mock_blocks = {"block-minimal": mock_block_class}
with patch(
"backend.api.features.store.content_handlers.get_blocks",
return_value=mock_blocks,
):
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=[],
):
items = await handler.get_missing_items(batch_size=10)
assert len(items) == 1
assert items[0].searchable_text == "Minimal Block"
@pytest.mark.asyncio(loop_scope="session")
async def test_block_handler_skips_failed_blocks():
"""Test BlockHandler skips blocks that fail to instantiate."""
handler = BlockHandler()
# Mock one good block and one bad block
good_block = MagicMock()
good_instance = MagicMock()
good_instance.name = "Good Block"
good_instance.description = "Works fine"
good_instance.categories = []
good_block.return_value = good_instance
bad_block = MagicMock()
bad_block.side_effect = Exception("Instantiation failed")
mock_blocks = {"good-block": good_block, "bad-block": bad_block}
with patch(
"backend.api.features.store.content_handlers.get_blocks",
return_value=mock_blocks,
):
with patch(
"backend.api.features.store.content_handlers.query_raw_with_schema",
return_value=[],
):
items = await handler.get_missing_items(batch_size=10)
# Should only get the good block
assert len(items) == 1
assert items[0].content_id == "good-block"
@pytest.mark.asyncio(loop_scope="session")
async def test_documentation_handler_missing_docs_directory():
"""Test DocumentationHandler handles missing docs directory gracefully."""
handler = DocumentationHandler()
# Mock _get_docs_root to return non-existent path
fake_path = Path("/nonexistent/docs")
with patch.object(handler, "_get_docs_root", return_value=fake_path):
items = await handler.get_missing_items(batch_size=10)
assert items == []
stats = await handler.get_stats()
assert stats["total"] == 0
assert stats["with_embeddings"] == 0
assert stats["without_embeddings"] == 0

View File

@@ -1,7 +1,8 @@
import asyncio
import logging
import typing
from datetime import datetime, timezone
from typing import Any, Literal
from typing import Literal
import fastapi
import prisma.enums
@@ -9,7 +10,7 @@ import prisma.errors
import prisma.models
import prisma.types
from backend.data.db import transaction
from backend.data.db import query_raw_with_schema, transaction
from backend.data.graph import (
GraphMeta,
GraphModel,
@@ -29,8 +30,6 @@ from backend.util.settings import Settings
from . import exceptions as store_exceptions
from . import model as store_model
from .embeddings import ensure_embedding
from .hybrid_search import hybrid_search
logger = logging.getLogger(__name__)
settings = Settings()
@@ -51,77 +50,128 @@ async def get_store_agents(
page_size: int = 20,
) -> store_model.StoreAgentsResponse:
"""
Get PUBLIC store agents from the StoreAgent view.
Search behavior:
- With search_query: Uses hybrid search (semantic + lexical)
- Fallback: If embeddings unavailable, gracefully degrades to lexical-only
- Rationale: User-facing endpoint prioritizes availability over accuracy
Note: Admin operations (approval) use fail-fast to prevent inconsistent state.
Get PUBLIC store agents from the StoreAgent view
"""
logger.debug(
f"Getting store agents. featured={featured}, creators={creators}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
)
search_used_hybrid = False
store_agents: list[store_model.StoreAgent] = []
agents: list[dict[str, Any]] = []
total = 0
total_pages = 0
try:
# If search_query is provided, use hybrid search (embeddings + tsvector)
# If search_query is provided, use full-text search
if search_query:
# Try hybrid search combining semantic and lexical signals
# Falls back to lexical-only if OpenAI unavailable (user-facing, high SLA)
try:
agents, total = await hybrid_search(
query=search_query,
featured=featured,
creators=creators,
category=category,
sorted_by="relevance", # Use hybrid scoring for relevance
page=page,
page_size=page_size,
)
search_used_hybrid = True
except Exception as e:
# Log error but fall back to lexical search for better UX
logger.error(
f"Hybrid search failed (likely OpenAI unavailable), "
f"falling back to lexical search: {e}"
)
# search_used_hybrid remains False, will use fallback path below
offset = (page - 1) * page_size
# Convert hybrid search results (dict format) if hybrid succeeded
if search_used_hybrid:
total_pages = (total + page_size - 1) // page_size
store_agents: list[store_model.StoreAgent] = []
for agent in agents:
try:
store_agent = store_model.StoreAgent(
slug=agent["slug"],
agent_name=agent["agent_name"],
agent_image=(
agent["agent_image"][0] if agent["agent_image"] else ""
),
creator=agent["creator_username"] or "Needs Profile",
creator_avatar=agent["creator_avatar"] or "",
sub_heading=agent["sub_heading"],
description=agent["description"],
runs=agent["runs"],
rating=agent["rating"],
)
store_agents.append(store_agent)
except Exception as e:
logger.error(
f"Error parsing Store agent from hybrid search results: {e}"
)
continue
# Whitelist allowed order_by columns
ALLOWED_ORDER_BY = {
"rating": "rating DESC, rank DESC",
"runs": "runs DESC, rank DESC",
"name": "agent_name ASC, rank ASC",
"updated_at": "updated_at DESC, rank DESC",
}
if not search_used_hybrid:
# Fallback path - use basic search or no search
# Validate and get order clause
if sorted_by and sorted_by in ALLOWED_ORDER_BY:
order_by_clause = ALLOWED_ORDER_BY[sorted_by]
else:
order_by_clause = "updated_at DESC, rank DESC"
# Build WHERE conditions and parameters list
where_parts: list[str] = []
params: list[typing.Any] = [search_query] # $1 - search term
param_index = 2 # Start at $2 for next parameter
# Always filter for available agents
where_parts.append("is_available = true")
if featured:
where_parts.append("featured = true")
if creators and creators:
# Use ANY with array parameter
where_parts.append(f"creator_username = ANY(${param_index})")
params.append(creators)
param_index += 1
if category and category:
where_parts.append(f"${param_index} = ANY(categories)")
params.append(category)
param_index += 1
sql_where_clause: str = " AND ".join(where_parts) if where_parts else "1=1"
# Add pagination params
params.extend([page_size, offset])
limit_param = f"${param_index}"
offset_param = f"${param_index + 1}"
# Execute full-text search query with parameterized values
sql_query = f"""
SELECT
slug,
agent_name,
agent_image,
creator_username,
creator_avatar,
sub_heading,
description,
runs,
rating,
categories,
featured,
is_available,
updated_at,
ts_rank_cd(search, query) AS rank
FROM {{schema_prefix}}"StoreAgent",
plainto_tsquery('english', $1) AS query
WHERE {sql_where_clause}
AND search @@ query
ORDER BY {order_by_clause}
LIMIT {limit_param} OFFSET {offset_param}
"""
# Count query for pagination - only uses search term parameter
count_query = f"""
SELECT COUNT(*) as count
FROM {{schema_prefix}}"StoreAgent",
plainto_tsquery('english', $1) AS query
WHERE {sql_where_clause}
AND search @@ query
"""
# Execute both queries with parameters
agents = await query_raw_with_schema(sql_query, *params)
# For count, use params without pagination (last 2 params)
count_params = params[:-2]
count_result = await query_raw_with_schema(count_query, *count_params)
total = count_result[0]["count"] if count_result else 0
total_pages = (total + page_size - 1) // page_size
# Convert raw results to StoreAgent models
store_agents: list[store_model.StoreAgent] = []
for agent in agents:
try:
store_agent = store_model.StoreAgent(
slug=agent["slug"],
agent_name=agent["agent_name"],
agent_image=(
agent["agent_image"][0] if agent["agent_image"] else ""
),
creator=agent["creator_username"] or "Needs Profile",
creator_avatar=agent["creator_avatar"] or "",
sub_heading=agent["sub_heading"],
description=agent["description"],
runs=agent["runs"],
rating=agent["rating"],
)
store_agents.append(store_agent)
except Exception as e:
logger.error(f"Error parsing Store agent from search results: {e}")
continue
else:
# Non-search query path (original logic)
where_clause: prisma.types.StoreAgentWhereInput = {"is_available": True}
if featured:
where_clause["featured"] = featured
@@ -130,14 +180,6 @@ async def get_store_agents(
if category:
where_clause["categories"] = {"has": category}
# Add basic text search if search_query provided but hybrid failed
if search_query:
where_clause["OR"] = [
{"agent_name": {"contains": search_query, "mode": "insensitive"}},
{"sub_heading": {"contains": search_query, "mode": "insensitive"}},
{"description": {"contains": search_query, "mode": "insensitive"}},
]
order_by = []
if sorted_by == "rating":
order_by.append({"rating": "desc"})
@@ -146,7 +188,7 @@ async def get_store_agents(
elif sorted_by == "name":
order_by.append({"agent_name": "asc"})
db_agents = await prisma.models.StoreAgent.prisma().find_many(
agents = await prisma.models.StoreAgent.prisma().find_many(
where=where_clause,
order=order_by,
skip=(page - 1) * page_size,
@@ -157,7 +199,7 @@ async def get_store_agents(
total_pages = (total + page_size - 1) // page_size
store_agents: list[store_model.StoreAgent] = []
for agent in db_agents:
for agent in agents:
try:
# Create the StoreAgent object safely
store_agent = store_model.StoreAgent(
@@ -1535,7 +1577,7 @@ async def review_store_submission(
)
# Update the AgentGraph with store listing data
await prisma.models.AgentGraph.prisma(tx).update(
await prisma.models.AgentGraph.prisma().update(
where={
"graphVersionId": {
"id": store_listing_version.agentGraphId,
@@ -1550,23 +1592,6 @@ async def review_store_submission(
},
)
# Generate embedding for approved listing (blocking - admin operation)
# Inside transaction: if embedding fails, entire transaction rolls back
embedding_success = await ensure_embedding(
version_id=store_listing_version_id,
name=store_listing_version.name,
description=store_listing_version.description,
sub_heading=store_listing_version.subHeading,
categories=store_listing_version.categories or [],
tx=tx,
)
if not embedding_success:
raise ValueError(
f"Failed to generate embedding for listing {store_listing_version_id}. "
"This is likely due to OpenAI API being unavailable. "
"Please try again later or contact support if the issue persists."
)
await prisma.models.StoreListing.prisma(tx).update(
where={"id": store_listing_version.StoreListing.id},
data={

View File

@@ -1,628 +0,0 @@
"""
Unified Content Embeddings Service
Handles generation and storage of OpenAI embeddings for all content types
(store listings, blocks, documentation, library agents) to enable semantic/hybrid search.
"""
import asyncio
import logging
import time
from typing import Any
import prisma
from prisma.enums import ContentType
from tiktoken import encoding_for_model
from backend.api.features.store.content_handlers import CONTENT_HANDLERS
from backend.data.db import execute_raw_with_schema, query_raw_with_schema
from backend.util.clients import get_openai_client
from backend.util.json import dumps
logger = logging.getLogger(__name__)
# OpenAI embedding model configuration
EMBEDDING_MODEL = "text-embedding-3-small"
# OpenAI embedding token limit (8,191 with 1 token buffer for safety)
EMBEDDING_MAX_TOKENS = 8191
def build_searchable_text(
name: str,
description: str,
sub_heading: str,
categories: list[str],
) -> str:
"""
Build searchable text from listing version fields.
Combines relevant fields into a single string for embedding.
"""
parts = []
# Name is important - include it
if name:
parts.append(name)
# Sub-heading provides context
if sub_heading:
parts.append(sub_heading)
# Description is the main content
if description:
parts.append(description)
# Categories help with semantic matching
if categories:
parts.append(" ".join(categories))
return " ".join(parts)
async def generate_embedding(text: str) -> list[float] | None:
"""
Generate embedding for text using OpenAI API.
Returns None if embedding generation fails.
Fail-fast: no retries to maintain consistency with approval flow.
"""
try:
client = get_openai_client()
if not client:
logger.error("openai_internal_api_key not set, cannot generate embedding")
return None
# Truncate text to token limit using tiktoken
# Character-based truncation is insufficient because token ratios vary by content type
enc = encoding_for_model(EMBEDDING_MODEL)
tokens = enc.encode(text)
if len(tokens) > EMBEDDING_MAX_TOKENS:
tokens = tokens[:EMBEDDING_MAX_TOKENS]
truncated_text = enc.decode(tokens)
logger.info(
f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens"
)
else:
truncated_text = text
start_time = time.time()
response = await client.embeddings.create(
model=EMBEDDING_MODEL,
input=truncated_text,
)
latency_ms = (time.time() - start_time) * 1000
embedding = response.data[0].embedding
logger.info(
f"Generated embedding: {len(embedding)} dims, "
f"{len(tokens)} tokens, {latency_ms:.0f}ms"
)
return embedding
except Exception as e:
logger.error(f"Failed to generate embedding: {e}")
return None
async def store_embedding(
version_id: str,
embedding: list[float],
tx: prisma.Prisma | None = None,
) -> bool:
"""
Store embedding in the database.
BACKWARD COMPATIBILITY: Maintained for existing store listing usage.
DEPRECATED: Use ensure_embedding() instead (includes searchable_text).
"""
return await store_content_embedding(
content_type=ContentType.STORE_AGENT,
content_id=version_id,
embedding=embedding,
searchable_text="", # Empty for backward compat; ensure_embedding() populates this
metadata=None,
user_id=None, # Store agents are public
tx=tx,
)
async def store_content_embedding(
content_type: ContentType,
content_id: str,
embedding: list[float],
searchable_text: str,
metadata: dict | None = None,
user_id: str | None = None,
tx: prisma.Prisma | None = None,
) -> bool:
"""
Store embedding in the unified content embeddings table.
New function for unified content embedding storage.
Uses raw SQL since Prisma doesn't natively support pgvector.
"""
try:
client = tx if tx else prisma.get_client()
# Convert embedding to PostgreSQL vector format
embedding_str = embedding_to_vector_string(embedding)
metadata_json = dumps(metadata or {})
# Upsert the embedding
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
await execute_raw_with_schema(
"""
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
)
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW())
ON CONFLICT ("contentType", "contentId", "userId")
DO UPDATE SET
"embedding" = $4::vector,
"searchableText" = $5,
"metadata" = $6::jsonb,
"updatedAt" = NOW()
WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType"
AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2
AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL))
""",
content_type,
content_id,
user_id,
embedding_str,
searchable_text,
metadata_json,
client=client,
set_public_search_path=True,
)
logger.info(f"Stored embedding for {content_type}:{content_id}")
return True
except Exception as e:
logger.error(f"Failed to store embedding for {content_type}:{content_id}: {e}")
return False
async def get_embedding(version_id: str) -> dict[str, Any] | None:
"""
Retrieve embedding record for a listing version.
BACKWARD COMPATIBILITY: Maintained for existing store listing usage.
Returns dict with storeListingVersionId, embedding, timestamps or None if not found.
"""
result = await get_content_embedding(
ContentType.STORE_AGENT, version_id, user_id=None
)
if result:
# Transform to old format for backward compatibility
return {
"storeListingVersionId": result["contentId"],
"embedding": result["embedding"],
"createdAt": result["createdAt"],
"updatedAt": result["updatedAt"],
}
return None
async def get_content_embedding(
content_type: ContentType, content_id: str, user_id: str | None = None
) -> dict[str, Any] | None:
"""
Retrieve embedding record for any content type.
New function for unified content embedding retrieval.
Returns dict with contentType, contentId, embedding, timestamps or None if not found.
"""
try:
result = await query_raw_with_schema(
"""
SELECT
"contentType",
"contentId",
"userId",
"embedding"::text as "embedding",
"searchableText",
"metadata",
"createdAt",
"updatedAt"
FROM {schema_prefix}"UnifiedContentEmbedding"
WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL))
""",
content_type,
content_id,
user_id,
set_public_search_path=True,
)
if result and len(result) > 0:
return result[0]
return None
except Exception as e:
logger.error(f"Failed to get embedding for {content_type}:{content_id}: {e}")
return None
async def ensure_embedding(
version_id: str,
name: str,
description: str,
sub_heading: str,
categories: list[str],
force: bool = False,
tx: prisma.Prisma | None = None,
) -> bool:
"""
Ensure an embedding exists for the listing version.
Creates embedding if missing. Use force=True to regenerate.
Backward-compatible wrapper for store listings.
Args:
version_id: The StoreListingVersion ID
name: Agent name
description: Agent description
sub_heading: Agent sub-heading
categories: Agent categories
force: Force regeneration even if embedding exists
tx: Optional transaction client
Returns:
True if embedding exists/was created, False on failure
"""
try:
# Check if embedding already exists
if not force:
existing = await get_embedding(version_id)
if existing and existing.get("embedding"):
logger.debug(f"Embedding for version {version_id} already exists")
return True
# Build searchable text for embedding
searchable_text = build_searchable_text(
name, description, sub_heading, categories
)
# Generate new embedding
embedding = await generate_embedding(searchable_text)
if embedding is None:
logger.warning(f"Could not generate embedding for version {version_id}")
return False
# Store the embedding with metadata using new function
metadata = {
"name": name,
"subHeading": sub_heading,
"categories": categories,
}
return await store_content_embedding(
content_type=ContentType.STORE_AGENT,
content_id=version_id,
embedding=embedding,
searchable_text=searchable_text,
metadata=metadata,
user_id=None, # Store agents are public
tx=tx,
)
except Exception as e:
logger.error(f"Failed to ensure embedding for version {version_id}: {e}")
return False
async def delete_embedding(version_id: str) -> bool:
"""
Delete embedding for a listing version.
BACKWARD COMPATIBILITY: Maintained for existing store listing usage.
Note: This is usually handled automatically by CASCADE delete,
but provided for manual cleanup if needed.
"""
return await delete_content_embedding(ContentType.STORE_AGENT, version_id)
async def delete_content_embedding(
content_type: ContentType, content_id: str, user_id: str | None = None
) -> bool:
"""
Delete embedding for any content type.
New function for unified content embedding deletion.
Note: This is usually handled automatically by CASCADE delete,
but provided for manual cleanup if needed.
Args:
content_type: The type of content (STORE_AGENT, LIBRARY_AGENT, etc.)
content_id: The unique identifier for the content
user_id: Optional user ID. For public content (STORE_AGENT, BLOCK), pass None.
For user-scoped content (LIBRARY_AGENT), pass the user's ID to avoid
deleting embeddings belonging to other users.
Returns:
True if deletion succeeded, False otherwise
"""
try:
client = prisma.get_client()
await execute_raw_with_schema(
"""
DELETE FROM {schema_prefix}"UnifiedContentEmbedding"
WHERE "contentType" = $1::{schema_prefix}"ContentType"
AND "contentId" = $2
AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL))
""",
content_type,
content_id,
user_id,
client=client,
)
user_str = f" (user: {user_id})" if user_id else ""
logger.info(f"Deleted embedding for {content_type}:{content_id}{user_str}")
return True
except Exception as e:
logger.error(f"Failed to delete embedding for {content_type}:{content_id}: {e}")
return False
async def get_embedding_stats() -> dict[str, Any]:
"""
Get statistics about embedding coverage for all content types.
Returns stats per content type and overall totals.
"""
try:
stats_by_type = {}
total_items = 0
total_with_embeddings = 0
total_without_embeddings = 0
# Aggregate stats from all handlers
for content_type, handler in CONTENT_HANDLERS.items():
try:
stats = await handler.get_stats()
stats_by_type[content_type.value] = {
"total": stats["total"],
"with_embeddings": stats["with_embeddings"],
"without_embeddings": stats["without_embeddings"],
"coverage_percent": (
round(stats["with_embeddings"] / stats["total"] * 100, 1)
if stats["total"] > 0
else 0
),
}
total_items += stats["total"]
total_with_embeddings += stats["with_embeddings"]
total_without_embeddings += stats["without_embeddings"]
except Exception as e:
logger.error(f"Failed to get stats for {content_type.value}: {e}")
stats_by_type[content_type.value] = {
"total": 0,
"with_embeddings": 0,
"without_embeddings": 0,
"coverage_percent": 0,
"error": str(e),
}
return {
"by_type": stats_by_type,
"totals": {
"total": total_items,
"with_embeddings": total_with_embeddings,
"without_embeddings": total_without_embeddings,
"coverage_percent": (
round(total_with_embeddings / total_items * 100, 1)
if total_items > 0
else 0
),
},
}
except Exception as e:
logger.error(f"Failed to get embedding stats: {e}")
return {
"by_type": {},
"totals": {
"total": 0,
"with_embeddings": 0,
"without_embeddings": 0,
"coverage_percent": 0,
},
"error": str(e),
}
async def backfill_missing_embeddings(batch_size: int = 10) -> dict[str, Any]:
"""
Generate embeddings for approved listings that don't have them.
BACKWARD COMPATIBILITY: Maintained for existing usage.
This now delegates to backfill_all_content_types() to process all content types.
Args:
batch_size: Number of embeddings to generate per content type
Returns:
Dict with success/failure counts aggregated across all content types
"""
# Delegate to the new generic backfill system
result = await backfill_all_content_types(batch_size)
# Return in the old format for backward compatibility
return result["totals"]
async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]:
"""
Generate embeddings for all content types using registered handlers.
Processes content types in order: BLOCK → STORE_AGENT → DOCUMENTATION.
This ensures foundational content (blocks) are searchable first.
Args:
batch_size: Number of embeddings to generate per content type
Returns:
Dict with stats per content type and overall totals
"""
results_by_type = {}
total_processed = 0
total_success = 0
total_failed = 0
# Process content types in explicit order
processing_order = [
ContentType.BLOCK,
ContentType.STORE_AGENT,
ContentType.DOCUMENTATION,
]
for content_type in processing_order:
handler = CONTENT_HANDLERS.get(content_type)
if not handler:
logger.warning(f"No handler registered for {content_type.value}")
continue
try:
logger.info(f"Processing {content_type.value} content type...")
# Get missing items from handler
missing_items = await handler.get_missing_items(batch_size)
if not missing_items:
results_by_type[content_type.value] = {
"processed": 0,
"success": 0,
"failed": 0,
"message": "No missing embeddings",
}
continue
# Process embeddings concurrently for better performance
embedding_tasks = [
ensure_content_embedding(
content_type=item.content_type,
content_id=item.content_id,
searchable_text=item.searchable_text,
metadata=item.metadata,
user_id=item.user_id,
)
for item in missing_items
]
results = await asyncio.gather(*embedding_tasks, return_exceptions=True)
success = sum(1 for result in results if result is True)
failed = len(results) - success
results_by_type[content_type.value] = {
"processed": len(missing_items),
"success": success,
"failed": failed,
"message": f"Backfilled {success} embeddings, {failed} failed",
}
total_processed += len(missing_items)
total_success += success
total_failed += failed
logger.info(
f"{content_type.value}: processed {len(missing_items)}, "
f"success {success}, failed {failed}"
)
except Exception as e:
logger.error(f"Failed to process {content_type.value}: {e}")
results_by_type[content_type.value] = {
"processed": 0,
"success": 0,
"failed": 0,
"error": str(e),
}
return {
"by_type": results_by_type,
"totals": {
"processed": total_processed,
"success": total_success,
"failed": total_failed,
"message": f"Overall: {total_success} succeeded, {total_failed} failed",
},
}
async def embed_query(query: str) -> list[float] | None:
"""
Generate embedding for a search query.
Same as generate_embedding but with clearer intent.
"""
return await generate_embedding(query)
def embedding_to_vector_string(embedding: list[float]) -> str:
"""Convert embedding list to PostgreSQL vector string format."""
return "[" + ",".join(str(x) for x in embedding) + "]"
async def ensure_content_embedding(
content_type: ContentType,
content_id: str,
searchable_text: str,
metadata: dict | None = None,
user_id: str | None = None,
force: bool = False,
tx: prisma.Prisma | None = None,
) -> bool:
"""
Ensure an embedding exists for any content type.
Generic function for creating embeddings for store agents, blocks, docs, etc.
Args:
content_type: ContentType enum value (STORE_AGENT, BLOCK, etc.)
content_id: Unique identifier for the content
searchable_text: Combined text for embedding generation
metadata: Optional metadata to store with embedding
force: Force regeneration even if embedding exists
tx: Optional transaction client
Returns:
True if embedding exists/was created, False on failure
"""
try:
# Check if embedding already exists
if not force:
existing = await get_content_embedding(content_type, content_id, user_id)
if existing and existing.get("embedding"):
logger.debug(
f"Embedding for {content_type}:{content_id} already exists"
)
return True
# Generate new embedding
embedding = await generate_embedding(searchable_text)
if embedding is None:
logger.warning(
f"Could not generate embedding for {content_type}:{content_id}"
)
return False
# Store the embedding
return await store_content_embedding(
content_type=content_type,
content_id=content_id,
embedding=embedding,
searchable_text=searchable_text,
metadata=metadata or {},
user_id=user_id,
tx=tx,
)
except Exception as e:
logger.error(f"Failed to ensure embedding for {content_type}:{content_id}: {e}")
return False

View File

@@ -1,329 +0,0 @@
"""
Integration tests for embeddings with schema handling.
These tests verify that embeddings operations work correctly across different database schemas.
"""
from unittest.mock import AsyncMock, patch
import pytest
from prisma.enums import ContentType
from backend.api.features.store import embeddings
# Schema prefix tests removed - functionality moved to db.raw_with_schema() helper
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_store_content_embedding_with_schema():
"""Test storing embeddings with proper schema handling."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch("prisma.get_client") as mock_get_client:
mock_client = AsyncMock()
mock_get_client.return_value = mock_client
result = await embeddings.store_content_embedding(
content_type=ContentType.STORE_AGENT,
content_id="test-id",
embedding=[0.1] * 1536,
searchable_text="test text",
metadata={"test": "data"},
user_id=None,
)
# Verify the query was called
assert mock_client.execute_raw.called
# Get the SQL query that was executed
call_args = mock_client.execute_raw.call_args
sql_query = call_args[0][0]
# Verify schema prefix is in the query
assert '"platform"."UnifiedContentEmbedding"' in sql_query
# Verify result
assert result is True
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_get_content_embedding_with_schema():
"""Test retrieving embeddings with proper schema handling."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch("prisma.get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.query_raw.return_value = [
{
"contentType": "STORE_AGENT",
"contentId": "test-id",
"userId": None,
"embedding": "[0.1, 0.2]",
"searchableText": "test",
"metadata": {},
"createdAt": "2024-01-01",
"updatedAt": "2024-01-01",
}
]
mock_get_client.return_value = mock_client
result = await embeddings.get_content_embedding(
ContentType.STORE_AGENT,
"test-id",
user_id=None,
)
# Verify the query was called
assert mock_client.query_raw.called
# Get the SQL query that was executed
call_args = mock_client.query_raw.call_args
sql_query = call_args[0][0]
# Verify schema prefix is in the query
assert '"platform"."UnifiedContentEmbedding"' in sql_query
# Verify result
assert result is not None
assert result["contentId"] == "test-id"
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_delete_content_embedding_with_schema():
"""Test deleting embeddings with proper schema handling."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch("prisma.get_client") as mock_get_client:
mock_client = AsyncMock()
mock_get_client.return_value = mock_client
result = await embeddings.delete_content_embedding(
ContentType.STORE_AGENT,
"test-id",
)
# Verify the query was called
assert mock_client.execute_raw.called
# Get the SQL query that was executed
call_args = mock_client.execute_raw.call_args
sql_query = call_args[0][0]
# Verify schema prefix is in the query
assert '"platform"."UnifiedContentEmbedding"' in sql_query
# Verify result
assert result is True
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_get_embedding_stats_with_schema():
"""Test embedding statistics with proper schema handling."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch("prisma.get_client") as mock_get_client:
mock_client = AsyncMock()
# Mock both query results
mock_client.query_raw.side_effect = [
[{"count": 100}], # total_approved
[{"count": 80}], # with_embeddings
]
mock_get_client.return_value = mock_client
result = await embeddings.get_embedding_stats()
# Verify both queries were called
assert mock_client.query_raw.call_count == 2
# Get both SQL queries
first_call = mock_client.query_raw.call_args_list[0]
second_call = mock_client.query_raw.call_args_list[1]
first_sql = first_call[0][0]
second_sql = second_call[0][0]
# Verify schema prefix in both queries
assert '"platform"."StoreListingVersion"' in first_sql
assert '"platform"."StoreListingVersion"' in second_sql
assert '"platform"."UnifiedContentEmbedding"' in second_sql
# Verify results
assert result["total_approved"] == 100
assert result["with_embeddings"] == 80
assert result["without_embeddings"] == 20
assert result["coverage_percent"] == 80.0
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_backfill_missing_embeddings_with_schema():
"""Test backfilling embeddings with proper schema handling."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch("prisma.get_client") as mock_get_client:
mock_client = AsyncMock()
# Mock missing embeddings query
mock_client.query_raw.return_value = [
{
"id": "version-1",
"name": "Test Agent",
"description": "Test description",
"subHeading": "Test heading",
"categories": ["test"],
}
]
mock_get_client.return_value = mock_client
with patch(
"backend.api.features.store.embeddings.ensure_embedding"
) as mock_ensure:
mock_ensure.return_value = True
result = await embeddings.backfill_missing_embeddings(batch_size=10)
# Verify the query was called
assert mock_client.query_raw.called
# Get the SQL query
call_args = mock_client.query_raw.call_args
sql_query = call_args[0][0]
# Verify schema prefix in query
assert '"platform"."StoreListingVersion"' in sql_query
assert '"platform"."UnifiedContentEmbedding"' in sql_query
# Verify ensure_embedding was called
assert mock_ensure.called
# Verify results
assert result["processed"] == 1
assert result["success"] == 1
assert result["failed"] == 0
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_ensure_content_embedding_with_schema():
"""Test ensuring embeddings exist with proper schema handling."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch(
"backend.api.features.store.embeddings.get_content_embedding"
) as mock_get:
# Simulate no existing embedding
mock_get.return_value = None
with patch(
"backend.api.features.store.embeddings.generate_embedding"
) as mock_generate:
mock_generate.return_value = [0.1] * 1536
with patch(
"backend.api.features.store.embeddings.store_content_embedding"
) as mock_store:
mock_store.return_value = True
result = await embeddings.ensure_content_embedding(
content_type=ContentType.STORE_AGENT,
content_id="test-id",
searchable_text="test text",
metadata={"test": "data"},
user_id=None,
force=False,
)
# Verify the flow
assert mock_get.called
assert mock_generate.called
assert mock_store.called
assert result is True
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_backward_compatibility_store_embedding():
"""Test backward compatibility wrapper for store_embedding."""
with patch(
"backend.api.features.store.embeddings.store_content_embedding"
) as mock_store:
mock_store.return_value = True
result = await embeddings.store_embedding(
version_id="test-version-id",
embedding=[0.1] * 1536,
tx=None,
)
# Verify it calls the new function with correct parameters
assert mock_store.called
call_args = mock_store.call_args
assert call_args[1]["content_type"] == ContentType.STORE_AGENT
assert call_args[1]["content_id"] == "test-version-id"
assert call_args[1]["user_id"] is None
assert result is True
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_backward_compatibility_get_embedding():
"""Test backward compatibility wrapper for get_embedding."""
with patch(
"backend.api.features.store.embeddings.get_content_embedding"
) as mock_get:
mock_get.return_value = {
"contentType": "STORE_AGENT",
"contentId": "test-version-id",
"embedding": "[0.1, 0.2]",
"createdAt": "2024-01-01",
"updatedAt": "2024-01-01",
}
result = await embeddings.get_embedding("test-version-id")
# Verify it calls the new function
assert mock_get.called
# Verify it transforms to old format
assert result is not None
assert result["storeListingVersionId"] == "test-version-id"
assert "embedding" in result
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_schema_handling_error_cases():
"""Test error handling in schema-aware operations."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch("prisma.get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.execute_raw.side_effect = Exception("Database error")
mock_get_client.return_value = mock_client
result = await embeddings.store_content_embedding(
content_type=ContentType.STORE_AGENT,
content_id="test-id",
embedding=[0.1] * 1536,
searchable_text="test",
metadata=None,
user_id=None,
)
# Should return False on error, not raise
assert result is False
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View File

@@ -1,387 +0,0 @@
from unittest.mock import AsyncMock, MagicMock, patch
import prisma
import pytest
from prisma import Prisma
from prisma.enums import ContentType
from backend.api.features.store import embeddings
@pytest.fixture(autouse=True)
async def setup_prisma():
"""Setup Prisma client for tests."""
try:
Prisma()
except prisma.errors.ClientAlreadyRegisteredError:
pass
yield
@pytest.mark.asyncio(loop_scope="session")
async def test_build_searchable_text():
"""Test searchable text building from listing fields."""
result = embeddings.build_searchable_text(
name="AI Assistant",
description="A helpful AI assistant for productivity",
sub_heading="Boost your productivity",
categories=["AI", "Productivity"],
)
expected = "AI Assistant Boost your productivity A helpful AI assistant for productivity AI Productivity"
assert result == expected
@pytest.mark.asyncio(loop_scope="session")
async def test_build_searchable_text_empty_fields():
"""Test searchable text building with empty fields."""
result = embeddings.build_searchable_text(
name="", description="Test description", sub_heading="", categories=[]
)
assert result == "Test description"
@pytest.mark.asyncio(loop_scope="session")
async def test_generate_embedding_success():
"""Test successful embedding generation."""
# Mock OpenAI response
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.data = [MagicMock()]
mock_response.data[0].embedding = [0.1, 0.2, 0.3] * 512 # 1536 dimensions
# Use AsyncMock for async embeddings.create method
mock_client.embeddings.create = AsyncMock(return_value=mock_response)
# Patch at the point of use in embeddings.py
with patch(
"backend.api.features.store.embeddings.get_openai_client"
) as mock_get_client:
mock_get_client.return_value = mock_client
result = await embeddings.generate_embedding("test text")
assert result is not None
assert len(result) == 1536
assert result[0] == 0.1
mock_client.embeddings.create.assert_called_once_with(
model="text-embedding-3-small", input="test text"
)
@pytest.mark.asyncio(loop_scope="session")
async def test_generate_embedding_no_api_key():
"""Test embedding generation without API key."""
# Patch at the point of use in embeddings.py
with patch(
"backend.api.features.store.embeddings.get_openai_client"
) as mock_get_client:
mock_get_client.return_value = None
result = await embeddings.generate_embedding("test text")
assert result is None
@pytest.mark.asyncio(loop_scope="session")
async def test_generate_embedding_api_error():
"""Test embedding generation with API error."""
mock_client = MagicMock()
mock_client.embeddings.create = AsyncMock(side_effect=Exception("API Error"))
# Patch at the point of use in embeddings.py
with patch(
"backend.api.features.store.embeddings.get_openai_client"
) as mock_get_client:
mock_get_client.return_value = mock_client
result = await embeddings.generate_embedding("test text")
assert result is None
@pytest.mark.asyncio(loop_scope="session")
async def test_generate_embedding_text_truncation():
"""Test that long text is properly truncated using tiktoken."""
from tiktoken import encoding_for_model
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.data = [MagicMock()]
mock_response.data[0].embedding = [0.1] * 1536
# Use AsyncMock for async embeddings.create method
mock_client.embeddings.create = AsyncMock(return_value=mock_response)
# Patch at the point of use in embeddings.py
with patch(
"backend.api.features.store.embeddings.get_openai_client"
) as mock_get_client:
mock_get_client.return_value = mock_client
# Create text that will exceed 8191 tokens
# Use varied characters to ensure token-heavy text: each word is ~1 token
words = [f"word{i}" for i in range(10000)]
long_text = " ".join(words) # ~10000 tokens
await embeddings.generate_embedding(long_text)
# Verify text was truncated to 8191 tokens
call_args = mock_client.embeddings.create.call_args
truncated_text = call_args.kwargs["input"]
# Count actual tokens in truncated text
enc = encoding_for_model("text-embedding-3-small")
actual_tokens = len(enc.encode(truncated_text))
# Should be at or just under 8191 tokens
assert actual_tokens <= 8191
# Should be close to the limit (not over-truncated)
assert actual_tokens >= 8100
@pytest.mark.asyncio(loop_scope="session")
async def test_store_embedding_success(mocker):
"""Test successful embedding storage."""
mock_client = mocker.AsyncMock()
mock_client.execute_raw = mocker.AsyncMock()
embedding = [0.1, 0.2, 0.3]
result = await embeddings.store_embedding(
version_id="test-version-id", embedding=embedding, tx=mock_client
)
assert result is True
# execute_raw is called twice: once for SET search_path, once for INSERT
assert mock_client.execute_raw.call_count == 2
# First call: SET search_path
first_call_args = mock_client.execute_raw.call_args_list[0][0]
assert "SET search_path" in first_call_args[0]
# Second call: INSERT query with the actual data
second_call_args = mock_client.execute_raw.call_args_list[1][0]
assert "test-version-id" in second_call_args
assert "[0.1,0.2,0.3]" in second_call_args
assert None in second_call_args # userId should be None for store agents
@pytest.mark.asyncio(loop_scope="session")
async def test_store_embedding_database_error(mocker):
"""Test embedding storage with database error."""
mock_client = mocker.AsyncMock()
mock_client.execute_raw.side_effect = Exception("Database error")
embedding = [0.1, 0.2, 0.3]
result = await embeddings.store_embedding(
version_id="test-version-id", embedding=embedding, tx=mock_client
)
assert result is False
@pytest.mark.asyncio(loop_scope="session")
async def test_get_embedding_success():
"""Test successful embedding retrieval."""
mock_result = [
{
"contentType": "STORE_AGENT",
"contentId": "test-version-id",
"userId": None,
"embedding": "[0.1,0.2,0.3]",
"searchableText": "Test text",
"metadata": {},
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
}
]
with patch(
"backend.api.features.store.embeddings.query_raw_with_schema",
return_value=mock_result,
):
result = await embeddings.get_embedding("test-version-id")
assert result is not None
assert result["storeListingVersionId"] == "test-version-id"
assert result["embedding"] == "[0.1,0.2,0.3]"
@pytest.mark.asyncio(loop_scope="session")
async def test_get_embedding_not_found():
"""Test embedding retrieval when not found."""
with patch(
"backend.api.features.store.embeddings.query_raw_with_schema",
return_value=[],
):
result = await embeddings.get_embedding("test-version-id")
assert result is None
@pytest.mark.asyncio(loop_scope="session")
@patch("backend.api.features.store.embeddings.generate_embedding")
@patch("backend.api.features.store.embeddings.store_embedding")
@patch("backend.api.features.store.embeddings.get_embedding")
async def test_ensure_embedding_already_exists(mock_get, mock_store, mock_generate):
"""Test ensure_embedding when embedding already exists."""
mock_get.return_value = {"embedding": "[0.1,0.2,0.3]"}
result = await embeddings.ensure_embedding(
version_id="test-id",
name="Test",
description="Test description",
sub_heading="Test heading",
categories=["test"],
)
assert result is True
mock_generate.assert_not_called()
mock_store.assert_not_called()
@pytest.mark.asyncio(loop_scope="session")
@patch("backend.api.features.store.embeddings.generate_embedding")
@patch("backend.api.features.store.embeddings.store_content_embedding")
@patch("backend.api.features.store.embeddings.get_embedding")
async def test_ensure_embedding_create_new(mock_get, mock_store, mock_generate):
"""Test ensure_embedding creating new embedding."""
mock_get.return_value = None
mock_generate.return_value = [0.1, 0.2, 0.3]
mock_store.return_value = True
result = await embeddings.ensure_embedding(
version_id="test-id",
name="Test",
description="Test description",
sub_heading="Test heading",
categories=["test"],
)
assert result is True
mock_generate.assert_called_once_with("Test Test heading Test description test")
mock_store.assert_called_once_with(
content_type=ContentType.STORE_AGENT,
content_id="test-id",
embedding=[0.1, 0.2, 0.3],
searchable_text="Test Test heading Test description test",
metadata={"name": "Test", "subHeading": "Test heading", "categories": ["test"]},
user_id=None,
tx=None,
)
@pytest.mark.asyncio(loop_scope="session")
@patch("backend.api.features.store.embeddings.generate_embedding")
@patch("backend.api.features.store.embeddings.get_embedding")
async def test_ensure_embedding_generation_fails(mock_get, mock_generate):
"""Test ensure_embedding when generation fails."""
mock_get.return_value = None
mock_generate.return_value = None
result = await embeddings.ensure_embedding(
version_id="test-id",
name="Test",
description="Test description",
sub_heading="Test heading",
categories=["test"],
)
assert result is False
@pytest.mark.asyncio(loop_scope="session")
async def test_get_embedding_stats():
"""Test embedding statistics retrieval."""
# Mock approved count query and embedded count query
mock_approved_result = [{"count": 100}]
mock_embedded_result = [{"count": 75}]
with patch(
"backend.api.features.store.embeddings.query_raw_with_schema",
side_effect=[mock_approved_result, mock_embedded_result],
):
result = await embeddings.get_embedding_stats()
assert result["total_approved"] == 100
assert result["with_embeddings"] == 75
assert result["without_embeddings"] == 25
assert result["coverage_percent"] == 75.0
@pytest.mark.asyncio(loop_scope="session")
@patch("backend.api.features.store.embeddings.ensure_embedding")
async def test_backfill_missing_embeddings_success(mock_ensure):
"""Test backfill with successful embedding generation."""
# Mock missing embeddings query
mock_missing = [
{
"id": "version-1",
"name": "Agent 1",
"description": "Description 1",
"subHeading": "Heading 1",
"categories": ["AI"],
},
{
"id": "version-2",
"name": "Agent 2",
"description": "Description 2",
"subHeading": "Heading 2",
"categories": ["Productivity"],
},
]
# Mock ensure_embedding to succeed for first, fail for second
mock_ensure.side_effect = [True, False]
with patch(
"backend.api.features.store.embeddings.query_raw_with_schema",
return_value=mock_missing,
):
result = await embeddings.backfill_missing_embeddings(batch_size=5)
assert result["processed"] == 2
assert result["success"] == 1
assert result["failed"] == 1
assert mock_ensure.call_count == 2
@pytest.mark.asyncio(loop_scope="session")
async def test_backfill_missing_embeddings_no_missing():
"""Test backfill when no embeddings are missing."""
with patch(
"backend.api.features.store.embeddings.query_raw_with_schema",
return_value=[],
):
result = await embeddings.backfill_missing_embeddings(batch_size=5)
assert result["processed"] == 0
assert result["success"] == 0
assert result["failed"] == 0
assert result["message"] == "No missing embeddings"
@pytest.mark.asyncio(loop_scope="session")
async def test_embedding_to_vector_string():
"""Test embedding to PostgreSQL vector string conversion."""
embedding = [0.1, 0.2, 0.3, -0.4]
result = embeddings.embedding_to_vector_string(embedding)
assert result == "[0.1,0.2,0.3,-0.4]"
@pytest.mark.asyncio(loop_scope="session")
async def test_embed_query():
"""Test embed_query function (alias for generate_embedding)."""
with patch(
"backend.api.features.store.embeddings.generate_embedding"
) as mock_generate:
mock_generate.return_value = [0.1, 0.2, 0.3]
result = await embeddings.embed_query("test query")
assert result == [0.1, 0.2, 0.3]
mock_generate.assert_called_once_with("test query")

View File

@@ -1,393 +0,0 @@
"""
Hybrid Search for Store Agents
Combines semantic (embedding) search with lexical (tsvector) search
for improved relevance in marketplace agent discovery.
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from backend.api.features.store.embeddings import (
embed_query,
embedding_to_vector_string,
)
from backend.data.db import query_raw_with_schema
logger = logging.getLogger(__name__)
@dataclass
class HybridSearchWeights:
"""Weights for combining search signals."""
semantic: float = 0.30 # Embedding cosine similarity
lexical: float = 0.30 # tsvector ts_rank_cd score
category: float = 0.20 # Category match boost
recency: float = 0.10 # Newer agents ranked higher
popularity: float = 0.10 # Agent usage/runs (PageRank-like)
def __post_init__(self):
"""Validate weights are non-negative and sum to approximately 1.0."""
total = (
self.semantic
+ self.lexical
+ self.category
+ self.recency
+ self.popularity
)
if any(
w < 0
for w in [
self.semantic,
self.lexical,
self.category,
self.recency,
self.popularity,
]
):
raise ValueError("All weights must be non-negative")
if not (0.99 <= total <= 1.01):
raise ValueError(f"Weights must sum to ~1.0, got {total:.3f}")
DEFAULT_WEIGHTS = HybridSearchWeights()
# Minimum relevance score threshold - agents below this are filtered out
# With weights (0.30 semantic + 0.30 lexical + 0.20 category + 0.10 recency + 0.10 popularity):
# - 0.20 means at least ~60% semantic match OR strong lexical match required
# - Ensures only genuinely relevant results are returned
# - Recency/popularity alone (0.10 each) won't pass the threshold
DEFAULT_MIN_SCORE = 0.20
@dataclass
class HybridSearchResult:
"""A single search result with score breakdown."""
slug: str
agent_name: str
agent_image: str
creator_username: str
creator_avatar: str
sub_heading: str
description: str
runs: int
rating: float
categories: list[str]
featured: bool
is_available: bool
updated_at: datetime
# Score breakdown (for debugging/tuning)
combined_score: float
semantic_score: float = 0.0
lexical_score: float = 0.0
category_score: float = 0.0
recency_score: float = 0.0
popularity_score: float = 0.0
async def hybrid_search(
query: str,
featured: bool = False,
creators: list[str] | None = None,
category: str | None = None,
sorted_by: (
Literal["relevance", "rating", "runs", "name", "updated_at"] | None
) = None,
page: int = 1,
page_size: int = 20,
weights: HybridSearchWeights | None = None,
min_score: float | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""
Perform hybrid search combining semantic and lexical signals.
Args:
query: Search query string
featured: Filter for featured agents only
creators: Filter by creator usernames
category: Filter by category
sorted_by: Sort order (relevance uses hybrid scoring)
page: Page number (1-indexed)
page_size: Results per page
weights: Custom weights for search signals
min_score: Minimum relevance score threshold (0-1). Results below
this score are filtered out. Defaults to DEFAULT_MIN_SCORE.
Returns:
Tuple of (results list, total count). Returns empty list if no
results meet the minimum relevance threshold.
"""
# Validate inputs
query = query.strip()
if not query:
return [], 0 # Empty query returns no results
if page < 1:
page = 1
if page_size < 1:
page_size = 1
if page_size > 100: # Cap at reasonable limit to prevent performance issues
page_size = 100
if weights is None:
weights = DEFAULT_WEIGHTS
if min_score is None:
min_score = DEFAULT_MIN_SCORE
offset = (page - 1) * page_size
# Generate query embedding
query_embedding = await embed_query(query)
# Build WHERE clause conditions
where_parts: list[str] = ["sa.is_available = true"]
params: list[Any] = []
param_index = 1
# Add search query for lexical matching
params.append(query)
query_param = f"${param_index}"
param_index += 1
# Add lowercased query for category matching
params.append(query.lower())
query_lower_param = f"${param_index}"
param_index += 1
if featured:
where_parts.append("sa.featured = true")
if creators:
where_parts.append(f"sa.creator_username = ANY(${param_index})")
params.append(creators)
param_index += 1
if category:
where_parts.append(f"${param_index} = ANY(sa.categories)")
params.append(category)
param_index += 1
# Safe: where_parts only contains hardcoded strings with $N parameter placeholders
# No user input is concatenated directly into the SQL string
where_clause = " AND ".join(where_parts)
# Embedding is required for hybrid search - fail fast if unavailable
if query_embedding is None or not query_embedding:
# Log detailed error server-side
logger.error(
"Failed to generate query embedding. "
"Check that openai_internal_api_key is configured and OpenAI API is accessible."
)
# Raise generic error to client
raise ValueError("Search service temporarily unavailable")
# Add embedding parameter
embedding_str = embedding_to_vector_string(query_embedding)
params.append(embedding_str)
embedding_param = f"${param_index}"
param_index += 1
# Add weight parameters for SQL calculation
params.append(weights.semantic)
weight_semantic_param = f"${param_index}"
param_index += 1
params.append(weights.lexical)
weight_lexical_param = f"${param_index}"
param_index += 1
params.append(weights.category)
weight_category_param = f"${param_index}"
param_index += 1
params.append(weights.recency)
weight_recency_param = f"${param_index}"
param_index += 1
params.append(weights.popularity)
weight_popularity_param = f"${param_index}"
param_index += 1
# Add min_score parameter
params.append(min_score)
min_score_param = f"${param_index}"
param_index += 1
# Optimized hybrid search query:
# 1. Direct join to UnifiedContentEmbedding via contentId=storeListingVersionId (no redundant JOINs)
# 2. UNION approach (deduplicates agents matching both branches)
# 3. COUNT(*) OVER() to get total count in single query
# 4. Optimized category matching with EXISTS + unnest
# 5. Pre-calculated max values for lexical and popularity normalization
# 6. Simplified recency calculation with linear decay
# 7. Logarithmic popularity scaling to prevent viral agents from dominating
sql_query = f"""
WITH candidates AS (
-- Lexical matches (uses GIN index on search column)
SELECT sa."storeListingVersionId"
FROM {{schema_prefix}}"StoreAgent" sa
WHERE {where_clause}
AND sa.search @@ plainto_tsquery('english', {query_param})
UNION
-- Semantic matches (uses HNSW index on embedding with KNN)
SELECT "storeListingVersionId"
FROM (
SELECT sa."storeListingVersionId", uce.embedding
FROM {{schema_prefix}}"StoreAgent" sa
INNER JOIN {{schema_prefix}}"UnifiedContentEmbedding" uce
ON sa."storeListingVersionId" = uce."contentId" AND uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
WHERE {where_clause}
ORDER BY uce.embedding <=> {embedding_param}::vector
LIMIT 200
) semantic_results
),
search_scores AS (
SELECT
sa.slug,
sa.agent_name,
sa.agent_image,
sa.creator_username,
sa.creator_avatar,
sa.sub_heading,
sa.description,
sa.runs,
sa.rating,
sa.categories,
sa.featured,
sa.is_available,
sa.updated_at,
-- Semantic score: cosine similarity (1 - distance)
COALESCE(1 - (uce.embedding <=> {embedding_param}::vector), 0) as semantic_score,
-- Lexical score: ts_rank_cd (will be normalized later)
COALESCE(ts_rank_cd(sa.search, plainto_tsquery('english', {query_param})), 0) as lexical_raw,
-- Category match: optimized with unnest for better performance
CASE
WHEN EXISTS (
SELECT 1 FROM unnest(sa.categories) cat
WHERE LOWER(cat) LIKE '%' || {query_lower_param} || '%'
)
THEN 1.0
ELSE 0.0
END as category_score,
-- Recency score: linear decay over 90 days (simpler than exponential)
GREATEST(0, 1 - EXTRACT(EPOCH FROM (NOW() - sa.updated_at)) / (90 * 24 * 3600)) as recency_score,
-- Popularity raw: agent runs count (will be normalized with log scaling)
sa.runs as popularity_raw
FROM candidates c
INNER JOIN {{schema_prefix}}"StoreAgent" sa
ON c."storeListingVersionId" = sa."storeListingVersionId"
LEFT JOIN {{schema_prefix}}"UnifiedContentEmbedding" uce
ON sa."storeListingVersionId" = uce."contentId" AND uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
),
max_lexical AS (
SELECT MAX(lexical_raw) as max_val FROM search_scores
),
max_popularity AS (
SELECT MAX(popularity_raw) as max_val FROM search_scores
),
normalized AS (
SELECT
ss.*,
-- Normalize lexical score by pre-calculated max
CASE
WHEN ml.max_val > 0
THEN ss.lexical_raw / ml.max_val
ELSE 0
END as lexical_score,
-- Normalize popularity with logarithmic scaling to prevent viral agents from dominating
-- LOG(1 + runs) / LOG(1 + max_runs) ensures score is 0-1 range
CASE
WHEN mp.max_val > 0 AND ss.popularity_raw > 0
THEN LN(1 + ss.popularity_raw) / LN(1 + mp.max_val)
ELSE 0
END as popularity_score
FROM search_scores ss
CROSS JOIN max_lexical ml
CROSS JOIN max_popularity mp
),
scored AS (
SELECT
slug,
agent_name,
agent_image,
creator_username,
creator_avatar,
sub_heading,
description,
runs,
rating,
categories,
featured,
is_available,
updated_at,
semantic_score,
lexical_score,
category_score,
recency_score,
popularity_score,
(
{weight_semantic_param} * semantic_score +
{weight_lexical_param} * lexical_score +
{weight_category_param} * category_score +
{weight_recency_param} * recency_score +
{weight_popularity_param} * popularity_score
) as combined_score
FROM normalized
),
filtered AS (
SELECT
*,
COUNT(*) OVER () as total_count
FROM scored
WHERE combined_score >= {min_score_param}
)
SELECT * FROM filtered
ORDER BY combined_score DESC
LIMIT ${param_index} OFFSET ${param_index + 1}
"""
# Add pagination params
params.extend([page_size, offset])
# Execute search query - includes total_count via window function
results = await query_raw_with_schema(
sql_query, *params, set_public_search_path=True
)
# Extract total count from first result (all rows have same count)
total = results[0]["total_count"] if results else 0
# Remove total_count from results before returning
for result in results:
result.pop("total_count", None)
# Log without sensitive query content
logger.info(f"Hybrid search: {len(results)} results, {total} total")
return results, total
async def hybrid_search_simple(
query: str,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
"""
Simplified hybrid search for common use cases.
Uses default weights and no filters.
"""
return await hybrid_search(
query=query,
page=page,
page_size=page_size,
)

View File

@@ -1,334 +0,0 @@
"""
Integration tests for hybrid search with schema handling.
These tests verify that hybrid search works correctly across different database schemas.
"""
from unittest.mock import patch
import pytest
from backend.api.features.store.hybrid_search import HybridSearchWeights, hybrid_search
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_with_schema_handling():
"""Test that hybrid search correctly handles database schema prefixes."""
# Test with a mock query to ensure schema handling works
query = "test agent"
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
# Mock the query result
mock_query.return_value = [
{
"slug": "test/agent",
"agent_name": "Test Agent",
"agent_image": "test.png",
"creator_username": "test",
"creator_avatar": "avatar.png",
"sub_heading": "Test sub-heading",
"description": "Test description",
"runs": 10,
"rating": 4.5,
"categories": ["test"],
"featured": False,
"is_available": True,
"updated_at": "2024-01-01T00:00:00Z",
"combined_score": 0.8,
"semantic_score": 0.7,
"lexical_score": 0.6,
"category_score": 0.5,
"recency_score": 0.4,
"total_count": 1,
}
]
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536 # Mock embedding
results, total = await hybrid_search(
query=query,
page=1,
page_size=20,
)
# Verify the query was called
assert mock_query.called
# Verify the SQL template uses schema_prefix placeholder
call_args = mock_query.call_args
sql_template = call_args[0][0]
assert "{schema_prefix}" in sql_template
# Verify results
assert len(results) == 1
assert total == 1
assert results[0]["slug"] == "test/agent"
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_with_public_schema():
"""Test hybrid search when using public schema (no prefix needed)."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "public"
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
mock_query.return_value = []
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
results, total = await hybrid_search(
query="test",
page=1,
page_size=20,
)
# Verify the mock was set up correctly
assert mock_schema.return_value == "public"
# Results should work even with empty results
assert results == []
assert total == 0
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_with_custom_schema():
"""Test hybrid search when using custom schema (e.g., 'platform')."""
with patch("backend.data.db.get_database_schema") as mock_schema:
mock_schema.return_value = "platform"
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
mock_query.return_value = []
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
results, total = await hybrid_search(
query="test",
page=1,
page_size=20,
)
# Verify the mock was set up correctly
assert mock_schema.return_value == "platform"
assert results == []
assert total == 0
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_without_embeddings():
"""Test hybrid search fails fast when embeddings are unavailable."""
# Patch where the function is used, not where it's defined
with patch("backend.api.features.store.hybrid_search.embed_query") as mock_embed:
# Simulate embedding failure
mock_embed.return_value = None
# Should raise ValueError with helpful message
with pytest.raises(ValueError) as exc_info:
await hybrid_search(
query="test",
page=1,
page_size=20,
)
# Verify error message is generic (doesn't leak implementation details)
assert "Search service temporarily unavailable" in str(exc_info.value)
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_with_filters():
"""Test hybrid search with various filters."""
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
mock_query.return_value = []
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
# Test with featured filter
results, total = await hybrid_search(
query="test",
featured=True,
creators=["user1", "user2"],
category="productivity",
page=1,
page_size=10,
)
# Verify filters were applied in the query
call_args = mock_query.call_args
params = call_args[0][1:] # Skip SQL template
# Should have query, query_lower, creators array, category
assert len(params) >= 4
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_weights():
"""Test hybrid search with custom weights."""
custom_weights = HybridSearchWeights(
semantic=0.5,
lexical=0.3,
category=0.1,
recency=0.1,
popularity=0.0,
)
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
mock_query.return_value = []
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
results, total = await hybrid_search(
query="test",
weights=custom_weights,
page=1,
page_size=20,
)
# Verify custom weights were used in the query
call_args = mock_query.call_args
sql_template = call_args[0][0]
params = call_args[0][1:] # Get all parameters passed
# Check that SQL uses parameterized weights (not f-string interpolation)
assert "$" in sql_template # Verify parameterization is used
# Check that custom weights are in the params
assert 0.5 in params # semantic weight
assert 0.3 in params # lexical weight
assert 0.1 in params # category and recency weights
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_min_score_filtering():
"""Test hybrid search minimum score threshold."""
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
# Return results with varying scores
mock_query.return_value = [
{
"slug": "high-score/agent",
"agent_name": "High Score Agent",
"combined_score": 0.8,
"total_count": 1,
# ... other fields
}
]
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
# Test with custom min_score
results, total = await hybrid_search(
query="test",
min_score=0.5, # High threshold
page=1,
page_size=20,
)
# Verify min_score was applied in query
call_args = mock_query.call_args
sql_template = call_args[0][0]
params = call_args[0][1:] # Get all parameters
# Check that SQL uses parameterized min_score
assert "combined_score >=" in sql_template
assert "$" in sql_template # Verify parameterization
# Check that custom min_score is in the params
assert 0.5 in params
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_pagination():
"""Test hybrid search pagination."""
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
mock_query.return_value = []
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
# Test page 2 with page_size 10
results, total = await hybrid_search(
query="test",
page=2,
page_size=10,
)
# Verify pagination parameters
call_args = mock_query.call_args
params = call_args[0]
# Last two params should be LIMIT and OFFSET
limit = params[-2]
offset = params[-1]
assert limit == 10 # page_size
assert offset == 10 # (page - 1) * page_size = (2 - 1) * 10
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.integration
async def test_hybrid_search_error_handling():
"""Test hybrid search error handling."""
with patch(
"backend.api.features.store.hybrid_search.query_raw_with_schema"
) as mock_query:
# Simulate database error
mock_query.side_effect = Exception("Database connection error")
with patch(
"backend.api.features.store.hybrid_search.embed_query"
) as mock_embed:
mock_embed.return_value = [0.1] * 1536
# Should raise exception
with pytest.raises(Exception) as exc_info:
await hybrid_search(
query="test",
page=1,
page_size=20,
)
assert "Database connection error" in str(exc_info.value)
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View File

@@ -38,20 +38,6 @@ POOL_TIMEOUT = os.getenv("DB_POOL_TIMEOUT")
if POOL_TIMEOUT:
DATABASE_URL = add_param(DATABASE_URL, "pool_timeout", POOL_TIMEOUT)
# Add public schema to search_path for pgvector type access
# The vector extension is in public schema, but search_path is determined by schema parameter
# Extract the schema from DATABASE_URL or default to 'platform'
parsed_url = urlparse(DATABASE_URL)
url_params = dict(parse_qsl(parsed_url.query))
db_schema = url_params.get("schema", "platform")
# Build search_path, avoiding duplicates if db_schema is already 'public'
search_path_schemas = list(
dict.fromkeys([db_schema, "public"])
) # Preserves order, removes duplicates
search_path = ",".join(search_path_schemas)
# This allows using ::vector without schema qualification
DATABASE_URL = add_param(DATABASE_URL, "options", f"-c search_path={search_path}")
HTTP_TIMEOUT = int(POOL_TIMEOUT) if POOL_TIMEOUT else None
prisma = Prisma(
@@ -122,102 +108,21 @@ def get_database_schema() -> str:
return query_params.get("schema", "public")
async def _raw_with_schema(
query_template: str,
*args,
execute: bool = False,
client: Prisma | None = None,
set_public_search_path: bool = False,
) -> list[dict] | int:
"""Internal: Execute raw SQL with proper schema handling.
Use query_raw_with_schema() or execute_raw_with_schema() instead.
Args:
query_template: SQL query with {schema_prefix} placeholder
*args: Query parameters
execute: If False, executes SELECT query. If True, executes INSERT/UPDATE/DELETE.
client: Optional Prisma client for transactions (only used when execute=True).
set_public_search_path: If True, sets search_path to include public schema.
Needed for pgvector types and other public schema objects.
Returns:
- list[dict] if execute=False (query results)
- int if execute=True (number of affected rows)
"""
async def query_raw_with_schema(query_template: str, *args) -> list[dict]:
"""Execute raw SQL query with proper schema handling."""
schema = get_database_schema()
schema_prefix = f'"{schema}".' if schema != "public" else ""
formatted_query = query_template.format(schema_prefix=schema_prefix)
import prisma as prisma_module
db_client = client if client else prisma_module.get_client()
# Set search_path to include public schema if requested
# Prisma doesn't support the 'options' connection parameter, so we set it per-session
# This is idempotent and safe to call multiple times
if set_public_search_path:
await db_client.execute_raw(f"SET search_path = {schema}, public") # type: ignore
if execute:
result = await db_client.execute_raw(formatted_query, *args) # type: ignore
else:
result = await db_client.query_raw(formatted_query, *args) # type: ignore
result = await prisma_module.get_client().query_raw(
formatted_query, *args # type: ignore
)
return result
async def query_raw_with_schema(
query_template: str, *args, set_public_search_path: bool = False
) -> list[dict]:
"""Execute raw SQL SELECT query with proper schema handling.
Args:
query_template: SQL query with {schema_prefix} placeholder
*args: Query parameters
set_public_search_path: If True, sets search_path to include public schema.
Needed for pgvector types and other public schema objects.
Returns:
List of result rows as dictionaries
Example:
results = await query_raw_with_schema(
'SELECT * FROM {schema_prefix}"User" WHERE id = $1',
user_id
)
"""
return await _raw_with_schema(query_template, *args, execute=False, set_public_search_path=set_public_search_path) # type: ignore
async def execute_raw_with_schema(
query_template: str,
*args,
client: Prisma | None = None,
set_public_search_path: bool = False,
) -> int:
"""Execute raw SQL command (INSERT/UPDATE/DELETE) with proper schema handling.
Args:
query_template: SQL query with {schema_prefix} placeholder
*args: Query parameters
client: Optional Prisma client for transactions
set_public_search_path: If True, sets search_path to include public schema.
Needed for pgvector types and other public schema objects.
Returns:
Number of affected rows
Example:
await execute_raw_with_schema(
'INSERT INTO {schema_prefix}"User" (id, name) VALUES ($1, $2)',
user_id, name,
client=tx # Optional transaction client
)
"""
return await _raw_with_schema(query_template, *args, execute=True, client=client, set_public_search_path=set_public_search_path) # type: ignore
class BaseDbModel(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))

View File

@@ -1,6 +1,5 @@
import json
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import UUID
import fastapi.exceptions
@@ -19,17 +18,6 @@ from backend.usecases.sample import create_test_user
from backend.util.test import SpinTestServer
@pytest.fixture(scope="session", autouse=True)
def mock_embedding_functions():
"""Mock embedding functions for all tests to avoid database/API dependencies."""
with patch(
"backend.api.features.store.db.ensure_embedding",
new_callable=AsyncMock,
return_value=True,
):
yield
@pytest.mark.asyncio(loop_scope="session")
async def test_graph_creation(server: SpinTestServer, snapshot: Snapshot):
"""

View File

@@ -7,10 +7,6 @@ from backend.api.features.library.db import (
list_library_agents,
)
from backend.api.features.store.db import get_store_agent_details, get_store_agents
from backend.api.features.store.embeddings import (
backfill_missing_embeddings,
get_embedding_stats,
)
from backend.data import db
from backend.data.analytics import (
get_accuracy_trends_and_alerts,
@@ -212,10 +208,6 @@ class DatabaseManager(AppService):
get_store_agents = _(get_store_agents)
get_store_agent_details = _(get_store_agent_details)
# Store Embeddings
get_embedding_stats = _(get_embedding_stats)
backfill_missing_embeddings = _(backfill_missing_embeddings)
# Summary data - async
get_user_execution_summary_data = _(get_user_execution_summary_data)
@@ -267,10 +259,6 @@ class DatabaseManagerClient(AppServiceClient):
get_store_agents = _(d.get_store_agents)
get_store_agent_details = _(d.get_store_agent_details)
# Store Embeddings
get_embedding_stats = _(d.get_embedding_stats)
backfill_missing_embeddings = _(d.backfill_missing_embeddings)
class DatabaseManagerAsyncClient(AppServiceClient):
d = DatabaseManager

View File

@@ -1,5 +1,4 @@
import logging
from unittest.mock import AsyncMock, patch
import fastapi.responses
import pytest
@@ -20,17 +19,6 @@ from backend.util.test import SpinTestServer, wait_execution
logger = logging.getLogger(__name__)
@pytest.fixture(scope="session", autouse=True)
def mock_embedding_functions():
"""Mock embedding functions for all tests to avoid database/API dependencies."""
with patch(
"backend.api.features.store.db.ensure_embedding",
new_callable=AsyncMock,
return_value=True,
):
yield
async def create_graph(s: SpinTestServer, g: graph.Graph, u: User) -> graph.Graph:
logger.info(f"Creating graph for user {u.id}")
return await s.agent_server.test_create_graph(CreateGraph(graph=g), u.id)

View File

@@ -2,7 +2,6 @@ import asyncio
import logging
import os
import threading
import time
import uuid
from enum import Enum
from typing import Optional
@@ -38,7 +37,7 @@ from backend.monitoring import (
report_execution_accuracy_alerts,
report_late_executions,
)
from backend.util.clients import get_database_manager_client, get_scheduler_client
from backend.util.clients import get_scheduler_client
from backend.util.cloud_storage import cleanup_expired_files_async
from backend.util.exceptions import (
GraphNotFoundError,
@@ -255,88 +254,6 @@ def execution_accuracy_alerts():
return report_execution_accuracy_alerts()
def ensure_embeddings_coverage():
"""
Ensure all content types (store agents, blocks, docs) have embeddings for search.
Processes ALL missing embeddings in batches of 10 per content type until 100% coverage.
Missing embeddings = content invisible in hybrid search.
Schedule: Runs every 6 hours (balanced between coverage and API costs).
- Catches new content added between scheduled runs
- Batch size 10 per content type: gradual processing to avoid rate limits
- Manual trigger available via execute_ensure_embeddings_coverage endpoint
"""
db_client = get_database_manager_client()
stats = db_client.get_embedding_stats()
# Check for error from get_embedding_stats() first
if "error" in stats:
logger.error(
f"Failed to get embedding stats: {stats['error']} - skipping backfill"
)
return {"processed": 0, "success": 0, "failed": 0, "error": stats["error"]}
# Extract totals from new stats structure
totals = stats.get("totals", {})
without_embeddings = totals.get("without_embeddings", 0)
coverage_percent = totals.get("coverage_percent", 0)
if without_embeddings == 0:
logger.info("All content has embeddings, skipping backfill")
return {"processed": 0, "success": 0, "failed": 0}
# Log per-content-type stats for visibility
by_type = stats.get("by_type", {})
for content_type, type_stats in by_type.items():
if type_stats.get("without_embeddings", 0) > 0:
logger.info(
f"{content_type}: {type_stats['without_embeddings']} items without embeddings "
f"({type_stats['coverage_percent']}% coverage)"
)
logger.info(
f"Total: {without_embeddings} items without embeddings "
f"({coverage_percent}% coverage) - processing all"
)
total_processed = 0
total_success = 0
total_failed = 0
# Process in batches until no more missing embeddings
while True:
result = db_client.backfill_missing_embeddings(batch_size=10)
total_processed += result["processed"]
total_success += result["success"]
total_failed += result["failed"]
if result["processed"] == 0:
# No more missing embeddings
break
if result["success"] == 0 and result["processed"] > 0:
# All attempts in this batch failed - stop to avoid infinite loop
logger.error(
f"All {result['processed']} embedding attempts failed - stopping backfill"
)
break
# Small delay between batches to avoid rate limits
time.sleep(1)
logger.info(
f"Embedding backfill completed: {total_success}/{total_processed} succeeded, "
f"{total_failed} failed"
)
return {
"processed": total_processed,
"success": total_success,
"failed": total_failed,
}
# Monitoring functions are now imported from monitoring module
@@ -558,19 +475,6 @@ class Scheduler(AppService):
jobstore=Jobstores.EXECUTION.value,
)
# Embedding Coverage - Every 6 hours
# Ensures all approved agents have embeddings for hybrid search
# Critical: missing embeddings = agents invisible in search
self.scheduler.add_job(
ensure_embeddings_coverage,
id="ensure_embeddings_coverage",
trigger="interval",
hours=6,
replace_existing=True,
max_instances=1, # Prevent overlapping runs
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
self.scheduler.add_listener(job_missed_listener, EVENT_JOB_MISSED)
self.scheduler.add_listener(job_max_instances_listener, EVENT_JOB_MAX_INSTANCES)
@@ -728,11 +632,6 @@ class Scheduler(AppService):
"""Manually trigger execution accuracy alert checking."""
return execution_accuracy_alerts()
@expose
def execute_ensure_embeddings_coverage(self):
"""Manually trigger embedding backfill for approved store agents."""
return ensure_embeddings_coverage()
class SchedulerClient(AppServiceClient):
@classmethod

View File

@@ -245,6 +245,21 @@ DEFAULT_CREDENTIALS = [
webshare_proxy_credentials,
]
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
# Set of providers that have system credentials available
SYSTEM_PROVIDERS = {cred.provider for cred in DEFAULT_CREDENTIALS}
def is_system_credential(credential_id: str) -> bool:
"""Check if a credential ID belongs to a system-managed credential."""
return credential_id in SYSTEM_CREDENTIAL_IDS
def is_system_provider(provider: str) -> bool:
"""Check if a provider has system-managed credentials available."""
return provider in SYSTEM_PROVIDERS
class IntegrationCredentialsStore:
def __init__(self):

View File

@@ -10,7 +10,6 @@ from backend.util.settings import Settings
settings = Settings()
if TYPE_CHECKING:
from openai import AsyncOpenAI
from supabase import AClient, Client
from backend.data.execution import (
@@ -140,24 +139,6 @@ async def get_async_supabase() -> "AClient":
)
# ============ OpenAI Client ============ #
@cached(ttl_seconds=3600)
def get_openai_client() -> "AsyncOpenAI | None":
"""
Get a process-cached async OpenAI client for embeddings.
Returns None if API key is not configured.
"""
from openai import AsyncOpenAI
api_key = settings.secrets.openai_internal_api_key
if not api_key:
return None
return AsyncOpenAI(api_key=api_key)
# ============ Notification Queue Helpers ============ #

View File

@@ -1,46 +0,0 @@
-- CreateExtension
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
-- Create in public schema so vector type is available across all schemas
DO $$
BEGIN
CREATE EXTENSION IF NOT EXISTS "vector" WITH SCHEMA "public";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'vector extension not available or already exists, skipping';
END $$;
-- CreateEnum
CREATE TYPE "ContentType" AS ENUM ('STORE_AGENT', 'BLOCK', 'INTEGRATION', 'DOCUMENTATION', 'LIBRARY_AGENT');
-- CreateTable
CREATE TABLE "UnifiedContentEmbedding" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"contentType" "ContentType" NOT NULL,
"contentId" TEXT NOT NULL,
"userId" TEXT,
"embedding" public.vector(1536) NOT NULL,
"searchableText" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "UnifiedContentEmbedding_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "UnifiedContentEmbedding_contentType_idx" ON "UnifiedContentEmbedding"("contentType");
-- CreateIndex
CREATE INDEX "UnifiedContentEmbedding_userId_idx" ON "UnifiedContentEmbedding"("userId");
-- CreateIndex
CREATE INDEX "UnifiedContentEmbedding_contentType_userId_idx" ON "UnifiedContentEmbedding"("contentType", "userId");
-- CreateIndex
-- NULLS NOT DISTINCT ensures only one public (NULL userId) embedding per contentType+contentId
-- Requires PostgreSQL 15+. Supabase uses PostgreSQL 15+.
CREATE UNIQUE INDEX "UnifiedContentEmbedding_contentType_contentId_userId_key" ON "UnifiedContentEmbedding"("contentType", "contentId", "userId") NULLS NOT DISTINCT;
-- CreateIndex
-- HNSW index for fast vector similarity search on embeddings
-- Uses cosine distance operator (<=>), which matches the query in hybrid_search.py
CREATE INDEX "UnifiedContentEmbedding_embedding_idx" ON "UnifiedContentEmbedding" USING hnsw ("embedding" public.vector_cosine_ops);

View File

@@ -1,71 +0,0 @@
-- Acknowledge Supabase-managed extensions to prevent drift warnings
-- These extensions are pre-installed by Supabase in specific schemas
-- This migration ensures they exist where available (Supabase) or skips gracefully (CI)
-- Create schemas (safe in both CI and Supabase)
CREATE SCHEMA IF NOT EXISTS "extensions";
-- Extensions that exist in both CI and Supabase
DO $$
BEGIN
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'pgcrypto extension not available, skipping';
END $$;
DO $$
BEGIN
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'uuid-ossp extension not available, skipping';
END $$;
-- Supabase-specific extensions (skip gracefully in CI)
DO $$
BEGIN
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'pg_stat_statements extension not available, skipping';
END $$;
DO $$
BEGIN
CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'pg_net extension not available, skipping';
END $$;
DO $$
BEGIN
CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'pgjwt extension not available, skipping';
END $$;
DO $$
BEGIN
CREATE SCHEMA IF NOT EXISTS "graphql";
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'pg_graphql extension not available, skipping';
END $$;
DO $$
BEGIN
CREATE SCHEMA IF NOT EXISTS "pgsodium";
CREATE EXTENSION IF NOT EXISTS "pgsodium" WITH SCHEMA "pgsodium";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'pgsodium extension not available, skipping';
END $$;
DO $$
BEGIN
CREATE SCHEMA IF NOT EXISTS "vault";
CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault";
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'supabase_vault extension not available, skipping';
END $$;
-- Return to platform
CREATE SCHEMA IF NOT EXISTS "platform";

View File

@@ -1,15 +1,14 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
extensions = [pgvector(map: "vector")]
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
generator client {
provider = "prisma-client-py"
recursive_type_depth = -1
interface = "asyncio"
previewFeatures = ["views", "fullTextSearch", "postgresqlExtensions"]
previewFeatures = ["views", "fullTextSearch"]
partial_type_generator = "backend/data/partial_types.py"
}
@@ -128,8 +127,8 @@ model BuilderSearchHistory {
updatedAt DateTime @default(now()) @updatedAt
searchQuery String
filter String[] @default([])
byCreator String[] @default([])
filter String[] @default([])
byCreator String[] @default([])
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -722,25 +721,26 @@ view StoreAgent {
storeListingVersionId String
updated_at DateTime
slug String
agent_name String
agent_video String?
agent_output_demo String?
agent_image String[]
slug String
agent_name String
agent_video String?
agent_output_demo String?
agent_image String[]
featured Boolean @default(false)
creator_username String?
creator_avatar String?
sub_heading String
description String
categories String[]
runs Int
rating Float
versions String[]
agentGraphVersions String[]
agentGraphId String
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
featured Boolean @default(false)
creator_username String?
creator_avatar String?
sub_heading String
description String
categories String[]
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
runs Int
rating Float
versions String[]
agentGraphVersions String[]
agentGraphId String
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
// Materialized views used (refreshed every 15 minutes via pg_cron):
// - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId
@@ -856,14 +856,14 @@ model StoreListingVersion {
AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version])
// Content fields
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
instructions String?
categories String[]
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
instructions String?
categories String[]
isFeatured Boolean @default(false)
@@ -899,9 +899,6 @@ model StoreListingVersion {
// Reviews for this specific version
Reviews StoreListingReview[]
// Note: Embeddings now stored in UnifiedContentEmbedding table
// Use contentType=STORE_AGENT and contentId=storeListingVersionId
@@unique([storeListingId, version])
@@index([storeListingId, submissionStatus, isAvailable])
@@index([submissionStatus])
@@ -909,42 +906,6 @@ model StoreListingVersion {
@@index([agentGraphId, agentGraphVersion]) // Non-unique index for efficient lookups
}
// Content type enum for unified search across store agents, blocks, docs
// Note: BLOCK/INTEGRATION are file-based (Python classes), not DB records
// DOCUMENTATION are file-based (.md files), not DB records
// Only STORE_AGENT and LIBRARY_AGENT are stored in database
enum ContentType {
STORE_AGENT // Database: StoreListingVersion
BLOCK // File-based: Python classes in /backend/blocks/
INTEGRATION // File-based: Python classes (blocks with credentials)
DOCUMENTATION // File-based: .md/.mdx files
LIBRARY_AGENT // Database: User's personal agents
}
// Unified embeddings table for all searchable content types
// Supports both public content (userId=null) and user-specific content (userId=userID)
model UnifiedContentEmbedding {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Content identification
contentType ContentType
contentId String // DB ID (storeListingVersionId) or file identifier (block.id, file_path)
userId String? // NULL for public content (store, blocks, docs), userId for private content (library agents)
// Search data
embedding Unsupported("vector(1536)") // pgvector embedding (extension in platform schema)
searchableText String // Combined text for search and fallback
metadata Json @default("{}") // Content-specific metadata
@@unique([contentType, contentId, userId], map: "UnifiedContentEmbedding_contentType_contentId_userId_key")
@@index([contentType])
@@index([userId])
@@index([contentType, userId])
@@index([embedding], map: "UnifiedContentEmbedding_embedding_idx")
}
model StoreListingReview {
id String @id @default(uuid())
createdAt DateTime @default(now())
@@ -1037,16 +998,16 @@ model OAuthApplication {
updatedAt DateTime @updatedAt
// Application metadata
name String
description String?
logoUrl String? // URL to app logo stored in GCS
clientId String @unique
clientSecret String // Hashed with Scrypt (same as API keys)
clientSecretSalt String // Salt for Scrypt hashing
name String
description String?
logoUrl String? // URL to app logo stored in GCS
clientId String @unique
clientSecret String // Hashed with Scrypt (same as API keys)
clientSecretSalt String // Salt for Scrypt hashing
// OAuth configuration
redirectUris String[] // Allowed callback URLs
grantTypes String[] @default(["authorization_code", "refresh_token"])
grantTypes String[] @default(["authorization_code", "refresh_token"])
scopes APIKeyPermission[] // Which permissions the app can request
// Application management

View File

@@ -3,6 +3,13 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
serverExternalPackages: [
"@opentelemetry/instrumentation",
"@opentelemetry/sdk-node",
"import-in-the-middle",
"require-in-the-middle",
],
experimental: {
serverActions: {
bodySizeLimit: "256mb",

View File

@@ -32,6 +32,7 @@
"@hookform/resolvers": "5.2.2",
"@next/third-parties": "15.4.6",
"@phosphor-icons/react": "2.1.10",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-checkbox": "1.3.3",
@@ -117,6 +118,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.2",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5",
"@storybook/addon-docs": "9.1.5",
@@ -140,6 +142,7 @@
"eslint": "8.57.1",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"orval": "7.13.0",
@@ -147,7 +150,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"require-in-the-middle": "7.5.2",
"require-in-the-middle": "8.0.1",
"storybook": "9.1.5",
"tailwindcss": "3.4.17",
"typescript": "5.9.3"
@@ -157,5 +160,10 @@
"public"
]
},
"pnpm": {
"overrides": {
"@opentelemetry/instrumentation": "0.209.0"
}
},
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
}

View File

@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@opentelemetry/instrumentation': 0.209.0
importers:
.:
@@ -20,6 +23,9 @@ importers:
'@phosphor-icons/react':
specifier: 2.1.10
version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-accordion':
specifier: 1.2.12
version: 1.2.12(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-alert-dialog':
specifier: 1.1.15
version: 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -270,6 +276,9 @@ importers:
'@chromatic-com/storybook':
specifier: 4.1.2
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@opentelemetry/instrumentation':
specifier: 0.209.0
version: 0.209.0(@opentelemetry/api@1.9.0)
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
@@ -339,6 +348,9 @@ importers:
eslint-plugin-storybook:
specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 2.0.2
version: 2.0.2
msw:
specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -361,8 +373,8 @@ importers:
specifier: 0.7.1
version: 0.7.1(prettier@3.6.2)
require-in-the-middle:
specifier: 7.5.2
version: 7.5.2
specifier: 8.0.1
version: 8.0.1
storybook:
specifier: 9.1.5
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
@@ -1543,8 +1555,8 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api-logs@0.208.0':
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
'@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
@@ -1695,8 +1707,8 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.7.0
'@opentelemetry/instrumentation@0.208.0':
resolution: {integrity: sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==}
'@opentelemetry/instrumentation@0.209.0':
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
@@ -1810,6 +1822,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.12':
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
peerDependencies:
@@ -2631,7 +2656,7 @@ packages:
'@opentelemetry/api': ^1.9.0
'@opentelemetry/context-async-hooks': ^1.30.1 || ^2.1.0 || ^2.2.0
'@opentelemetry/core': ^1.30.1 || ^2.1.0 || ^2.2.0
'@opentelemetry/instrumentation': '>=0.57.1 <1'
'@opentelemetry/instrumentation': 0.209.0
'@opentelemetry/resources': ^1.30.1 || ^2.1.0 || ^2.2.0
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 || ^2.2.0
'@opentelemetry/semantic-conventions': ^1.37.0
@@ -4957,8 +4982,8 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@2.0.1:
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==}
import-in-the-middle@2.0.2:
resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -6502,10 +6527,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -8716,7 +8737,7 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@opentelemetry/api-logs@0.208.0':
'@opentelemetry/api-logs@0.209.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -8735,7 +8756,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -8743,7 +8764,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
'@types/connect': 3.4.38
transitivePeerDependencies:
@@ -8752,7 +8773,7 @@ snapshots:
'@opentelemetry/instrumentation-dataloader@0.26.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -8760,7 +8781,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
- supports-color
@@ -8769,21 +8790,21 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-generic-pool@0.52.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-graphql@0.56.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -8791,7 +8812,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
- supports-color
@@ -8800,7 +8821,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
forwarded-parse: 2.1.2
transitivePeerDependencies:
@@ -8809,7 +8830,7 @@ snapshots:
'@opentelemetry/instrumentation-ioredis@0.56.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/redis-common': 0.38.2
transitivePeerDependencies:
- supports-color
@@ -8817,7 +8838,7 @@ snapshots:
'@opentelemetry/instrumentation-kafkajs@0.18.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
- supports-color
@@ -8825,7 +8846,7 @@ snapshots:
'@opentelemetry/instrumentation-knex@0.53.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
- supports-color
@@ -8834,7 +8855,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
- supports-color
@@ -8842,14 +8863,14 @@ snapshots:
'@opentelemetry/instrumentation-lru-memoizer@0.53.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-mongodb@0.61.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -8857,14 +8878,14 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-mysql2@0.55.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
'@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
@@ -8873,7 +8894,7 @@ snapshots:
'@opentelemetry/instrumentation-mysql@0.54.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@types/mysql': 2.15.27
transitivePeerDependencies:
- supports-color
@@ -8882,7 +8903,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
'@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0)
'@types/pg': 8.15.6
@@ -8893,7 +8914,7 @@ snapshots:
'@opentelemetry/instrumentation-redis@0.57.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/redis-common': 0.38.2
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
@@ -8902,7 +8923,7 @@ snapshots:
'@opentelemetry/instrumentation-tedious@0.27.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@types/tedious': 4.0.14
transitivePeerDependencies:
- supports-color
@@ -8911,16 +8932,16 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)':
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
import-in-the-middle: 2.0.1
'@opentelemetry/api-logs': 0.209.0
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -9100,7 +9121,7 @@ snapshots:
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -9108,6 +9129,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.17)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.17)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.17)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.17)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.17)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.17
'@types/react-dom': 18.3.5(@types/react@18.3.17)
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -9932,19 +9970,19 @@ snapshots:
- supports-color
- webpack
'@sentry/node-core@10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)':
'@sentry/node-core@10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)':
dependencies:
'@apm-js-collab/tracing-hooks': 0.3.1
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
'@sentry/core': 10.27.0
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.1
import-in-the-middle: 2.0.2
transitivePeerDependencies:
- supports-color
@@ -9953,7 +9991,7 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation-amqplib': 0.55.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation-connect': 0.52.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation-dataloader': 0.26.0(@opentelemetry/api@1.9.0)
@@ -9981,9 +10019,9 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.38.0
'@prisma/instrumentation': 6.19.0(@opentelemetry/api@1.9.0)
'@sentry/core': 10.27.0
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.1
import-in-the-middle: 2.0.2
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
@@ -12792,7 +12830,7 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@2.0.1:
import-in-the-middle@2.0.2:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
@@ -14631,14 +14669,6 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3

View File

@@ -1,4 +1,4 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
import { useState } from "react";

View File

@@ -1,22 +1,22 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useState, useMemo, useRef } from "react";
import { AuthCard } from "@/components/auth/AuthCard";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { AuthCard } from "@/components/auth/AuthCard";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import type {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsType,
} from "@/lib/autogpt-server-api";
import { CheckIcon, CircleIcon } from "@phosphor-icons/react";
import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
import { okData } from "@/app/api/helpers";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useMemo, useRef, useState } from "react";
// All credential types - we accept any type of credential
const ALL_CREDENTIAL_TYPES: CredentialsType[] = [

View File

@@ -3,7 +3,7 @@ import {
CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { Button } from "@/components/__legacy__/ui/button";
import { Calendar } from "@/components/__legacy__/ui/calendar";
import { LocalValuedInput } from "@/components/__legacy__/ui/input";

View File

@@ -1,4 +1,4 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { Card } from "@/components/atoms/Card/Card";
import { Text } from "@/components/atoms/Text/Text";
import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";

View File

@@ -1,32 +1,31 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
@@ -45,7 +44,6 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
@@ -137,13 +135,16 @@ export function NewAgentLibraryView() {
return (
<>
<div className="flex h-full flex-col">
<div className="mx-6 pt-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<div className="mx-6 flex flex-col gap-4 pt-4">
<div className="flex items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<AgentSettingsModal agent={agent} />
</div>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
@@ -170,31 +171,24 @@ export function NewAgentLibraryView() {
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<div className="flex items-center gap-2">
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="flex-1"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
</div>
<SidebarRunsList
@@ -208,12 +202,7 @@ export function NewAgentLibraryView() {
</SectionWrap>
{activeItem ? (
activeItem === "settings" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
@@ -246,8 +235,6 @@ export function NewAgentLibraryView() {
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/>
)
) : sidebarLoading ? (

View File

@@ -3,7 +3,8 @@
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInput";
import { isSystemCredential } from "../CredentialsInputs/helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -71,6 +72,7 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
if (isSystemCredential(credential)) return null;
return (
<CredentialsInput

View File

@@ -0,0 +1,81 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { GearIcon } from "@phosphor-icons/react";
import { useState } from "react";
interface Props {
agent: LibraryAgent;
controlledOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AgentSettingsModal({
agent,
controlledOpen,
onOpenChange,
}: Props) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
function setIsOpen(open: boolean) {
if (onOpenChange) {
onOpenChange(open);
} else {
setInternalIsOpen(open);
}
}
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
if (!hasHITLBlocks) return null;
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
title="Agent Settings"
>
{controlledOpen === undefined && (
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
</Dialog.Trigger>
)}
<Dialog.Content>
<div className="space-y-6">
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
<Text variant="large-semibold">Require human approval</Text>
<Text variant="large" className="mt-1 text-zinc-900">
The agent will pause and wait for your review before
continuing
</Text>
</div>
<Switch
checked={currentSafeMode || false}
onCheckedChange={handleToggle}
disabled={isPending}
className="mt-1"
/>
</div>
</div>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,6 +1,4 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -8,13 +6,11 @@ import {
import { cn } from "@/lib/utils";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { CredentialsFlatView } from "./components/CredentialsFlatView/CredentialsFlatView";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers";
import { isSystemCredential } from "./helpers";
import {
CredentialsInputState,
useCredentialsInput,
@@ -72,115 +68,53 @@ export function CredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
userCredentials,
systemCredentials,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
const selectedCredentialIsSystem =
selectedCredential && isSystemCredential(selectedCredential);
const allCredentials = [...userCredentials, ...systemCredentials];
if (readOnly && selectedCredentialIsSystem) {
return null;
}
return (
<div className={cn("mb-6", className)}>
{showTitle && (
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">
{displayName} credentials
{isOptional && (
<span className="ml-1 text-sm font-normal text-gray-500">
(optional)
</span>
)}
</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
)}
{hasCredentialsToShow ? (
<>
{(credentialsToShow.length > 1 || isOptional) && !readOnly ? (
<CredentialsSelect
credentials={credentialsToShow}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredential}
onSelectCredential={handleCredentialSelect}
onClearCredential={() => onSelectCredential(undefined)}
readOnly={readOnly}
allowNone={isOptional}
variant={variant}
/>
) : (
<div className="mb-4 space-y-2">
{credentialsToShow.map((credential) => {
return (
<CredentialRow
key={credential.id}
credential={credential}
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
})}
</div>
)}
{!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
)}
</>
) : (
!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
)
)}
<CredentialsFlatView
schema={schema}
provider={provider}
displayName={displayName}
credentials={allCredentials}
selectedCredential={selectedCredential}
onSelectCredential={handleCredentialSelect}
onClearCredential={() => onSelectCredential(undefined)}
onAddCredential={handleActionButtonClick}
actionButtonText={actionButtonText}
isOptional={isOptional}
showTitle={showTitle}
readOnly={readOnly}
variant={variant}
/>
{!readOnly && (
<>
{supportsApiKey ? (
{supportsApiKey && (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
@@ -191,15 +125,15 @@ export function CredentialsInput({
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsOAuth2 ? (
)}
{supportsOAuth2 && (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
) : null}
{supportsUserPassword ? (
)}
{supportsUserPassword && (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
@@ -210,8 +144,8 @@ export function CredentialsInput({
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsHostScoped ? (
)}
{supportsHostScoped && (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
@@ -222,20 +156,13 @@ export function CredentialsInput({
}}
siblingInputs={siblingInputs}
/>
) : null}
)}
{oAuthError ? (
{oAuthError && (
<Text variant="body" className="mt-2 text-red-500">
Error: {oAuthError}
</Text>
) : null}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
)}
</>
)}
</div>

View File

@@ -1,11 +1,11 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
} from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -60,7 +60,23 @@ export function APIKeyCredentialsModal({
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 px-2"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API Key..."
{...field}
/>
)}
/>
<FormField
control={form.control}
name="apiKey"
@@ -70,8 +86,7 @@ export function APIKeyCredentialsModal({
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API key..."
size="small"
placeholder="Enter API Key..."
hint={
schema.credentials_scopes ? (
<FormDescription>
@@ -90,20 +105,7 @@ export function APIKeyCredentialsModal({
</>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<Input
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API key..."
size="small"
{...field}
/>
)}
/>
<FormField
control={form.control}
name="expiresAt"
@@ -113,13 +115,31 @@ export function APIKeyCredentialsModal({
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
value={field.value}
onChange={(e) => {
const value = e.target.value;
if (value) {
const dateTime = new Date(value);
dateTime.setHours(0, 0, 0, 0);
const year = dateTime.getFullYear();
const month = String(dateTime.getMonth() + 1).padStart(
2,
"0",
);
const day = String(dateTime.getDate()).padStart(2, "0");
const normalizedValue = `${year}-${month}-${day}T00:00`;
field.onChange(normalizedValue);
} else {
field.onChange(value);
}
}}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
<Button type="submit" className="min-w-68">
Add API Key
</Button>
</form>
</Form>

View File

@@ -1,11 +1,11 @@
import { z } from "zod";
import { useForm, type UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import useCredentials from "@/hooks/useCredentials";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, type UseFormReturn } from "react-hook-form";
import { z } from "zod";
export type APIKeyFormValues = {
apiKey: string;
@@ -40,12 +40,24 @@ export function useAPIKeyCredentialsModal({
expiresAt: z.string().optional(),
});
function getDefaultExpirationDate(): string {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const year = tomorrow.getFullYear();
const month = String(tomorrow.getMonth() + 1).padStart(2, "0");
const day = String(tomorrow.getDate()).padStart(2, "0");
const hours = String(tomorrow.getHours()).padStart(2, "0");
const minutes = String(tomorrow.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
const form = useForm<APIKeyFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
apiKey: "",
title: "",
expiresAt: "",
expiresAt: getDefaultExpirationDate(),
},
});

View File

@@ -7,7 +7,8 @@ import {
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { cn } from "@/lib/utils";
import { CaretDown, DotsThreeVertical } from "@phosphor-icons/react";
import { CaretDownIcon, DotsThreeVertical } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import {
fallbackIcon,
getCredentialDisplayName,
@@ -26,7 +27,7 @@ type CredentialRowProps = {
provider: string;
displayName: string;
onSelect: () => void;
onDelete: () => void;
onDelete?: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
@@ -47,11 +48,32 @@ export function CredentialRow({
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const isNodeVariant = variant === "node";
const containerRef = useRef<HTMLDivElement>(null);
const [showMaskedKey, setShowMaskedKey] = useState(true);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
setShowMaskedKey(width >= 360);
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<div
ref={containerRef}
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
"flex min-w-[20rem] items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
asSelectTrigger && isNodeVariant
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: asSelectTrigger
@@ -73,34 +95,35 @@ export function CredentialRow({
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
"relative flex min-w-0 flex-1 flex-nowrap items-center gap-4",
isNodeVariant && "overflow-hidden",
)}
>
<Text
variant="body"
className={cn(
"tracking-tight",
isNodeVariant
? "truncate"
: "line-clamp-1 flex-[0_0_50%] text-ellipsis",
"min-w-0 flex-1 tracking-tight",
isNodeVariant ? "truncate" : "line-clamp-1 text-ellipsis",
)}
>
{getCredentialDisplayName(credential, displayName)}
</Text>
{!(asSelectTrigger && isNodeVariant) && (
{!(asSelectTrigger && isNodeVariant) && showMaskedKey && (
<Text
variant="large"
className="relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
className={cn(
"absolute top-[65%] -translate-y-1/2 overflow-hidden whitespace-nowrap font-mono tracking-tight",
asSelectTrigger ? "right-0" : "right-6",
)}
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
)}
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
{(showCaret || (asSelectTrigger && !readOnly)) && (
<CaretDownIcon className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && (
{!readOnly && !showCaret && !asSelectTrigger && onDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View File

@@ -0,0 +1,203 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/molecules/Accordion/Accordion";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { SlidersHorizontalIcon } from "lucide-react";
import { CredentialRow } from "../CredentialRow/CredentialRow";
import { CredentialsSelect } from "../CredentialsSelect/CredentialsSelect";
type Credential = {
id: string;
title?: string;
username?: string;
type: string;
provider: string;
};
type Props = {
schema: BlockIOCredentialsSubSchema;
provider: string;
displayName: string;
userCredentials: Credential[];
systemCredentials: Credential[];
isSystemProvider: boolean;
selectedCredential?: CredentialsMetaInput;
onSelectCredential: (credentialId: string) => void;
onClearCredential: () => void;
onAddCredential: () => void;
actionButtonText: string;
isOptional: boolean;
showTitle: boolean;
variant: "default" | "node";
};
export function CredentialsAccordionView({
schema,
provider,
displayName,
userCredentials,
systemCredentials,
isSystemProvider,
selectedCredential,
onSelectCredential,
onClearCredential,
onAddCredential,
actionButtonText,
isOptional,
showTitle,
variant,
}: Props) {
const allCredentials = [...userCredentials, ...systemCredentials];
const hasSystemCredentials = systemCredentials.length > 0;
const hasUserCredentials = userCredentials.length > 0;
const credentialsInAccordion = isSystemProvider
? allCredentials
: systemCredentials;
const shouldOpenAccordionByDefault =
hasSystemCredentials && !isOptional && !selectedCredential;
const showUserCredentialsOutsideAccordion =
!isSystemProvider && hasUserCredentials;
return (
<>
{showTitle && showUserCredentialsOutsideAccordion && (
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">
{displayName} credentials
{isOptional && (
<span className="ml-1 text-sm font-normal text-gray-500">
(optional)
</span>
)}
</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
)}
{showUserCredentialsOutsideAccordion && (
<>
{userCredentials.length > 1 || isOptional ? (
<CredentialsSelect
credentials={userCredentials}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredential}
onSelectCredential={onSelectCredential}
onClearCredential={onClearCredential}
allowNone={isOptional}
variant={variant}
/>
) : (
<div className="mb-4 space-y-2">
{userCredentials.map((credential) => (
<CredentialRow
key={credential.id}
credential={credential}
provider={provider}
displayName={displayName}
onSelect={() => onSelectCredential(credential.id)}
/>
))}
</div>
)}
<Button
variant="secondary"
size="small"
onClick={onAddCredential}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
</>
)}
{hasSystemCredentials && (
<Accordion
type="single"
collapsible
defaultValue={
shouldOpenAccordionByDefault ? "system-credentials" : undefined
}
className={showUserCredentialsOutsideAccordion ? "mt-4" : ""}
>
<AccordionItem value="system-credentials" className="border-none">
<AccordionTrigger className="py-2 text-sm text-muted-foreground hover:no-underline">
<div className="flex items-center gap-1">
<SlidersHorizontalIcon className="size-4" /> System credentials
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 px-1 pt-2">
{showTitle && (
<div className="flex items-center gap-2">
<Text variant="large-medium">
{displayName} credentials
{isOptional && (
<span className="ml-1 text-sm font-normal text-gray-500">
(optional)
</span>
)}
</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
)}
{credentialsInAccordion.length > 0 && (
<CredentialsSelect
credentials={credentialsInAccordion}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredential}
onSelectCredential={onSelectCredential}
onClearCredential={onClearCredential}
allowNone={isOptional}
variant={variant}
/>
)}
{isSystemProvider && (
<Button
variant="secondary"
size="small"
onClick={onAddCredential}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{!showUserCredentialsOutsideAccordion && !isSystemProvider && (
<Button
variant="secondary"
size="small"
onClick={onAddCredential}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
)}
</>
);
}

View File

@@ -0,0 +1,134 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { CredentialRow } from "../CredentialRow/CredentialRow";
import { CredentialsSelect } from "../CredentialsSelect/CredentialsSelect";
type Credential = {
id: string;
title?: string;
username?: string;
type: string;
provider: string;
};
type Props = {
schema: BlockIOCredentialsSubSchema;
provider: string;
displayName: string;
credentials: Credential[];
selectedCredential?: CredentialsMetaInput;
actionButtonText: string;
isOptional: boolean;
showTitle: boolean;
readOnly: boolean;
variant: "default" | "node";
onSelectCredential: (credentialId: string) => void;
onClearCredential: () => void;
onAddCredential: () => void;
};
export function CredentialsFlatView({
schema,
provider,
displayName,
credentials,
selectedCredential,
actionButtonText,
isOptional,
showTitle,
readOnly,
variant,
onSelectCredential,
onClearCredential,
onAddCredential,
}: Props) {
const hasCredentials = credentials.length > 0;
return (
<>
{showTitle && (
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium" className="flex items-center gap-2">
<span className="inline-flex items-center gap-1">
{displayName} credentials
{isOptional && (
<span className="text-sm font-normal text-gray-500">
(optional)
</span>
)}
{!isOptional && !selectedCredential && (
<span className="inline-flex items-center gap-1 text-red-600">
<ExclamationTriangleIcon className="size-4" />
<span className="text-sm font-normal">required</span>
</span>
)}
</span>
</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
)}
{hasCredentials ? (
<>
{(credentials.length > 1 || isOptional) && !readOnly ? (
<CredentialsSelect
credentials={credentials}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredential}
onSelectCredential={onSelectCredential}
onClearCredential={onClearCredential}
readOnly={readOnly}
allowNone={isOptional}
variant={variant}
/>
) : (
<div className="mb-4 space-y-2">
{credentials.map((credential) => (
<CredentialRow
key={credential.id}
credential={credential}
provider={provider}
displayName={displayName}
onSelect={() => onSelectCredential(credential.id)}
readOnly={readOnly}
/>
))}
</div>
)}
{!readOnly && (
<Button
variant="secondary"
size="small"
onClick={onAddCredential}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
)}
</>
) : (
!readOnly && (
<Button
variant="secondary"
size="small"
onClick={onAddCredential}
className="w-fit"
type="button"
>
{actionButtonText}
</Button>
)
)}
</>
);
}

View File

@@ -1,14 +1,4 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
@@ -42,76 +32,77 @@ export function CredentialsSelect({
allowNone = true,
variant = "default",
}: Props) {
// Auto-select first credential if none is selected (only if allowNone is false)
useEffect(() => {
if (!allowNone && !selectedCredentials && credentials.length > 0) {
onSelectCredential(credentials[0].id);
}
}, [allowNone, selectedCredentials, credentials, onSelectCredential]);
const handleValueChange = (value: string) => {
function handleValueChange(e: React.ChangeEvent<HTMLSelectElement>) {
const value = e.target.value;
if (value === "__none__") {
onClearCredential?.();
} else {
onSelectCredential(value);
}
};
}
const selectedCredential = selectedCredentials
? credentials.find((c) => c.id === selectedCredentials.id)
: null;
const displayCredential = selectedCredential
? {
id: selectedCredential.id,
title: selectedCredential.title,
username: selectedCredential.username,
type: selectedCredential.type,
provider: selectedCredential.provider,
}
: allowNone
? {
id: "__none__",
title: "None (skip this credential)",
type: "none",
provider: provider,
}
: {
id: "__placeholder__",
title: "Select credential",
type: "placeholder",
provider: provider,
};
return (
<div className="mb-4 w-full">
<Select
value={selectedCredentials?.id || (allowNone ? "__none__" : "")}
onValueChange={handleValueChange}
>
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
<div className="relative">
<select
value={selectedCredentials?.id ?? "__none__"}
onChange={handleValueChange}
disabled={readOnly}
className="absolute inset-0 z-10 cursor-pointer opacity-0"
aria-label={`Select ${displayName} credential`}
>
{selectedCredentials ? (
<SelectValue key={selectedCredentials.id} asChild>
<CredentialRow
credential={{
id: selectedCredentials.id,
title: selectedCredentials.title || undefined,
type: selectedCredentials.type,
provider: selectedCredentials.provider,
}}
provider={provider}
displayName={displayName}
onSelect={() => {}}
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
variant={variant}
/>
</SelectValue>
{allowNone ? (
<option value="__none__">None (skip this credential)</option>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}
</SelectTrigger>
<SelectContent>
{allowNone && (
<SelectItem key="__none__" value="__none__">
<div className="flex items-center gap-2">
<Text variant="body" className="tracking-tight text-gray-500">
None (skip this credential)
</Text>
</div>
</SelectItem>
<option value="__none__" disabled hidden>
Select a credential
</option>
)}
{credentials.map((credential) => (
<SelectItem key={credential.id} value={credential.id}>
<div className="flex items-center gap-2">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
</div>
</SelectItem>
<option key={credential.id} value={credential.id}>
{getCredentialDisplayName(credential, displayName)}
</option>
))}
</SelectContent>
</Select>
</select>
<div className="rounded-medium border border-zinc-200 bg-white">
<CredentialRow
credential={displayCredential}
provider={provider}
displayName={displayName}
onSelect={() => {}}
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
variant={variant}
/>
</div>
</div>
</div>
);
}

View File

@@ -99,4 +99,30 @@ export function getCredentialDisplayName(
}
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;
export const MASKED_KEY_LENGTH = 15;
export function isSystemCredential(credential: {
title?: string | null;
is_system?: boolean;
}): boolean {
if (credential.is_system === true) return true;
if (!credential.title) return false;
const titleLower = credential.title.toLowerCase();
return (
titleLower.includes("system") ||
titleLower.startsWith("use credits for") ||
titleLower.includes("use credits")
);
}
export function filterSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => !isSystemCredential(cred));
}
export function getSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => isSystemCredential(cred));
}

View File

@@ -6,9 +6,11 @@ import {
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
filterSystemCredentials,
getActionButtonText,
getSystemCredentials,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
@@ -54,6 +56,7 @@ export function useCredentialsInput({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const hasAttemptedAutoSelect = useRef(false);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -82,38 +85,51 @@ export function useCredentialsInput({
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
const availableCreds = credentials.savedCredentials;
if (
selectedCredential &&
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
!availableCreds.some((c) => c.id === selectedCredential.id)
) {
onSelectCredential(undefined);
// Reset auto-selection flag so it can run again after unsetting invalid credential
hasAttemptedAutoSelect.current = false;
}
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
// The available credential, if there is only one
const singleCredential = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return null;
}
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
// Auto-select the one available credential (only if not optional)
// Auto-select the first available credential on initial mount
// Once a user has made a selection, we don't override it
useEffect(() => {
if (readOnly) return;
if (isOptional) return; // Don't auto-select when credential is optional
if (singleCredential && !selectedCredential) {
onSelectCredential(singleCredential);
if (!credentials || !("savedCredentials" in credentials)) return;
// If already selected, don't auto-select
if (selectedCredential?.id) return;
// Only attempt auto-selection once
if (hasAttemptedAutoSelect.current) return;
hasAttemptedAutoSelect.current = true;
// If optional, don't auto-select (user can choose "None")
if (isOptional) return;
const savedCreds = credentials.savedCredentials;
// Auto-select the first credential if any are available
if (savedCreds.length > 0) {
const cred = savedCreds[0];
onSelectCredential({
id: cred.id,
type: cred.type,
provider: credentials.provider,
title: (cred as any).title,
});
}
}, [
singleCredential,
selectedCredential,
onSelectCredential,
credentials,
selectedCredential?.id,
readOnly,
isOptional,
onSelectCredential,
]);
if (
@@ -135,8 +151,13 @@ export function useCredentialsInput({
supportsHostScoped,
savedCredentials,
oAuthCallback,
isSystemProvider,
} = credentials;
// Split credentials into user and system
const userCredentials = filterSystemCredentials(savedCredentials);
const systemCredentials = getSystemCredentials(savedCredentials);
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -291,7 +312,10 @@ export function useCredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow: savedCredentials,
isSystemProvider,
userCredentials,
systemCredentials,
allCredentials: savedCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
@@ -306,7 +330,7 @@ export function useCredentialsInput({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
savedCredentials.length > 0,
userCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,

View File

@@ -12,7 +12,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
@@ -82,6 +82,8 @@ export function RunAgentModal({
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
@@ -89,6 +91,43 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger");
useEffect(() => {
if (!isOpen) return;
function checkOverflow() {
if (!contentRef.current) return;
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
setHasOverflow(
scrollableParent.scrollHeight > scrollableParent.clientHeight,
);
}
}
const timeoutId = setTimeout(checkOverflow, 100);
const resizeObserver = new ResizeObserver(checkOverflow);
if (contentRef.current) {
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
resizeObserver.observe(scrollableParent);
}
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [
isOpen,
hasAnySetupFields,
agentInputFields,
agentCredentialsInputFields,
]);
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -134,91 +173,97 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
{/* Header */}
<ModalHeader agent={agent} />
<div ref={contentRef} className="flex min-h-full flex-col">
<div className="flex-1">
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10 pb-32">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
</div>
) : null}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials before
scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
<Dialog.Footer
className={`sticky bottom-0 z-10 bg-white pt-4 ${
hasOverflow
? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
: ""
}`}
>
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials
before scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={isExecuting || isSettingUpTrigger}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</Dialog.Footer>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>

View File

@@ -0,0 +1,169 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/molecules/Accordion/Accordion";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { SlidersHorizontal } from "@phosphor-icons/react";
import { useContext, useEffect, useMemo, useRef } from "react";
import { useRunAgentModalContext } from "../../context";
import {
CredentialField,
findSavedCredentialByProviderAndType,
hasMissingRequiredSystemCredentials,
splitCredentialFieldsBySystem,
} from "../helpers";
type Props = {
credentialFields: CredentialField[];
requiredCredentials: Set<string>;
};
export function CredentialsGroupedView({
credentialFields,
requiredCredentials,
}: Props) {
const allProviders = useContext(CredentialsProvidersContext);
const { inputCredentials, setInputCredentialsValue, inputValues } =
useRunAgentModalContext();
const { userCredentialFields, systemCredentialFields } = useMemo(
() =>
splitCredentialFieldsBySystem(
credentialFields,
allProviders,
inputCredentials,
),
[credentialFields, allProviders, inputCredentials],
);
const hasSystemCredentials = systemCredentialFields.length > 0;
const hasUserCredentials = userCredentialFields.length > 0;
const hasAttemptedAutoSelect = useRef(false);
const hasMissingSystemCredentials = useMemo(
() =>
hasMissingRequiredSystemCredentials(
systemCredentialFields,
requiredCredentials,
inputCredentials,
),
[systemCredentialFields, requiredCredentials, inputCredentials],
);
useEffect(() => {
if (hasAttemptedAutoSelect.current) return;
if (!hasSystemCredentials) return;
let appliedSelection = false;
for (const [key, schema] of systemCredentialFields) {
const alreadySelected = inputCredentials?.[key];
const isRequired = requiredCredentials.has(key);
if (alreadySelected || !isRequired) continue;
const providerNames = schema.credentials_provider || [];
const credentialTypes = schema.credentials_types || [];
const savedCredential = findSavedCredentialByProviderAndType(
providerNames,
credentialTypes,
allProviders,
);
if (savedCredential) {
appliedSelection = true;
setInputCredentialsValue(key, {
id: savedCredential.id,
provider: savedCredential.provider,
type: savedCredential.type,
title: (savedCredential as { title?: string }).title,
});
}
}
if (appliedSelection) {
hasAttemptedAutoSelect.current = true;
}
}, [
allProviders,
hasSystemCredentials,
systemCredentialFields,
requiredCredentials,
inputCredentials,
setInputCredentialsValue,
]);
return (
<div className="space-y-6">
{hasUserCredentials && (
<>
{userCredentialFields.map(
([key, inputSubSchema]: CredentialField) => {
const selectedCred = inputCredentials?.[key];
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={selectedCred}
onSelectCredentials={(value) => {
setInputCredentialsValue(key, value);
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
},
)}
</>
)}
{hasSystemCredentials && (
<Accordion
type="single"
collapsible
className={hasUserCredentials ? "mt-4" : ""}
>
<AccordionItem value="system-credentials" className="border-none">
<AccordionTrigger className="py-2 text-sm text-muted-foreground hover:no-underline">
<div className="flex items-center gap-1">
<SlidersHorizontal size={16} weight="bold" /> System credentials
{hasMissingSystemCredentials && (
<span className="text-destructive">(missing)</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-6 px-1 pt-2">
{systemCredentialFields.map(
([key, inputSubSchema]: CredentialField) => {
const selectedCred = inputCredentials?.[key];
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={selectedCred}
onSelectCredentials={(value) => {
setInputCredentialsValue(key, value);
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
},
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { useMemo } from "react";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { CredentialsGroupedView } from "../CredentialsGroupedView/CredentialsGroupedView";
import { ModalSection } from "../ModalSection/ModalSection";
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
@@ -17,15 +18,16 @@ export function ModalRunSection() {
inputValues,
setInputValue,
agentInputFields,
inputCredentials,
setInputCredentialsValue,
agentCredentialsInputFields,
} = useRunAgentModalContext();
const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Get the list of required credentials from the schema
const credentialFields = useMemo(() => {
if (!agentCredentialsInputFields) return [];
return Object.entries(agentCredentialsInputFields);
}, [agentCredentialsInputFields]);
const requiredCredentials = new Set(
(agent.credentials_input_schema?.required as string[]) || [],
);
@@ -97,24 +99,10 @@ export function ModalRunSection() {
title="Task Credentials"
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={inputCredentials?.[key]}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
),
)}
</div>
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
/>
</ModalSection>
) : null}
</div>

View File

@@ -0,0 +1,93 @@
import { CredentialsProvidersContextType } from "@/providers/agent-credentials/credentials-provider";
export type CredentialField = [string, any];
type SavedCredential = {
id: string;
provider: string;
type: string;
title?: string | null;
};
export function splitCredentialFieldsBySystem(
credentialFields: CredentialField[],
allProviders: CredentialsProvidersContextType | null,
inputCredentials?: Record<string, unknown>,
) {
if (!allProviders || credentialFields.length === 0) {
return {
userCredentialFields: [] as CredentialField[],
systemCredentialFields: [] as CredentialField[],
};
}
const userFields: CredentialField[] = [];
const systemFields: CredentialField[] = [];
for (const [key, schema] of credentialFields) {
const providerNames = schema.credentials_provider || [];
const isSystemField = providerNames.some((providerName: string) => {
const providerData = allProviders[providerName];
return providerData?.isSystemProvider === true;
});
if (isSystemField) {
systemFields.push([key, schema]);
} else {
userFields.push([key, schema]);
}
}
const sortByUnsetFirst = (a: CredentialField, b: CredentialField) => {
const aIsSet = Boolean(inputCredentials?.[a[0]]);
const bIsSet = Boolean(inputCredentials?.[b[0]]);
if (aIsSet === bIsSet) return 0;
return aIsSet ? 1 : -1;
};
return {
userCredentialFields: userFields.sort(sortByUnsetFirst),
systemCredentialFields: systemFields.sort(sortByUnsetFirst),
};
}
export function hasMissingRequiredSystemCredentials(
systemCredentialFields: CredentialField[],
requiredCredentials: Set<string>,
inputCredentials?: Record<string, unknown>,
) {
if (systemCredentialFields.length === 0) return false;
return systemCredentialFields.some(([key]) => {
const isRequired = requiredCredentials.has(key);
const selectedCred = inputCredentials?.[key];
return isRequired && !selectedCred;
});
}
export function findSavedCredentialByProviderAndType(
providerNames: string[],
credentialTypes: string[],
allProviders: CredentialsProvidersContextType | null,
): SavedCredential | undefined {
for (const providerName of providerNames) {
const providerData = allProviders?.[providerName];
if (!providerData) continue;
const matchingCredential = providerData.savedCredentials.find(
(credential) => {
if (credentialTypes.length > 0) {
return credentialTypes.includes(credential.type);
}
return true;
},
);
if (matchingCredential) {
return matchingCredential as SavedCredential;
}
}
return undefined;
}

View File

@@ -11,9 +11,18 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { getSystemCredentials } from "../CredentialsInputs/helpers";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
@@ -42,8 +51,10 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -58,6 +69,91 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
const allProviders = useContext(CredentialsProvidersContext);
// Initialize credentials with default system credentials
useEffect(() => {
if (!allProviders || !agent.credentials_input_schema?.properties) return;
if (callbacks?.initialInputCredentials) {
hasInitializedSystemCreds.current = true;
return;
}
if (hasInitializedSystemCreds.current) return;
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
setInputCredentials((currentCreds) => {
const credsToAdd: Record<string, any> = {};
for (const [key, schema] of Object.entries(properties)) {
if (currentCreds[key]) continue;
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials ?? [],
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = requiredScopes.every(
(scope: string) => grantedScopes.has(scope),
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
credsToAdd[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break;
}
}
}
if (Object.keys(credsToAdd).length > 0) {
hasInitializedSystemCreds.current = true;
return {
...currentCreds,
...credsToAdd,
};
}
return currentCreds;
});
}, [
allProviders,
agent.credentials_input_schema,
callbacks?.initialInputCredentials,
]);
// Reset initialization flag when modal closes/opens or agent changes
useEffect(() => {
hasInitializedSystemCreds.current = false;
}, [isOpen, agent.graph_id]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
@@ -66,7 +162,6 @@ export function useAgentRunModal(
toast({
title: "Agent execution started",
});
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsQueryKey(agent.graph_id),
});
@@ -163,14 +258,10 @@ export function useAgentRunModal(
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
// Only check required credentials from schema, not all properties
// Credentials marked as optional in node metadata won't be in the required array
const requiredCredentials = new Set(
(agent.credentials_input_schema?.required as string[]) || [],
);
// Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set
const missing = [...requiredCredentials].filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
@@ -184,7 +275,6 @@ export function useAgentRunModal(
[agentCredentialsInputFields],
);
// Final readiness flag combining inputs + credentials when credentials are shown
const allRequiredInputsAreSet = useMemo(
() =>
allRequiredInputsAreSetRaw &&
@@ -223,7 +313,6 @@ export function useAgentRunModal(
defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger"
) {
// Setup trigger
if (!presetName.trim()) {
toast({
title: "⚠️ Trigger name required",
@@ -244,9 +333,6 @@ export function useAgentRunModal(
},
});
} else {
// Manual execution
// Filter out incomplete credentials (optional ones not selected)
// Only send credentials that have a valid id field
const validCredentials = Object.fromEntries(
Object.entries(inputCredentials).filter(([_, cred]) => cred && cred.id),
);
@@ -280,41 +366,24 @@ export function useAgentRunModal(
}, [agentInputFields]);
return {
// UI state
isOpen,
setIsOpen,
// Run mode
defaultRunType: defaultRunType as RunVariant,
// Form: regular inputs
inputValues,
setInputValues,
// Form: credentials
inputCredentials,
setInputCredentials,
// Preset/trigger labels
presetName,
presetDescription,
setPresetName,
setPresetDescription,
// Validation/readiness
allRequiredInputsAreSet,
missingInputs,
// Schemas for rendering
agentInputFields,
agentCredentialsInputFields,
hasInputFields,
// Async states
isExecuting: executeGraphMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
// Actions
handleRun,
};
}

View File

@@ -1,37 +1,17 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
return null;
}
export function AgentSettingsButton() {
return (
<Button
variant={selected ? "secondary" : "ghost"}
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings"
>
<GearIcon
size={18}
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
);
}

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() {

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() {

View File

@@ -1,3 +1,5 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {

View File

@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
interface MarketplaceBannersProps {
interface Props {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating,
onUpdate,
onPublish,
}: MarketplaceBannersProps) {
}: Props) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (

View File

@@ -1,3 +1,5 @@
"use client";
import { cn } from "@/lib/utils";
type Props = {

View File

@@ -1,22 +1,16 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props {
agent: LibraryAgent;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function LoadingSelectedContent(props: Props) {
return (
<SelectedViewLayout
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selectedSettings={props.selectedSettings}
>
<SelectedViewLayout agent={props.agent}>
<div
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
>

View File

@@ -33,8 +33,6 @@ interface Props {
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedRunView({
@@ -43,8 +41,6 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -84,12 +80,7 @@ export function SelectedRunView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<SelectedViewLayout agent={agent} banner={banner}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />

View File

@@ -21,8 +21,6 @@ interface Props {
scheduleId: string;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedScheduleView({
@@ -30,8 +28,6 @@ export function SelectedScheduleView({
scheduleId,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { schedule, isLoading, error } = useSelectedScheduleView(
agent.graph_id,
@@ -76,12 +72,7 @@ export function SelectedScheduleView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<SelectedViewLayout agent={agent} banner={banner}>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader

View File

@@ -1,11 +1,11 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { SelectedViewLayout } from "../SelectedViewLayout";
interface Props {
agent: LibraryAgent;
@@ -17,7 +17,7 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
useAgentSafeMode(agent);
return (
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}>
<SelectedViewLayout agent={agent}>
<div className="flex flex-col gap-4">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
@@ -33,15 +33,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
<Text variant="h2">Agent Settings</Text>
</div>
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
{!hasHITLBlocks ? (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any human-in-the-loop blocks, so
there are no settings to configure.
</Text>
</div>
) : (
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
{hasHITLBlocks ? (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
@@ -59,6 +52,12 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
/>
</div>
</div>
) : (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</div>

View File

@@ -8,7 +8,7 @@ import {
getAgentCredentialsFields,
getAgentInputFields,
} from "../../modals/AgentInputsReadOnly/helpers";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInput";
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";

View File

@@ -7,7 +7,7 @@ import {
getAgentCredentialsFields,
getAgentInputFields,
} from "../../modals/AgentInputsReadOnly/helpers";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInput";
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";

View File

@@ -1,7 +1,7 @@
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
import { SectionWrap } from "../other/SectionWrap";
interface Props {
@@ -9,8 +9,6 @@ interface Props {
children: React.ReactNode;
banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedViewLayout(props: Props) {
@@ -19,8 +17,8 @@ export function SelectedViewLayout(props: Props) {
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
{props.banner && <div className="mb-4">{props.banner}</div>}
<div className="relative flex w-fit items-center gap-2">
{props.banner}
<div className="relative flex w-full items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
@@ -33,15 +31,9 @@ export function SelectedViewLayout(props: Props) {
: []),
]}
/>
{props.agent && props.onSelectSettings && (
<div className="absolute -right-8">
<AgentSettingsButton
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}
<div className="absolute right-0">
<AgentSettingsModal agent={props.agent} />
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">

View File

@@ -12,7 +12,7 @@ import {
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs";
import { ScheduleTaskDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
import ActionButtonGroup from "@/components/__legacy__/action-button-group";

View File

@@ -1,14 +1,7 @@
"use client";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { NewAgentLibraryView } from "./components/NewAgentLibraryView/NewAgentLibraryView";
import { OldAgentLibraryView } from "./components/OldAgentLibraryView/OldAgentLibraryView";
export default function AgentLibraryPage() {
const isNewLibraryPageEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
return isNewLibraryPageEnabled ? (
<NewAgentLibraryView />
) : (
<OldAgentLibraryView />
);
return <NewAgentLibraryView />;
}

View File

@@ -2775,6 +2775,28 @@
}
}
},
"/api/integrations/providers/system": {
"get": {
"tags": ["v1", "integrations"],
"summary": "List System Providers",
"description": "Get a list of providers that have platform credits (system credentials) available.\n\nThese providers can be used without the user providing their own API keys.",
"operationId": "getV1ListSystemProviders",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": { "type": "string" },
"type": "array",
"title": "Response Getv1Listsystemproviders"
}
}
}
}
}
}
},
"/api/integrations/webhooks/{webhook_id}/ping": {
"post": {
"tags": ["v1", "integrations"],

View File

@@ -20,6 +20,7 @@ export function Button(props: ButtonProps) {
rightIcon,
children,
as = "button",
asChild: _asChild, // Destructure to prevent passing to DOM
...restProps
} = props;

View File

@@ -1,6 +1,6 @@
"use client";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { Button } from "@/components/atoms/Button/Button";
import { CircleNotchIcon, FolderOpenIcon } from "@phosphor-icons/react";
import {

View File

@@ -0,0 +1,203 @@
import type { Meta } from "@storybook/nextjs";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./Accordion";
const meta: Meta<typeof Accordion> = {
title: "Molecules/Accordion",
component: Accordion,
parameters: {
layout: "centered",
docs: {
description: {
component: `
## Accordion Component
A vertically stacked set of interactive headings that each reveal an associated section of content.
### ✨ Features
- **Built on Radix UI** - Uses @radix-ui/react-accordion for accessibility and functionality
- **Single or multiple** - Supports single or multiple items open at once
- **Smooth animations** - Built-in expand/collapse animations
- **Accessible** - Full keyboard navigation and screen reader support
- **Customizable** - Style with Tailwind CSS classes
### 🎯 Usage
\`\`\`tsx
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
</Accordion>
\`\`\`
### Props
**Accordion**:
- **type**: "single" | "multiple" - Whether one or multiple items can be open
- **collapsible**: boolean - When type is "single", allows closing all items
- **defaultValue**: string | string[] - Default open item(s)
- **value**: string | string[] - Controlled open item(s)
- **onValueChange**: (value) => void - Callback when value changes
**AccordionItem**:
- **value**: string - Unique identifier for the item
- **disabled**: boolean - Whether the item is disabled
**AccordionTrigger**:
- Standard button props
**AccordionContent**:
- Standard div props
`,
},
},
},
tags: ["autodocs"],
argTypes: {
type: {
control: "radio",
options: ["single", "multiple"],
description: "Whether one or multiple items can be open at the same time",
table: {
defaultValue: { summary: "single" },
},
},
collapsible: {
control: "boolean",
description:
'When type is "single", allows closing content when clicking on open trigger',
table: {
defaultValue: { summary: "false" },
},
},
},
};
export default meta;
export function Default() {
return (
<Accordion type="single" collapsible className="w-96">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that match your design system.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default with smooth expand/collapse
transitions.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function Multiple() {
return (
<Accordion type="multiple" className="w-96">
<AccordionItem value="item-1">
<AccordionTrigger>First section</AccordionTrigger>
<AccordionContent>
Multiple items can be open at the same time when type is set to
&quot;multiple&quot;.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Second section</AccordionTrigger>
<AccordionContent>
Try opening this one while the first is still open.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Third section</AccordionTrigger>
<AccordionContent>
All three can be open simultaneously.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function DefaultOpen() {
return (
<Accordion type="single" collapsible defaultValue="item-2" className="w-96">
<AccordionItem value="item-1">
<AccordionTrigger>Closed by default</AccordionTrigger>
<AccordionContent>This item starts closed.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Open by default</AccordionTrigger>
<AccordionContent>
This item starts open because defaultValue is set to
&quot;item-2&quot;.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Also closed</AccordionTrigger>
<AccordionContent>This item also starts closed.</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function WithDisabledItem() {
return (
<Accordion type="single" collapsible className="w-96">
<AccordionItem value="item-1">
<AccordionTrigger>Available item</AccordionTrigger>
<AccordionContent>This item can be toggled.</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" disabled>
<AccordionTrigger>Disabled item</AccordionTrigger>
<AccordionContent>
This content cannot be accessed because the item is disabled.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Another available item</AccordionTrigger>
<AccordionContent>This item can also be toggled.</AccordionContent>
</AccordionItem>
</Accordion>
);
}
export function CustomStyled() {
return (
<Accordion type="single" collapsible className="w-96">
<AccordionItem value="item-1" className="border-none">
<AccordionTrigger className="rounded-lg bg-zinc-100 px-4 hover:bg-zinc-200 hover:no-underline">
Custom styled trigger
</AccordionTrigger>
<AccordionContent className="px-4 pt-2">
You can customize the styling using className props.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" className="mt-2 border-none">
<AccordionTrigger className="rounded-lg bg-blue-50 px-4 text-blue-700 hover:bg-blue-100 hover:no-underline">
Blue themed
</AccordionTrigger>
<AccordionContent className="px-4 pt-2 text-blue-600">
Each item can have different styles.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";

View File

@@ -22,6 +22,9 @@ export function DrawerWrap({
handleClose,
isForceOpen,
}: Props) {
const accessibleTitle = title ?? "Dialog";
const hasVisibleTitle = Boolean(title);
const closeBtn = (
<Button
variant="link"
@@ -44,15 +47,19 @@ export function DrawerWrap({
>
<div
className={`flex w-full shrink-0 items-center justify-between ${
title ? "pb-6" : "pb-0"
hasVisibleTitle ? "pb-6" : "pb-0"
}`}
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : null}
{hasVisibleTitle ? (
<Drawer.Title className={drawerStyles.title}>
{accessibleTitle}
</Drawer.Title>
) : (
<Drawer.Title className="sr-only">{accessibleTitle}</Drawer.Title>
)}
{!isForceOpen ? (
title ? (
hasVisibleTitle ? (
closeBtn
) : (
<div

View File

@@ -1,14 +1,14 @@
import React, { useMemo } from "react";
import { FieldProps, getUiOptions } from "@rjsf/utils";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInput";
import { Switch } from "@/components/atoms/Switch/Switch";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { FieldProps, getUiOptions } from "@rjsf/utils";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { CredentialFieldTitle } from "./components/CredentialFieldTitle";
import { Switch } from "@/components/atoms/Switch/Switch";
export const CredentialsField = (props: FieldProps) => {
const { formData, onChange, schema, registry, fieldPathId, required } = props;

View File

@@ -0,0 +1,57 @@
"use client";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-neutral-500 transition-transform duration-200 dark:text-neutral-400" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -352,6 +352,10 @@ export default class BackendAPI {
return this._get("/integrations/providers");
}
listSystemProviders(): Promise<string[]> {
return this._get("/integrations/providers/system");
}
listCredentials(provider?: string): Promise<CredentialsMetaResponse[]> {
return this._get(
provider

View File

@@ -593,6 +593,7 @@ export type CredentialsMetaResponse = {
scopes?: Array<string>;
username?: string;
host?: string;
is_system?: boolean;
};
/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */

View File

@@ -1,5 +1,4 @@
import { createContext, useCallback, useEffect, useState } from "react";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import {
APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse,
@@ -10,8 +9,9 @@ import {
UserPasswordCredentials,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { createContext, useCallback, useEffect, useState } from "react";
type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials,
@@ -32,6 +32,8 @@ export type CredentialsProviderData = {
provider: CredentialsProviderName;
providerName: string;
savedCredentials: CredentialsMetaResponse[];
/** Whether this provider has platform credits available (system credentials) */
isSystemProvider: boolean;
oAuthCallback: (
code: string,
state_token: string,
@@ -68,6 +70,9 @@ export default function CredentialsProvider({
const [providers, setProviders] =
useState<CredentialsProvidersContextType | null>(null);
const [providerNames, setProviderNames] = useState<string[]>([]);
const [systemProviders, setSystemProviders] = useState<Set<string>>(
new Set(),
);
const { isLoggedIn } = useSupabase();
const api = useBackendAPI();
const onFailToast = useToastOnFail();
@@ -218,17 +223,7 @@ export default function CredentialsProvider({
[api, onFailToast],
);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("load provider names"));
}, [api, onFailToast]);
useEffect(() => {
const loadCredentials = useCallback(() => {
if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders({});
return;
@@ -251,27 +246,32 @@ export default function CredentialsProvider({
setProviders((prev) => ({
...prev,
...Object.fromEntries(
providerNames.map((provider) => [
provider,
{
providerNames.map((provider) => {
const providerCredentials = credentialsByProvider[provider] ?? [];
return [
provider,
providerName: toDisplayName(provider as string),
savedCredentials: credentialsByProvider[provider] ?? [],
oAuthCallback: (code: string, state_token: string) =>
oAuthCallback(provider, code, state_token),
createAPIKeyCredentials: (
credentials: APIKeyCredentialsCreatable,
) => createAPIKeyCredentials(provider, credentials),
createUserPasswordCredentials: (
credentials: UserPasswordCredentialsCreatable,
) => createUserPasswordCredentials(provider, credentials),
createHostScopedCredentials: (
credentials: HostScopedCredentialsCreatable,
) => createHostScopedCredentials(provider, credentials),
deleteCredentials: (id: string, force: boolean = false) =>
deleteCredentials(provider, id, force),
} satisfies CredentialsProviderData,
]),
{
provider,
providerName: toDisplayName(provider as string),
savedCredentials: providerCredentials,
isSystemProvider: systemProviders.has(provider),
oAuthCallback: (code: string, state_token: string) =>
oAuthCallback(provider, code, state_token),
createAPIKeyCredentials: (
credentials: APIKeyCredentialsCreatable,
) => createAPIKeyCredentials(provider, credentials),
createUserPasswordCredentials: (
credentials: UserPasswordCredentialsCreatable,
) => createUserPasswordCredentials(provider, credentials),
createHostScopedCredentials: (
credentials: HostScopedCredentialsCreatable,
) => createHostScopedCredentials(provider, credentials),
deleteCredentials: (id: string, force: boolean = false) =>
deleteCredentials(provider, id, force),
} satisfies CredentialsProviderData,
];
}),
),
}));
})
@@ -280,6 +280,7 @@ export default function CredentialsProvider({
api,
isLoggedIn,
providerNames,
systemProviders,
createAPIKeyCredentials,
createUserPasswordCredentials,
createHostScopedCredentials,
@@ -288,6 +289,20 @@ export default function CredentialsProvider({
onFailToast,
]);
// Fetch provider names and system providers on mount
useEffect(() => {
Promise.all([api.listProviders(), api.listSystemProviders()])
.then(([names, systemList]) => {
setProviderNames(names);
setSystemProviders(new Set(systemList));
})
.catch(onFailToast("Load provider names"));
}, [api, onFailToast]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
return (
<CredentialsProvidersContext.Provider value={providers}>
{children}

View File

@@ -22,13 +22,7 @@ const config = {
poppins: ["var(--font-poppins)"],
},
colors: {
// *** APPROVED DESIGN SYSTEM COLORS ***
// These are the ONLY colors that should be used in our app
...colors,
// Legacy colors - DO NOT USE THESE IN NEW CODE
// These are kept only to prevent breaking existing styles
// Use the approved design system colors above instead
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
@@ -63,70 +57,66 @@ const config = {
foreground: "hsl(var(--card-foreground))",
},
customGray: {
100: "#d9d9d9",
200: "#a8a8a8",
300: "#878787",
400: "#646464",
500: "#474747",
600: "#282828",
700: "#272727",
"100": "#d9d9d9",
"200": "#a8a8a8",
"300": "#878787",
"400": "#646464",
"500": "#474747",
"600": "#282828",
"700": "#272727",
},
},
spacing: {
// Tailwind spacing + custom sizes
0: "0rem", // 0px
0.5: "0.125rem", // 2px
1: "0.25rem", // 4px
1.5: "0.375rem", // 6px
2: "0.5rem", // 8px
2.5: "0.625rem", // 10px
3: "0.75rem", // 12px
3.5: "0.875rem", // 14px
4: "1rem", // 16px
5: "1.25rem", // 20px
6: "1.5rem", // 24px
7: "1.75rem", // 28px
7.5: "1.875rem", // 30px
8: "2rem", // 32px
8.5: "2.125rem", // 34px
9: "2.25rem", // 36px
10: "2.5rem", // 40px
11: "2.75rem", // 44px
12: "3rem", // 48px
14: "3.5rem", // 56px
16: "4rem", // 64px
18: "4.5rem", // 72px
20: "5rem", // 80px
24: "6rem", // 96px
28: "7rem", // 112px
32: "8rem", // 128px
36: "9rem", // 144px
40: "10rem", // 160px
44: "11rem", // 176px
48: "12rem", // 192px
52: "13rem", // 208px
56: "14rem", // 224px
60: "15rem", // 240px
64: "16rem", // 256px
68: "17rem", // 272px
70: "17.5rem", // 280px
71: "17.75rem", // 284px
72: "18rem", // 288px
76: "19rem", // 304px
80: "20rem", // 320px
96: "24rem", // 384px
"0": "0rem",
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"7": "1.75rem",
"8": "2rem",
"9": "2.25rem",
"10": "2.5rem",
"11": "2.75rem",
"12": "3rem",
"14": "3.5rem",
"16": "4rem",
"18": "4.5rem",
"20": "5rem",
"24": "6rem",
"28": "7rem",
"32": "8rem",
"36": "9rem",
"40": "10rem",
"44": "11rem",
"48": "12rem",
"52": "13rem",
"56": "14rem",
"60": "15rem",
"64": "16rem",
"68": "17rem",
"70": "17.5rem",
"71": "17.75rem",
"72": "18rem",
"76": "19rem",
"80": "20rem",
"96": "24rem",
"0.5": "0.125rem",
"1.5": "0.375rem",
"2.5": "0.625rem",
"3.5": "0.875rem",
"7.5": "1.875rem",
"8.5": "2.125rem",
},
borderRadius: {
// Design system border radius tokens from Figma
xsmall: "0.25rem", // 4px
small: "0.5rem", // 8px
medium: "0.75rem", // 12px
large: "1rem", // 16px
xlarge: "1.25rem", // 20px
"2xlarge": "1.5rem", // 24px
full: "9999px", // For pill buttons
// Legacy values - kept for backward compatibility
xsmall: "0.25rem",
small: "0.5rem",
medium: "0.75rem",
large: "1rem",
xlarge: "1.25rem",
"2xlarge": "1.5rem",
full: "9999px",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
@@ -136,16 +126,28 @@ const config = {
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
"fade-in": {
"0%": { opacity: "0" }, // Start with opacity 0
"100%": { opacity: "1" }, // End with opacity 1
"0%": {
opacity: "0",
},
"100%": {
opacity: "1",
},
},
},
animation: {