Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
0b05bb61d9 chore(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 07:26:23 +00:00
54 changed files with 674 additions and 1578 deletions

View File

@@ -83,7 +83,7 @@ jobs:
- name: Set up Python dependency cache - name: Set up Python dependency cache
# On Windows, unpacking cached dependencies takes longer than just installing them # On Windows, unpacking cached dependencies takes longer than just installing them
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }} path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }}
key: poetry-${{ runner.os }}-${{ hashFiles('classic/original_autogpt/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('classic/original_autogpt/poetry.lock') }}

View File

@@ -55,7 +55,7 @@ jobs:
- name: Set up Python dependency cache - name: Set up Python dependency cache
# On Windows, unpacking cached dependencies takes longer than just installing them # On Windows, unpacking cached dependencies takes longer than just installing them
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }} path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }}
key: poetry-${{ runner.os }}-${{ hashFiles('classic/benchmark/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('classic/benchmark/poetry.lock') }}

View File

@@ -107,7 +107,7 @@ jobs:
- name: Set up Python dependency cache - name: Set up Python dependency cache
# On Windows, unpacking cached dependencies takes longer than just installing them # On Windows, unpacking cached dependencies takes longer than just installing them
if: runner.os != 'Windows' if: runner.os != 'Windows'
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }} path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }}
key: poetry-${{ runner.os }}-${{ hashFiles('classic/forge/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('classic/forge/poetry.lock') }}

View File

@@ -78,7 +78,7 @@ jobs:
python-version: ${{ env.min-python-version }} python-version: ${{ env.min-python-version }}
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: ${{ runner.os }}-poetry-${{ hashFiles(format('{0}/poetry.lock', matrix.sub-package)) }} key: ${{ runner.os }}-poetry-${{ hashFiles(format('{0}/poetry.lock', matrix.sub-package)) }}
@@ -130,7 +130,7 @@ jobs:
python-version: ${{ env.min-python-version }} python-version: ${{ env.min-python-version }}
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: ${{ runner.os }}-poetry-${{ hashFiles(format('{0}/poetry.lock', matrix.sub-package)) }} key: ${{ runner.os }}-poetry-${{ hashFiles(format('{0}/poetry.lock', matrix.sub-package)) }}

View File

@@ -41,7 +41,7 @@ jobs:
python-version: "3.11" # Use standard version matching CI python-version: "3.11" # Use standard version matching CI
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
@@ -91,7 +91,7 @@ jobs:
echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }} key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}
@@ -124,7 +124,7 @@ jobs:
# Phase 1: Cache and load Docker images for faster setup # Phase 1: Cache and load Docker images for faster setup
- name: Set up Docker image cache - name: Set up Docker image cache
id: docker-cache id: docker-cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/docker-cache path: ~/docker-cache
# Use a versioned key for cache invalidation when image list changes # Use a versioned key for cache invalidation when image list changes

View File

@@ -57,7 +57,7 @@ jobs:
python-version: "3.11" # Use standard version matching CI python-version: "3.11" # Use standard version matching CI
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
@@ -107,7 +107,7 @@ jobs:
echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }} key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}
@@ -140,7 +140,7 @@ jobs:
# Phase 1: Cache and load Docker images for faster setup # Phase 1: Cache and load Docker images for faster setup
- name: Set up Docker image cache - name: Set up Docker image cache
id: docker-cache id: docker-cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/docker-cache path: ~/docker-cache
# Use a versioned key for cache invalidation when image list changes # Use a versioned key for cache invalidation when image list changes

View File

@@ -39,7 +39,7 @@ jobs:
python-version: "3.11" # Use standard version matching CI python-version: "3.11" # Use standard version matching CI
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
@@ -89,7 +89,7 @@ jobs:
echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }} key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}
@@ -132,7 +132,7 @@ jobs:
# Phase 1: Cache and load Docker images for faster setup # Phase 1: Cache and load Docker images for faster setup
- name: Set up Docker image cache - name: Set up Docker image cache
id: docker-cache id: docker-cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/docker-cache path: ~/docker-cache
# Use a versioned key for cache invalidation when image list changes # Use a versioned key for cache invalidation when image list changes

View File

@@ -33,7 +33,7 @@ jobs:
python-version: "3.11" python-version: "3.11"
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}

View File

@@ -33,7 +33,7 @@ jobs:
python-version: "3.11" python-version: "3.11"
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}

View File

@@ -38,7 +38,7 @@ jobs:
python-version: "3.11" python-version: "3.11"
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}

View File

@@ -88,7 +88,7 @@ jobs:
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Set up Python dependency cache - name: Set up Python dependency cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/pypoetry path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}

View File

@@ -45,7 +45,7 @@ jobs:
run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ steps.cache-key.outputs.key }} key: ${{ steps.cache-key.outputs.key }}
@@ -73,7 +73,7 @@ jobs:
run: corepack enable run: corepack enable
- name: Restore dependencies cache - name: Restore dependencies cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }} key: ${{ needs.setup.outputs.cache-key }}
@@ -108,7 +108,7 @@ jobs:
run: corepack enable run: corepack enable
- name: Restore dependencies cache - name: Restore dependencies cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }} key: ${{ needs.setup.outputs.cache-key }}
@@ -164,7 +164,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-frontend-test-${{ hashFiles('autogpt_platform/docker-compose.yml', 'autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/pyproject.toml', 'autogpt_platform/backend/poetry.lock') }} key: ${{ runner.os }}-buildx-frontend-test-${{ hashFiles('autogpt_platform/docker-compose.yml', 'autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/pyproject.toml', 'autogpt_platform/backend/poetry.lock') }}
@@ -219,7 +219,7 @@ jobs:
fi fi
- name: Restore dependencies cache - name: Restore dependencies cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }} key: ${{ needs.setup.outputs.cache-key }}

View File

@@ -44,7 +44,7 @@ jobs:
run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ steps.cache-key.outputs.key }} key: ${{ steps.cache-key.outputs.key }}
@@ -88,7 +88,7 @@ jobs:
docker compose -f ../docker-compose.yml --profile local --profile deps_backend up -d docker compose -f ../docker-compose.yml --profile local --profile deps_backend up -d
- name: Restore dependencies cache - name: Restore dependencies cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }} key: ${{ needs.setup.outputs.cache-key }}

View File

@@ -4,9 +4,14 @@ from collections.abc import AsyncGenerator
from typing import Any from typing import Any
import orjson import orjson
from langfuse import get_client, propagate_attributes from langfuse import Langfuse
from langfuse.openai import openai # type: ignore from openai import (
from openai import APIConnectionError, APIError, APIStatusError, RateLimitError APIConnectionError,
APIError,
APIStatusError,
AsyncOpenAI,
RateLimitError,
)
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
from backend.data.understanding import ( from backend.data.understanding import (
@@ -16,6 +21,7 @@ from backend.data.understanding import (
from backend.util.exceptions import NotFoundError from backend.util.exceptions import NotFoundError
from backend.util.settings import Settings from backend.util.settings import Settings
from . import db as chat_db
from .config import ChatConfig from .config import ChatConfig
from .model import ( from .model import (
ChatMessage, ChatMessage,
@@ -44,10 +50,10 @@ logger = logging.getLogger(__name__)
config = ChatConfig() config = ChatConfig()
settings = Settings() settings = Settings()
client = openai.AsyncOpenAI(api_key=config.api_key, base_url=config.base_url) client = AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
# Langfuse client (lazy initialization)
langfuse = get_client() _langfuse_client: Langfuse | None = None
class LangfuseNotConfiguredError(Exception): class LangfuseNotConfiguredError(Exception):
@@ -63,6 +69,65 @@ def _is_langfuse_configured() -> bool:
) )
def _get_langfuse_client() -> Langfuse:
"""Get or create the Langfuse client for prompt management and tracing."""
global _langfuse_client
if _langfuse_client is None:
if not _is_langfuse_configured():
raise LangfuseNotConfiguredError(
"Langfuse is not configured. The chat feature requires Langfuse for prompt management. "
"Please set the LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY environment variables."
)
_langfuse_client = Langfuse(
public_key=settings.secrets.langfuse_public_key,
secret_key=settings.secrets.langfuse_secret_key,
host=settings.secrets.langfuse_host or "https://cloud.langfuse.com",
)
return _langfuse_client
def _get_environment() -> str:
"""Get the current environment name for Langfuse tagging."""
return settings.config.app_env.value
def _get_langfuse_prompt() -> str:
"""Fetch the latest production prompt from Langfuse.
Returns:
The compiled prompt text from Langfuse.
Raises:
Exception: If Langfuse is unavailable or prompt fetch fails.
"""
try:
langfuse = _get_langfuse_client()
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
compiled = prompt.compile()
logger.info(
f"Fetched prompt '{config.langfuse_prompt_name}' from Langfuse "
f"(version: {prompt.version})"
)
return compiled
except Exception as e:
logger.error(f"Failed to fetch prompt from Langfuse: {e}")
raise
async def _is_first_session(user_id: str) -> bool:
"""Check if this is the user's first chat session.
Returns True if the user has 1 or fewer sessions (meaning this is their first).
"""
try:
session_count = await chat_db.get_user_session_count(user_id)
return session_count <= 1
except Exception as e:
logger.warning(f"Failed to check session count for user {user_id}: {e}")
return False # Default to non-onboarding if we can't check
async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]: async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
"""Build the full system prompt including business understanding if available. """Build the full system prompt including business understanding if available.
@@ -74,6 +139,8 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
Tuple of (compiled prompt string, Langfuse prompt object for tracing) Tuple of (compiled prompt string, Langfuse prompt object for tracing)
""" """
langfuse = _get_langfuse_client()
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt # cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0) prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
@@ -91,7 +158,7 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
context = "This is the first time you are meeting the user. Greet them and introduce them to the platform" context = "This is the first time you are meeting the user. Greet them and introduce them to the platform"
compiled = prompt.compile(users_information=context) compiled = prompt.compile(users_information=context)
return compiled, understanding return compiled, prompt
async def _generate_session_title(message: str) -> str | None: async def _generate_session_title(message: str) -> str | None:
@@ -150,7 +217,6 @@ async def assign_user_to_session(
async def stream_chat_completion( async def stream_chat_completion(
session_id: str, session_id: str,
message: str | None = None, message: str | None = None,
tool_call_response: str | None = None,
is_user_message: bool = True, is_user_message: bool = True,
user_id: str | None = None, user_id: str | None = None,
retry_count: int = 0, retry_count: int = 0,
@@ -190,6 +256,11 @@ async def stream_chat_completion(
yield StreamFinish() yield StreamFinish()
return return
# Langfuse observations will be created after session is loaded (need messages for input)
# Initialize to None so finally block can safely check and end them
trace = None
generation = None
# Only fetch from Redis if session not provided (initial call) # Only fetch from Redis if session not provided (initial call)
if session is None: if session is None:
session = await get_chat_session(session_id, user_id) session = await get_chat_session(session_id, user_id)
@@ -265,259 +336,297 @@ async def stream_chat_completion(
asyncio.create_task(_update_title()) asyncio.create_task(_update_title())
# Build system prompt with business understanding # Build system prompt with business understanding
system_prompt, understanding = await _build_system_prompt(user_id) system_prompt, langfuse_prompt = await _build_system_prompt(user_id)
# Build input messages including system prompt for complete Langfuse logging
trace_input_messages = [{"role": "system", "content": system_prompt}] + [
m.model_dump() for m in session.messages
]
# Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_id) # Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_id)
# Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes # Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes
input = message try:
if not message and tool_call_response: langfuse = _get_langfuse_client()
input = tool_call_response env = _get_environment()
trace = langfuse.start_observation(
langfuse = get_client() name="chat_completion",
with langfuse.start_as_current_observation( input={"messages": trace_input_messages},
as_type="span", metadata={
name="user-copilot-request", "environment": env,
input=input, "model": config.model,
) as span: "message_count": len(session.messages),
with propagate_attributes( "prompt_name": langfuse_prompt.name if langfuse_prompt else None,
"prompt_version": langfuse_prompt.version if langfuse_prompt else None,
},
)
# Set trace-level attributes (session_id, user_id, tags)
trace.update_trace(
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
tags=["copilot"], tags=[env, "copilot"],
metadata={ )
"users_information": format_understanding_for_prompt(understanding)[ except Exception as e:
:200 logger.warning(f"Failed to create Langfuse trace: {e}")
] # langfuse only accepts upto to 200 chars
},
):
# Initialize variables that will be used in finally block (must be defined before try) # Initialize variables that will be used in finally block (must be defined before try)
assistant_response = ChatMessage( assistant_response = ChatMessage(
role="assistant", role="assistant",
content="", content="",
)
accumulated_tool_calls: list[dict[str, Any]] = []
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
try:
has_yielded_end = False
has_yielded_error = False
has_done_tool_call = False
has_received_text = False
text_streaming_ended = False
tool_response_messages: list[ChatMessage] = []
should_retry = False
# Generate unique IDs for AI SDK protocol
import uuid as uuid_module
message_id = str(uuid_module.uuid4())
text_block_id = str(uuid_module.uuid4())
# Yield message start
yield StreamStart(messageId=message_id)
# Create Langfuse generation for each LLM call, linked to the prompt
# Using v3 SDK: start_observation with as_type="generation"
generation = (
trace.start_observation(
as_type="generation",
name="llm_call",
model=config.model,
input={"messages": trace_input_messages},
prompt=langfuse_prompt,
) )
accumulated_tool_calls: list[dict[str, Any]] = [] if trace
else None
)
# Wrap main logic in try/finally to ensure Langfuse observations are always ended try:
has_yielded_end = False async for chunk in _stream_chat_chunks(
has_yielded_error = False session=session,
has_done_tool_call = False tools=tools,
has_received_text = False system_prompt=system_prompt,
text_streaming_ended = False text_block_id=text_block_id,
tool_response_messages: list[ChatMessage] = [] ):
should_retry = False
# Generate unique IDs for AI SDK protocol if isinstance(chunk, StreamTextStart):
import uuid as uuid_module # Emit text-start before first text delta
if not has_received_text:
message_id = str(uuid_module.uuid4())
text_block_id = str(uuid_module.uuid4())
# Yield message start
yield StreamStart(messageId=message_id)
try:
async for chunk in _stream_chat_chunks(
session=session,
tools=tools,
system_prompt=system_prompt,
text_block_id=text_block_id,
):
if isinstance(chunk, StreamTextStart):
# Emit text-start before first text delta
if not has_received_text:
yield chunk
elif isinstance(chunk, StreamTextDelta):
delta = chunk.delta or ""
assert assistant_response.content is not None
assistant_response.content += delta
has_received_text = True
yield chunk yield chunk
elif isinstance(chunk, StreamTextEnd): elif isinstance(chunk, StreamTextDelta):
# Emit text-end after text completes delta = chunk.delta or ""
if has_received_text and not text_streaming_ended: assert assistant_response.content is not None
text_streaming_ended = True assistant_response.content += delta
if assistant_response.content: has_received_text = True
logger.warn( yield chunk
f"StreamTextEnd: Attempting to set output {assistant_response.content}" elif isinstance(chunk, StreamTextEnd):
) # Emit text-end after text completes
span.update_trace(output=assistant_response.content) if has_received_text and not text_streaming_ended:
span.update(output=assistant_response.content) text_streaming_ended = True
yield chunk yield chunk
elif isinstance(chunk, StreamToolInputStart): elif isinstance(chunk, StreamToolInputStart):
# Emit text-end before first tool call, but only if we've received text # Emit text-end before first tool call, but only if we've received text
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
yield chunk
elif isinstance(chunk, StreamToolInputAvailable):
# Accumulate tool calls in OpenAI format
accumulated_tool_calls.append(
{
"id": chunk.toolCallId,
"type": "function",
"function": {
"name": chunk.toolName,
"arguments": orjson.dumps(chunk.input).decode("utf-8"),
},
}
)
elif isinstance(chunk, StreamToolOutputAvailable):
result_content = (
chunk.output
if isinstance(chunk.output, str)
else orjson.dumps(chunk.output).decode("utf-8")
)
tool_response_messages.append(
ChatMessage(
role="tool",
content=result_content,
tool_call_id=chunk.toolCallId,
)
)
has_done_tool_call = True
# Track if any tool execution failed
if not chunk.success:
logger.warning(
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
)
yield chunk
elif isinstance(chunk, StreamFinish):
if not has_done_tool_call:
# Emit text-end before finish if we received text but haven't closed it
if has_received_text and not text_streaming_ended: if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id) yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True text_streaming_ended = True
has_yielded_end = True
yield chunk yield chunk
elif isinstance(chunk, StreamToolInputAvailable): elif isinstance(chunk, StreamError):
# Accumulate tool calls in OpenAI format has_yielded_error = True
accumulated_tool_calls.append( elif isinstance(chunk, StreamUsage):
{ session.usage.append(
"id": chunk.toolCallId, Usage(
"type": "function", prompt_tokens=chunk.promptTokens,
"function": { completion_tokens=chunk.completionTokens,
"name": chunk.toolName, total_tokens=chunk.totalTokens,
"arguments": orjson.dumps(chunk.input).decode(
"utf-8"
),
},
}
) )
elif isinstance(chunk, StreamToolOutputAvailable):
result_content = (
chunk.output
if isinstance(chunk.output, str)
else orjson.dumps(chunk.output).decode("utf-8")
)
tool_response_messages.append(
ChatMessage(
role="tool",
content=result_content,
tool_call_id=chunk.toolCallId,
)
)
has_done_tool_call = True
# Track if any tool execution failed
if not chunk.success:
logger.warning(
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
)
yield chunk
elif isinstance(chunk, StreamFinish):
if not has_done_tool_call:
# Emit text-end before finish if we received text but haven't closed it
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
has_yielded_end = True
yield chunk
elif isinstance(chunk, StreamError):
has_yielded_error = True
elif isinstance(chunk, StreamUsage):
session.usage.append(
Usage(
prompt_tokens=chunk.promptTokens,
completion_tokens=chunk.completionTokens,
total_tokens=chunk.totalTokens,
)
)
else:
logger.error(
f"Unknown chunk type: {type(chunk)}", exc_info=True
)
if assistant_response.content:
langfuse.update_current_trace(output=assistant_response.content)
langfuse.update_current_span(output=assistant_response.content)
elif tool_response_messages:
langfuse.update_current_trace(output=str(tool_response_messages))
langfuse.update_current_span(output=str(tool_response_messages))
except Exception as e:
logger.error(f"Error during stream: {e!s}", exc_info=True)
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
is_retryable = isinstance(
e, (orjson.JSONDecodeError, KeyError, TypeError)
)
if is_retryable and retry_count < config.max_retries:
logger.info(
f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
) )
should_retry = True
else: else:
# Non-retryable error or max retries exceeded logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
# Save any partial progress before reporting error except Exception as e:
messages_to_save: list[ChatMessage] = [] logger.error(f"Error during stream: {e!s}", exc_info=True)
# Add assistant message if it has content or tool calls # Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
if accumulated_tool_calls: is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError))
assistant_response.tool_calls = accumulated_tool_calls
if assistant_response.content or assistant_response.tool_calls:
messages_to_save.append(assistant_response)
# Add tool response messages after assistant message if is_retryable and retry_count < config.max_retries:
messages_to_save.extend(tool_response_messages)
session.messages.extend(messages_to_save)
await upsert_chat_session(session)
if not has_yielded_error:
error_message = str(e)
if not is_retryable:
error_message = f"Non-retryable error: {error_message}"
elif retry_count >= config.max_retries:
error_message = f"Max retries ({config.max_retries}) exceeded: {error_message}"
error_response = StreamError(errorText=error_message)
yield error_response
if not has_yielded_end:
yield StreamFinish()
return
# Handle retry outside of exception handler to avoid nesting
if should_retry and retry_count < config.max_retries:
logger.info( logger.info(
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}" f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
) )
async for chunk in stream_chat_completion( should_retry = True
session_id=session.session_id, else:
user_id=user_id, # Non-retryable error or max retries exceeded
retry_count=retry_count + 1, # Save any partial progress before reporting error
session=session, messages_to_save: list[ChatMessage] = []
context=context,
):
yield chunk
return # Exit after retry to avoid double-saving in finally block
# Normal completion path - save session and handle tool call continuation # Add assistant message if it has content or tool calls
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if assistant_response.content or assistant_response.tool_calls:
messages_to_save.append(assistant_response)
# Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages)
session.messages.extend(messages_to_save)
await upsert_chat_session(session)
if not has_yielded_error:
error_message = str(e)
if not is_retryable:
error_message = f"Non-retryable error: {error_message}"
elif retry_count >= config.max_retries:
error_message = f"Max retries ({config.max_retries}) exceeded: {error_message}"
error_response = StreamError(errorText=error_message)
yield error_response
if not has_yielded_end:
yield StreamFinish()
return
# Handle retry outside of exception handler to avoid nesting
if should_retry and retry_count < config.max_retries:
logger.info( logger.info(
f"Normal completion path: session={session.session_id}, " f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
f"current message_count={len(session.messages)}" )
async for chunk in stream_chat_completion(
session_id=session.session_id,
user_id=user_id,
retry_count=retry_count + 1,
session=session,
context=context,
):
yield chunk
return # Exit after retry to avoid double-saving in finally block
# Normal completion path - save session and handle tool call continuation
logger.info(
f"Normal completion path: session={session.session_id}, "
f"current message_count={len(session.messages)}"
)
# Build the messages list in the correct order
messages_to_save: list[ChatMessage] = []
# Add assistant message with tool_calls if any
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
logger.info(
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
)
if assistant_response.content or assistant_response.tool_calls:
messages_to_save.append(assistant_response)
logger.info(
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
) )
# Build the messages list in the correct order # Add tool response messages after assistant message
messages_to_save: list[ChatMessage] = [] messages_to_save.extend(tool_response_messages)
logger.info(
f"Saving {len(tool_response_messages)} tool response messages, "
f"total_to_save={len(messages_to_save)}"
)
# Add assistant message with tool_calls if any session.messages.extend(messages_to_save)
if accumulated_tool_calls: logger.info(
assistant_response.tool_calls = accumulated_tool_calls f"Extended session messages, new message_count={len(session.messages)}"
logger.info( )
f"Added {len(accumulated_tool_calls)} tool calls to assistant message" await upsert_chat_session(session)
)
if assistant_response.content or assistant_response.tool_calls:
messages_to_save.append(assistant_response)
logger.info(
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
)
# Add tool response messages after assistant message # If we did a tool call, stream the chat completion again to get the next response
messages_to_save.extend(tool_response_messages) if has_done_tool_call:
logger.info( logger.info(
f"Saving {len(tool_response_messages)} tool response messages, " "Tool call executed, streaming chat completion again to get assistant response"
f"total_to_save={len(messages_to_save)}"
) )
async for chunk in stream_chat_completion(
session_id=session.session_id,
user_id=user_id,
session=session, # Pass session object to avoid Redis refetch
context=context,
):
yield chunk
session.messages.extend(messages_to_save) finally:
logger.info( # Always end Langfuse observations to prevent resource leaks
f"Extended session messages, new message_count={len(session.messages)}" # Guard against None and catch errors to avoid masking original exceptions
) if generation is not None:
await upsert_chat_session(session) try:
latest_usage = session.usage[-1] if session.usage else None
# If we did a tool call, stream the chat completion again to get the next response generation.update(
if has_done_tool_call: model=config.model,
logger.info( output={
"Tool call executed, streaming chat completion again to get assistant response" "content": assistant_response.content,
"tool_calls": accumulated_tool_calls or None,
},
usage_details=(
{
"input": latest_usage.prompt_tokens,
"output": latest_usage.completion_tokens,
"total": latest_usage.total_tokens,
}
if latest_usage
else None
),
) )
async for chunk in stream_chat_completion( generation.end()
session_id=session.session_id, except Exception as e:
user_id=user_id, logger.warning(f"Failed to end Langfuse generation: {e}")
session=session, # Pass session object to avoid Redis refetch
context=context, if trace is not None:
tool_call_response=str(tool_response_messages), try:
): if accumulated_tool_calls:
yield chunk trace.update_trace(output={"tool_calls": accumulated_tool_calls})
else:
trace.update_trace(output={"response": assistant_response.content})
trace.end()
except Exception as e:
logger.warning(f"Failed to end Langfuse trace: {e}")
# Retry configuration for OpenAI API calls # Retry configuration for OpenAI API calls
@@ -791,4 +900,5 @@ async def _yield_tool_call(
session=session, session=session,
) )
logger.info(f"Yielding Tool execution response: {tool_execution_response}")
yield tool_execution_response yield tool_execution_response

View File

@@ -30,7 +30,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
"find_library_agent": FindLibraryAgentTool(), "find_library_agent": FindLibraryAgentTool(),
"run_agent": RunAgentTool(), "run_agent": RunAgentTool(),
"run_block": RunBlockTool(), "run_block": RunBlockTool(),
"view_agent_output": AgentOutputTool(), "agent_output": AgentOutputTool(),
"search_docs": SearchDocsTool(), "search_docs": SearchDocsTool(),
"get_doc_page": GetDocPageTool(), "get_doc_page": GetDocPageTool(),
} }

View File

@@ -3,8 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.data.understanding import ( from backend.data.understanding import (
BusinessUnderstandingInput, BusinessUnderstandingInput,
@@ -61,7 +59,6 @@ and automations for the user's specific needs."""
"""Requires authentication to store user-specific data.""" """Requires authentication to store user-specific data."""
return True return True
@observe(as_type="tool", name="add_understanding")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -5,7 +5,6 @@ import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from langfuse import observe
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
@@ -104,7 +103,7 @@ class AgentOutputTool(BaseTool):
@property @property
def name(self) -> str: def name(self) -> str:
return "view_agent_output" return "agent_output"
@property @property
def description(self) -> str: def description(self) -> str:
@@ -329,7 +328,6 @@ class AgentOutputTool(BaseTool):
total_executions=len(available_executions) if available_executions else 1, total_executions=len(available_executions) if available_executions else 1,
) )
@observe(as_type="tool", name="view_agent_output")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,8 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_generator import ( from .agent_generator import (
@@ -80,7 +78,6 @@ class CreateAgentTool(BaseTool):
"required": ["description"], "required": ["description"],
} }
@observe(as_type="tool", name="create_agent")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,8 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_generator import ( from .agent_generator import (
@@ -87,7 +85,6 @@ class EditAgentTool(BaseTool):
"required": ["agent_id", "changes"], "required": ["agent_id", "changes"],
} }
@observe(as_type="tool", name="edit_agent")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -2,8 +2,6 @@
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_search import search_agents from .agent_search import search_agents
@@ -37,7 +35,6 @@ class FindAgentTool(BaseTool):
"required": ["query"], "required": ["query"],
} }
@observe(as_type="tool", name="find_agent")
async def _execute( async def _execute(
self, user_id: str | None, session: ChatSession, **kwargs self, user_id: str | None, session: ChatSession, **kwargs
) -> ToolResponseBase: ) -> ToolResponseBase:

View File

@@ -1,7 +1,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from prisma.enums import ContentType from prisma.enums import ContentType
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
@@ -56,7 +55,6 @@ class FindBlockTool(BaseTool):
def requires_auth(self) -> bool: def requires_auth(self) -> bool:
return True return True
@observe(as_type="tool", name="find_block")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -2,8 +2,6 @@
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_search import search_agents from .agent_search import search_agents
@@ -43,7 +41,6 @@ class FindLibraryAgentTool(BaseTool):
def requires_auth(self) -> bool: def requires_auth(self) -> bool:
return True return True
@observe(as_type="tool", name="find_library_agent")
async def _execute( async def _execute(
self, user_id: str | None, session: ChatSession, **kwargs self, user_id: str | None, session: ChatSession, **kwargs
) -> ToolResponseBase: ) -> ToolResponseBase:

View File

@@ -4,8 +4,6 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tools.base import BaseTool from backend.api.features.chat.tools.base import BaseTool
from backend.api.features.chat.tools.models import ( from backend.api.features.chat.tools.models import (
@@ -73,7 +71,6 @@ class GetDocPageTool(BaseTool):
url_path = path.rsplit(".", 1)[0] if "." in path else path url_path = path.rsplit(".", 1)[0] if "." in path else path
return f"{DOCS_BASE_URL}/{url_path}" return f"{DOCS_BASE_URL}/{url_path}"
@observe(as_type="tool", name="get_doc_page")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,7 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from backend.api.features.chat.config import ChatConfig from backend.api.features.chat.config import ChatConfig
@@ -155,7 +154,6 @@ class RunAgentTool(BaseTool):
"""All operations require authentication.""" """All operations require authentication."""
return True return True
@observe(as_type="tool", name="run_agent")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -4,8 +4,6 @@ import logging
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.data.block import get_block from backend.data.block import get_block
from backend.data.execution import ExecutionContext from backend.data.execution import ExecutionContext
@@ -129,7 +127,6 @@ class RunBlockTool(BaseTool):
return matched_credentials, missing_credentials return matched_credentials, missing_credentials
@observe(as_type="tool", name="run_block")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,7 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from prisma.enums import ContentType from prisma.enums import ContentType
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
@@ -88,7 +87,6 @@ class SearchDocsTool(BaseTool):
url_path = path.rsplit(".", 1)[0] if "." in path else path url_path = path.rsplit(".", 1)[0] if "." in path else path
return f"{DOCS_BASE_URL}/{url_path}" return f"{DOCS_BASE_URL}/{url_path}"
@observe(as_type="tool", name="search_docs")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -328,8 +328,6 @@ async def clear_business_understanding(user_id: str) -> bool:
def format_understanding_for_prompt(understanding: BusinessUnderstanding) -> str: def format_understanding_for_prompt(understanding: BusinessUnderstanding) -> str:
"""Format business understanding as text for system prompt injection.""" """Format business understanding as text for system prompt injection."""
if not understanding:
return ""
sections = [] sections = []
# User info section # User info section

View File

@@ -10,7 +10,6 @@ import { useRunInputDialog } from "./useRunInputDialog";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog"; import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore"; import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
import { useEffect } from "react"; import { useEffect } from "react";
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
export const RunInputDialog = ({ export const RunInputDialog = ({
isOpen, isOpen,
@@ -24,17 +23,19 @@ export const RunInputDialog = ({
const hasInputs = useGraphStore((state) => state.hasInputs); const hasInputs = useGraphStore((state) => state.hasInputs);
const hasCredentials = useGraphStore((state) => state.hasCredentials); const hasCredentials = useGraphStore((state) => state.hasCredentials);
const inputSchema = useGraphStore((state) => state.inputSchema); const inputSchema = useGraphStore((state) => state.inputSchema);
const credentialsSchema = useGraphStore(
(state) => state.credentialsInputSchema,
);
const { const {
credentialFields, credentialsUiSchema,
requiredCredentials,
handleManualRun, handleManualRun,
handleInputChange, handleInputChange,
openCronSchedulerDialog, openCronSchedulerDialog,
setOpenCronSchedulerDialog, setOpenCronSchedulerDialog,
inputValues, inputValues,
credentialValues, credentialValues,
handleCredentialFieldChange, handleCredentialChange,
isExecutingGraph, isExecutingGraph,
} = useRunInputDialog({ setIsOpen }); } = useRunInputDialog({ setIsOpen });
@@ -66,7 +67,7 @@ export const RunInputDialog = ({
<Dialog.Content> <Dialog.Content>
<div className="space-y-6 p-1" data-id="run-input-dialog-content"> <div className="space-y-6 p-1" data-id="run-input-dialog-content">
{/* Credentials Section */} {/* Credentials Section */}
{hasCredentials() && credentialFields.length > 0 && ( {hasCredentials() && (
<div data-id="run-input-credentials-section"> <div data-id="run-input-credentials-section">
<div className="mb-4"> <div className="mb-4">
<Text variant="h4" className="text-gray-900"> <Text variant="h4" className="text-gray-900">
@@ -74,12 +75,16 @@ export const RunInputDialog = ({
</Text> </Text>
</div> </div>
<div className="px-2" data-id="run-input-credentials-form"> <div className="px-2" data-id="run-input-credentials-form">
<CredentialsGroupedView <FormRenderer
credentialFields={credentialFields} jsonSchema={credentialsSchema as RJSFSchema}
requiredCredentials={requiredCredentials} handleChange={(v) => handleCredentialChange(v.formData)}
inputCredentials={credentialValues} uiSchema={credentialsUiSchema}
inputValues={inputValues} initialValues={{}}
onCredentialChange={handleCredentialFieldChange} formContext={{
showHandles: false,
size: "large",
showOptionalToggle: false,
}}
/> />
</div> </div>
</div> </div>

View File

@@ -7,11 +7,12 @@ import {
GraphExecutionMeta, GraphExecutionMeta,
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useToast } from "@/components/molecules/Toast/use-toast"; import { useToast } from "@/components/molecules/Toast/use-toast";
import { useReactFlow } from "@xyflow/react"; import { useReactFlow } from "@xyflow/react";
import type { CredentialField } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/helpers";
export const useRunInputDialog = ({ export const useRunInputDialog = ({
setIsOpen, setIsOpen,
@@ -119,32 +120,27 @@ export const useRunInputDialog = ({
}, },
}); });
// Convert credentials schema to credential fields array for CredentialsGroupedView // We are rendering the credentials field differently compared to other fields.
const credentialFields: CredentialField[] = useMemo(() => { // In the node, we have the field name as "credential" - so our library catches it and renders it differently.
if (!credentialsSchema?.properties) return []; // But here we have a different name, something like `Firecrawl credentials`, so here we are telling the library that this field is a credential field type.
return Object.entries(credentialsSchema.properties);
}, [credentialsSchema]);
// Get required credentials as a Set const credentialsUiSchema = useMemo(() => {
const requiredCredentials = useMemo(() => { const dynamicUiSchema: any = { ...uiSchema };
return new Set<string>(credentialsSchema?.required || []);
}, [credentialsSchema]);
// Handler for individual credential changes if (credentialsSchema?.properties) {
const handleCredentialFieldChange = useCallback( Object.keys(credentialsSchema.properties).forEach((fieldName) => {
(key: string, value?: CredentialsMetaInput) => { const fieldSchema = credentialsSchema.properties[fieldName];
setCredentialValues((prev) => { if (isCredentialFieldSchema(fieldSchema)) {
if (value) { dynamicUiSchema[fieldName] = {
return { ...prev, [key]: value }; ...dynamicUiSchema[fieldName],
} else { "ui:field": "custom/credential_field",
const next = { ...prev }; };
delete next[key];
return next;
} }
}); });
}, }
[],
); return dynamicUiSchema;
}, [credentialsSchema]);
const handleManualRun = async () => { const handleManualRun = async () => {
// Filter out incomplete credentials (those without a valid id) // Filter out incomplete credentials (those without a valid id)
@@ -177,14 +173,12 @@ export const useRunInputDialog = ({
}; };
return { return {
credentialFields, credentialsUiSchema,
requiredCredentials,
inputValues, inputValues,
credentialValues, credentialValues,
isExecutingGraph, isExecutingGraph,
handleInputChange, handleInputChange,
handleCredentialChange, handleCredentialChange,
handleCredentialFieldChange,
handleManualRun, handleManualRun,
openCronSchedulerDialog, openCronSchedulerDialog,
setOpenCronSchedulerDialog, setOpenCronSchedulerDialog,

View File

@@ -139,6 +139,14 @@ export const useFlow = () => {
useNodeStore.getState().setNodes([]); useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState(); useNodeStore.getState().clearResolutionState();
addNodes(customNodes); addNodes(customNodes);
// Sync hardcoded values with handle IDs.
// If a keyvalue field has a key without a value, the backend omits it from hardcoded values.
// But if a handleId exists for that key, it causes inconsistency.
// This ensures hardcoded values stay in sync with handle IDs.
customNodes.forEach((node) => {
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
});
} }
}, [customNodes, addNodes]); }, [customNodes, addNodes]);
@@ -150,14 +158,6 @@ export const useFlow = () => {
} }
}, [graph?.links, addLinks]); }, [graph?.links, addLinks]);
useEffect(() => {
if (customNodes.length > 0 && graph?.links) {
customNodes.forEach((node) => {
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
});
}
}, [customNodes, graph?.links]);
// update node execution status in nodes // update node execution status in nodes
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,21 +1,22 @@
import { Button } from "@/components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text"; import { Text } from "@/components/atoms/Text/Text";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/molecules/Accordion/Accordion";
import { beautifyString, cn } from "@/lib/utils"; import { beautifyString, cn } from "@/lib/utils";
import { CopyIcon, CheckIcon } from "@phosphor-icons/react"; import { CaretDownIcon, CopyIcon, CheckIcon } from "@phosphor-icons/react";
import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer"; import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer";
import { ContentRenderer } from "./components/ContentRenderer"; import { ContentRenderer } from "./components/ContentRenderer";
import { useNodeOutput } from "./useNodeOutput"; import { useNodeOutput } from "./useNodeOutput";
import { ViewMoreData } from "./components/ViewMoreData"; import { ViewMoreData } from "./components/ViewMoreData";
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => { export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
const { outputData, copiedKey, handleCopy, executionResultId, inputData } = const {
useNodeOutput(nodeId); outputData,
isExpanded,
setIsExpanded,
copiedKey,
handleCopy,
executionResultId,
inputData,
} = useNodeOutput(nodeId);
if (Object.keys(outputData).length === 0) { if (Object.keys(outputData).length === 0) {
return null; return null;
@@ -24,117 +25,122 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
return ( return (
<div <div
data-tutorial-id={`node-output`} data-tutorial-id={`node-output`}
className="rounded-b-xl border-t border-zinc-200 px-4 py-2" className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4"
> >
<Accordion type="single" collapsible defaultValue="node-output"> <div className="flex items-center justify-between">
<AccordionItem value="node-output" className="border-none"> <Text variant="body-medium" className="!font-semibold text-slate-700">
<AccordionTrigger className="py-2 hover:no-underline"> Node Output
<Text </Text>
variant="body-medium" <Button
className="!font-semibold text-slate-700" variant="ghost"
> size="small"
Node Output onClick={() => setIsExpanded(!isExpanded)}
</Text> className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
</AccordionTrigger> >
<AccordionContent className="pt-2"> <CaretDownIcon
<div className="flex max-w-[350px] flex-col gap-4"> size={16}
<div className="space-y-2"> weight="bold"
<Text variant="small-medium">Input</Text> className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
<ContentRenderer value={inputData} shortContent={false} /> {isExpanded && (
<>
<div className="flex max-w-[350px] flex-col gap-4">
<div className="space-y-2">
<Text variant="small-medium">Input</Text>
<div className="mt-1 flex justify-end gap-1"> <ContentRenderer value={inputData} shortContent={false} />
<NodeDataViewer
data={inputData} <div className="mt-1 flex justify-end gap-1">
pinName="Input" <NodeDataViewer
execId={executionResultId} data={inputData}
/> pinName="Input"
<Button execId={executionResultId}
variant="secondary" />
size="small" <Button
onClick={() => handleCopy("input", inputData)} variant="secondary"
className={cn( size="small"
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900", onClick={() => handleCopy("input", inputData)}
copiedKey === "input" && className={cn(
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200", "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
)} copiedKey === "input" &&
> "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
{copiedKey === "input" ? ( )}
<CheckIcon size={12} className="text-green-600" /> >
) : ( {copiedKey === "input" ? (
<CopyIcon size={12} /> <CheckIcon size={12} className="text-green-600" />
)} ) : (
</Button> <CopyIcon size={12} />
</div> )}
</Button>
</div> </div>
</div>
{Object.entries(outputData) {Object.entries(outputData)
.slice(0, 2) .slice(0, 2)
.map(([key, value]) => ( .map(([key, value]) => (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
variant="small-medium" variant="small-medium"
className="!font-semibold text-slate-600" className="!font-semibold text-slate-600"
> >
Pin: Pin:
</Text> </Text>
<Text variant="small" className="text-slate-700"> <Text variant="small" className="text-slate-700">
{beautifyString(key)} {beautifyString(key)}
</Text> </Text>
</div> </div>
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<Text <Text
variant="small" variant="small"
className="!font-semibold text-slate-600" className="!font-semibold text-slate-600"
> >
Data: Data:
</Text> </Text>
<div className="relative space-y-2"> <div className="relative space-y-2">
{value.map((item, index) => ( {value.map((item, index) => (
<div key={index}> <div key={index}>
<ContentRenderer value={item} shortContent={true} /> <ContentRenderer value={item} shortContent={true} />
</div>
))}
<div className="mt-1 flex justify-end gap-1">
<NodeDataViewer
data={value}
pinName={key}
execId={executionResultId}
/>
<Button
variant="secondary"
size="small"
onClick={() => handleCopy(key, value)}
className={cn(
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
copiedKey === key &&
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
)}
>
{copiedKey === key ? (
<CheckIcon size={12} className="text-green-600" />
) : (
<CopyIcon size={12} />
)}
</Button>
</div> </div>
))}
<div className="mt-1 flex justify-end gap-1">
<NodeDataViewer
data={value}
pinName={key}
execId={executionResultId}
/>
<Button
variant="secondary"
size="small"
onClick={() => handleCopy(key, value)}
className={cn(
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
copiedKey === key &&
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
)}
>
{copiedKey === key ? (
<CheckIcon size={12} className="text-green-600" />
) : (
<CopyIcon size={12} />
)}
</Button>
</div> </div>
</div> </div>
</div> </div>
))} </div>
</div> ))}
</div>
{Object.keys(outputData).length > 2 && ( {Object.keys(outputData).length > 2 && (
<ViewMoreData <ViewMoreData outputData={outputData} execId={executionResultId} />
outputData={outputData} )}
execId={executionResultId} </>
/> )}
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

View File

@@ -4,6 +4,7 @@ import { useShallow } from "zustand/react/shallow";
import { useState } from "react"; import { useState } from "react";
export const useNodeOutput = (nodeId: string) => { export const useNodeOutput = (nodeId: string) => {
const [isExpanded, setIsExpanded] = useState(true);
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copiedKey, setCopiedKey] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
@@ -36,10 +37,13 @@ export const useNodeOutput = (nodeId: string) => {
} }
}; };
return { return {
outputData, outputData: outputData,
inputData, inputData: inputData,
copiedKey, isExpanded: isExpanded,
handleCopy, setIsExpanded: setIsExpanded,
copiedKey: copiedKey,
setCopiedKey: setCopiedKey,
handleCopy: handleCopy,
executionResultId: nodeExecutionResult?.node_exec_id, executionResultId: nodeExecutionResult?.node_exec_id,
}; };
}; };

View File

@@ -61,18 +61,12 @@ export const convertNodesPlusBlockInfoIntoCustomNodes = (
return customNode; return customNode;
}; };
const isToolSourceName = (sourceName: string): boolean =>
sourceName.startsWith("tools_^_");
const cleanupSourceName = (sourceName: string): string =>
isToolSourceName(sourceName) ? "tools" : sourceName;
export const linkToCustomEdge = (link: Link): CustomEdge => ({ export const linkToCustomEdge = (link: Link): CustomEdge => ({
id: link.id ?? "", id: link.id ?? "",
type: "custom" as const, type: "custom" as const,
source: link.source_id, source: link.source_id,
target: link.sink_id, target: link.sink_id,
sourceHandle: cleanupSourceName(link.source_name), sourceHandle: link.source_name,
targetHandle: link.sink_name, targetHandle: link.sink_name,
data: { data: {
isStatic: link.is_static, isStatic: link.is_static,

View File

@@ -5,37 +5,30 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/molecules/Accordion/Accordion"; } from "@/components/molecules/Accordion/Accordion";
import {
CredentialsMetaInput,
CredentialsType,
} from "@/lib/autogpt-server-api/types";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider"; import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { SlidersHorizontalIcon } from "@phosphor-icons/react"; import { SlidersHorizontal } from "@phosphor-icons/react";
import { useContext, useEffect, useMemo, useRef } from "react"; import { useContext, useEffect, useMemo, useRef } from "react";
import { useRunAgentModalContext } from "../../context";
import { import {
areSystemCredentialProvidersLoading, areSystemCredentialProvidersLoading,
CredentialField, CredentialField,
findSavedCredentialByProviderAndType, findSavedCredentialByProviderAndType,
hasMissingRequiredSystemCredentials, hasMissingRequiredSystemCredentials,
splitCredentialFieldsBySystem, splitCredentialFieldsBySystem,
} from "./helpers"; } from "../helpers";
type Props = { type Props = {
credentialFields: CredentialField[]; credentialFields: CredentialField[];
requiredCredentials: Set<string>; requiredCredentials: Set<string>;
inputCredentials: Record<string, CredentialsMetaInput | undefined>;
inputValues: Record<string, any>;
onCredentialChange: (key: string, value?: CredentialsMetaInput) => void;
}; };
export function CredentialsGroupedView({ export function CredentialsGroupedView({
credentialFields, credentialFields,
requiredCredentials, requiredCredentials,
inputCredentials,
inputValues,
onCredentialChange,
}: Props) { }: Props) {
const allProviders = useContext(CredentialsProvidersContext); const allProviders = useContext(CredentialsProvidersContext);
const { inputCredentials, setInputCredentialsValue, inputValues } =
useRunAgentModalContext();
const { userCredentialFields, systemCredentialFields } = useMemo( const { userCredentialFields, systemCredentialFields } = useMemo(
() => () =>
@@ -94,11 +87,11 @@ export function CredentialsGroupedView({
); );
if (savedCredential) { if (savedCredential) {
onCredentialChange(key, { setInputCredentialsValue(key, {
id: savedCredential.id, id: savedCredential.id,
provider: savedCredential.provider, provider: savedCredential.provider,
type: savedCredential.type as CredentialsType, type: savedCredential.type,
title: savedCredential.title, title: (savedCredential as { title?: string }).title,
}); });
} }
} }
@@ -110,7 +103,7 @@ export function CredentialsGroupedView({
systemCredentialFields, systemCredentialFields,
requiredCredentials, requiredCredentials,
inputCredentials, inputCredentials,
onCredentialChange, setInputCredentialsValue,
isLoadingProviders, isLoadingProviders,
]); ]);
@@ -130,7 +123,7 @@ export function CredentialsGroupedView({
} }
selectedCredentials={selectedCred} selectedCredentials={selectedCred}
onSelectCredentials={(value) => { onSelectCredentials={(value) => {
onCredentialChange(key, value); setInputCredentialsValue(key, value);
}} }}
siblingInputs={inputValues} siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)} isOptional={!requiredCredentials.has(key)}
@@ -150,8 +143,7 @@ export function CredentialsGroupedView({
<AccordionItem value="system-credentials" className="border-none"> <AccordionItem value="system-credentials" className="border-none">
<AccordionTrigger className="py-2 text-sm text-muted-foreground hover:no-underline"> <AccordionTrigger className="py-2 text-sm text-muted-foreground hover:no-underline">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<SlidersHorizontalIcon size={16} weight="bold" /> System <SlidersHorizontal size={16} weight="bold" /> System credentials
credentials
{hasMissingSystemCredentials && ( {hasMissingSystemCredentials && (
<span className="text-destructive">(missing)</span> <span className="text-destructive">(missing)</span>
)} )}
@@ -171,7 +163,7 @@ export function CredentialsGroupedView({
} }
selectedCredentials={selectedCred} selectedCredentials={selectedCred}
onSelectCredentials={(value) => { onSelectCredentials={(value) => {
onCredentialChange(key, value); setInputCredentialsValue(key, value);
}} }}
siblingInputs={inputValues} siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)} isOptional={!requiredCredentials.has(key)}

View File

@@ -1,9 +1,9 @@
import { Input } from "@/components/atoms/Input/Input"; import { Input } from "@/components/atoms/Input/Input";
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { useMemo } from "react"; import { useMemo } from "react";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs"; import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context"; import { useRunAgentModalContext } from "../../context";
import { CredentialsGroupedView } from "../CredentialsGroupedView/CredentialsGroupedView";
import { ModalSection } from "../ModalSection/ModalSection"; import { ModalSection } from "../ModalSection/ModalSection";
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner"; import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
@@ -19,8 +19,6 @@ export function ModalRunSection() {
setInputValue, setInputValue,
agentInputFields, agentInputFields,
agentCredentialsInputFields, agentCredentialsInputFields,
inputCredentials,
setInputCredentialsValue,
} = useRunAgentModalContext(); } = useRunAgentModalContext();
const inputFields = Object.entries(agentInputFields || {}); const inputFields = Object.entries(agentInputFields || {});
@@ -104,9 +102,6 @@ export function ModalRunSection() {
<CredentialsGroupedView <CredentialsGroupedView
credentialFields={credentialFields} credentialFields={credentialFields}
requiredCredentials={requiredCredentials} requiredCredentials={requiredCredentials}
inputCredentials={inputCredentials}
inputValues={inputValues}
onCredentialChange={setInputCredentialsValue}
/> />
</ModalSection> </ModalSection>
) : null} ) : null}

View File

@@ -1,5 +1,5 @@
import { CredentialsProvidersContextType } from "@/providers/agent-credentials/credentials-provider"; import { CredentialsProvidersContextType } from "@/providers/agent-credentials/credentials-provider";
import { getSystemCredentials } from "../../helpers"; import { getSystemCredentials } from "../../../../../../../../../../../components/contextual/CredentialsInput/helpers";
export type CredentialField = [string, any]; export type CredentialField = [string, any];

View File

@@ -5,8 +5,6 @@ import {
CarouselContent, CarouselContent,
CarouselItem, CarouselItem,
} from "@/components/__legacy__/ui/carousel"; } from "@/components/__legacy__/ui/carousel";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
import { useAgentsSection } from "./useAgentsSection"; import { useAgentsSection } from "./useAgentsSection";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent"; import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import { StoreCard } from "../StoreCard/StoreCard"; import { StoreCard } from "../StoreCard/StoreCard";
@@ -43,14 +41,12 @@ export const AgentsSection = ({
return ( return (
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]"> <div className="w-full max-w-[1360px]">
<FadeIn direction="left" duration={0.5}> <h2
<h2 style={{ marginBottom: margin }}
style={{ marginBottom: margin }} className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
className="font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200" >
> {sectionTitle}
{sectionTitle} </h2>
</h2>
</FadeIn>
{!displayedAgents || displayedAgents.length === 0 ? ( {!displayedAgents || displayedAgents.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400"> <div className="text-center text-gray-500 dark:text-gray-400">
No agents found No agents found
@@ -58,38 +54,32 @@ export const AgentsSection = ({
) : ( ) : (
<> <>
{/* Mobile Carousel View */} {/* Mobile Carousel View */}
<FadeIn direction="up" className="md:hidden"> <Carousel
<Carousel className="md:hidden"
opts={{ opts={{
loop: true, loop: true,
}} }}
>
<CarouselContent>
{displayedAgents.map((agent, index) => (
<CarouselItem key={index} className="min-w-64 max-w-71">
<StoreCard
agentName={agent.agent_name}
agentImage={agent.agent_image}
description={agent.description}
runs={agent.runs}
rating={agent.rating}
avatarSrc={agent.creator_avatar}
creatorName={agent.creator}
hideAvatar={hideAvatars}
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</FadeIn>
{/* Desktop Grid View with Staggered Animation */}
<StaggeredList
direction="up"
staggerDelay={0.08}
className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
> >
<CarouselContent>
{displayedAgents.map((agent, index) => (
<CarouselItem key={index} className="min-w-64 max-w-71">
<StoreCard
agentName={agent.agent_name}
agentImage={agent.agent_image}
description={agent.description}
runs={agent.runs}
rating={agent.rating}
avatarSrc={agent.creator_avatar}
creatorName={agent.creator}
hideAvatar={hideAvatars}
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{displayedAgents.map((agent, index) => ( {displayedAgents.map((agent, index) => (
<StoreCard <StoreCard
key={index} key={index}
@@ -104,7 +94,7 @@ export const AgentsSection = ({
onClick={() => handleCardClick(agent.creator, agent.slug)} onClick={() => handleCardClick(agent.creator, agent.slug)}
/> />
))} ))}
</StaggeredList> </div>
</> </>
)} )}
</div> </div>

View File

@@ -38,7 +38,7 @@ export function BecomeACreator({
<PublishAgentModal <PublishAgentModal
trigger={ trigger={
<button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:focus-visible:ring-neutral-50 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5"> <button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5">
<span className="whitespace-nowrap font-poppins text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7"> <span className="whitespace-nowrap font-poppins text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7">
{buttonText} {buttonText}
</span> </span>

View File

@@ -20,18 +20,9 @@ export const CreatorCard = ({
}: CreatorCardProps) => { }: CreatorCardProps) => {
return ( return (
<div <div
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-[filter] duration-200 hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-50`} className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-all duration-200 hover:brightness-95`}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
data-testid="creator-card" data-testid="creator-card"
role="button"
tabIndex={0}
aria-label={`View ${creatorName}'s profile - ${agentsUploaded} agents`}
> >
<div className="relative h-[64px] w-[64px]"> <div className="relative h-[64px] w-[64px]">
<div className="absolute inset-0 overflow-hidden rounded-full"> <div className="absolute inset-0 overflow-hidden rounded-full">

View File

@@ -1,7 +1,5 @@
"use client"; "use client";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
import { CreatorCard } from "../CreatorCard/CreatorCard"; import { CreatorCard } from "../CreatorCard/CreatorCard";
import { useFeaturedCreators } from "./useFeaturedCreators"; import { useFeaturedCreators } from "./useFeaturedCreators";
import { Creator } from "@/app/api/__generated__/models/creator"; import { Creator } from "@/app/api/__generated__/models/creator";
@@ -21,17 +19,11 @@ export const FeaturedCreators = ({
return ( return (
<div className="flex w-full flex-col items-center justify-center"> <div className="flex w-full flex-col items-center justify-center">
<div className="w-full max-w-[1360px]"> <div className="w-full max-w-[1360px]">
<FadeIn direction="left" duration={0.5}> <h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200"> {title}
{title} </h2>
</h2>
</FadeIn>
<StaggeredList <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
direction="up"
staggerDelay={0.1}
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
>
{displayedCreators.map((creator, index) => ( {displayedCreators.map((creator, index) => (
<CreatorCard <CreatorCard
key={index} key={index}
@@ -43,7 +35,7 @@ export const FeaturedCreators = ({
index={index} index={index}
/> />
))} ))}
</StaggeredList> </div>
</div> </div>
</div> </div>
); );

View File

@@ -8,7 +8,6 @@ import {
CarouselNext, CarouselNext,
CarouselIndicator, CarouselIndicator,
} from "@/components/__legacy__/ui/carousel"; } from "@/components/__legacy__/ui/carousel";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import Link from "next/link"; import Link from "next/link";
import { useFeaturedSection } from "./useFeaturedSection"; import { useFeaturedSection } from "./useFeaturedSection";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent"; import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
@@ -26,44 +25,40 @@ export const FeaturedSection = ({ featuredAgents }: FeaturedSectionProps) => {
return ( return (
<section className="w-full"> <section className="w-full">
<FadeIn direction="left" duration={0.5}> <h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200"> Featured agents
Featured agents </h2>
</h2>
</FadeIn>
<FadeIn direction="up" duration={0.6} delay={0.1}> <Carousel
<Carousel opts={{
opts={{ align: "center",
align: "center", containScroll: "trimSnaps",
containScroll: "trimSnaps", }}
}} >
> <CarouselContent>
<CarouselContent> {featuredAgents.map((agent, index) => (
{featuredAgents.map((agent, index) => ( <CarouselItem
<CarouselItem key={index}
key={index} className="h-[480px] md:basis-1/2 lg:basis-1/3"
className="h-[480px] md:basis-1/2 lg:basis-1/3" >
<Link
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
className="block h-full"
> >
<Link <FeaturedAgentCard
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`} agent={agent}
className="block h-full" backgroundColor={getBackgroundColor(index)}
> />
<FeaturedAgentCard </Link>
agent={agent} </CarouselItem>
backgroundColor={getBackgroundColor(index)} ))}
/> </CarouselContent>
</Link> <div className="relative mt-4">
</CarouselItem> <CarouselIndicator />
))} <CarouselPrevious afterClick={handlePrevSlide} />
</CarouselContent> <CarouselNext afterClick={handleNextSlide} />
<div className="relative mt-4"> </div>
<CarouselIndicator /> </Carousel>
<CarouselPrevious afterClick={handlePrevSlide} />
<CarouselNext afterClick={handleNextSlide} />
</div>
</Carousel>
</FadeIn>
</section> </section>
); );
}; };

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { FilterChip } from "@/components/atoms/FilterChip/FilterChip"; import { Badge } from "@/components/__legacy__/ui/badge";
import { useFilterChips } from "./useFilterChips"; import { useFilterChips } from "./useFilterChips";
interface FilterChipsProps { interface FilterChipsProps {
@@ -9,6 +9,8 @@ interface FilterChipsProps {
multiSelect?: boolean; multiSelect?: boolean;
} }
// Some flaws in its logic
// FRONTEND-TODO : This needs to be fixed
export const FilterChips = ({ export const FilterChips = ({
badges, badges,
onFilterChange, onFilterChange,
@@ -20,20 +22,18 @@ export const FilterChips = ({
}); });
return ( return (
<div <div className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5">
className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5"
role="group"
aria-label="Filter options"
>
{badges.map((badge) => ( {badges.map((badge) => (
<FilterChip <Badge
key={badge} key={badge}
label={badge} variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
selected={selectedFilters.includes(badge)} className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
onClick={() => handleBadgeClick(badge)} onClick={() => handleBadgeClick(badge)}
size="lg" >
className="mb-2 lg:mb-3" <div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
/> {badge}
</div>
</Badge>
))} ))}
</div> </div>
); );

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { FilterChips } from "../FilterChips/FilterChips"; import { FilterChips } from "../FilterChips/FilterChips";
import { SearchBar } from "../SearchBar/SearchBar"; import { SearchBar } from "../SearchBar/SearchBar";
import { useHeroSection } from "./useHeroSection"; import { useHeroSection } from "./useHeroSection";
@@ -10,36 +9,30 @@ export const HeroSection = () => {
return ( return (
<div className="mb-2 mt-8 flex flex-col items-center justify-center px-4 sm:mb-4 sm:mt-12 sm:px-6 md:mb-6 md:mt-16 lg:my-24 lg:px-8 xl:my-16"> <div className="mb-2 mt-8 flex flex-col items-center justify-center px-4 sm:mb-4 sm:mt-12 sm:px-6 md:mb-6 md:mt-16 lg:my-24 lg:px-8 xl:my-16">
<div className="w-full max-w-3xl lg:max-w-4xl xl:max-w-5xl"> <div className="w-full max-w-3xl lg:max-w-4xl xl:max-w-5xl">
<FadeIn direction="down" duration={0.6} delay={0}> <div className="mb-4 text-center md:mb-8">
<div className="mb-4 text-center md:mb-8"> <h1 className="text-center">
<h1 className="text-center"> <span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50"> Explore AI agents built for{" "}
Explore AI agents built for{" "} </span>
</span> <span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600"> you
you </span>
</span> <br />
<br /> <span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50"> by the{" "}
by the{" "} </span>
</span> <span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500"> community
community </span>
</span> </h1>
</h1> </div>
</div> <h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12">
</FadeIn> Bringing you AI agents designed by thinkers from around the world
<FadeIn direction="up" duration={0.6} delay={0.15}> </h3>
<h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12"> <div className="mb-4 flex justify-center sm:mb-5">
Bringing you AI agents designed by thinkers from around the world <SearchBar height="h-[74px]" />
</h3> </div>
</FadeIn> <div>
<FadeIn direction="up" duration={0.5} delay={0.3}>
<div className="mb-4 flex justify-center sm:mb-5">
<SearchBar height="h-[74px]" />
</div>
</FadeIn>
<FadeIn direction="up" duration={0.5} delay={0.4}>
<div className="flex justify-center"> <div className="flex justify-center">
<FilterChips <FilterChips
badges={searchTerms} badges={searchTerms}
@@ -47,7 +40,7 @@ export const HeroSection = () => {
multiSelect={false} multiSelect={false}
/> />
</div> </div>
</FadeIn> </div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { Separator } from "@/components/atoms/Separator/Separator"; import { Separator } from "@/components/__legacy__/ui/separator";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { FeaturedSection } from "../FeaturedSection/FeaturedSection"; import { FeaturedSection } from "../FeaturedSection/FeaturedSection";
import { BecomeACreator } from "../BecomeACreator/BecomeACreator"; import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
import { HeroSection } from "../HeroSection/HeroSection"; import { HeroSection } from "../HeroSection/HeroSection";
@@ -55,13 +54,11 @@ export const MainMarkeplacePage = () => {
<FeaturedCreators featuredCreators={featuredCreators.creators} /> <FeaturedCreators featuredCreators={featuredCreators.creators} />
)} )}
<Separator className="mb-[25px] mt-[60px]" /> <Separator className="mb-[25px] mt-[60px]" />
<FadeIn direction="up" duration={0.6}> <BecomeACreator
<BecomeACreator title="Become a Creator"
title="Become a Creator" description="Join our ever-growing community of hackers and tinkerers"
description="Join our ever-growing community of hackers and tinkerers" buttonText="Become a Creator"
buttonText="Become a Creator" />
/>
</FadeIn>
</main> </main>
</div> </div>
); );

View File

@@ -16,9 +16,9 @@ interface SearchBarProps {
export const SearchBar = ({ export const SearchBar = ({
placeholder = 'Search for tasks like "optimise SEO"', placeholder = 'Search for tasks like "optimise SEO"',
backgroundColor = "bg-neutral-100 dark:bg-neutral-800", backgroundColor = "bg-neutral-100 dark:bg-neutral-800",
iconColor = "text-neutral-500 dark:text-neutral-400", iconColor = "text-[#646464] dark:text-neutral-400",
textColor = "text-neutral-500 dark:text-neutral-200", textColor = "text-[#707070] dark:text-neutral-200",
placeholderColor = "text-neutral-500 dark:text-neutral-400", placeholderColor = "text-[#707070] dark:text-neutral-400",
width = "w-9/10 lg:w-[56.25rem]", width = "w-9/10 lg:w-[56.25rem]",
height = "h-[60px]", height = "h-[60px]",
}: SearchBarProps) => { }: SearchBarProps) => {
@@ -32,13 +32,10 @@ export const SearchBar = ({
> >
<MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} /> <MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} />
<input <input
type="search" type="text"
name="search"
autoComplete="off"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
aria-label="Search for AI agents"
className={`flex-grow border-none bg-transparent ${textColor} font-sans text-lg font-normal leading-[2.25rem] tracking-tight md:text-xl placeholder:${placeholderColor} focus:outline-none`} className={`flex-grow border-none bg-transparent ${textColor} font-sans text-lg font-normal leading-[2.25rem] tracking-tight md:text-xl placeholder:${placeholderColor} focus:outline-none`}
data-testid="store-search-input" data-testid="store-search-input"
/> />

View File

@@ -1,25 +1,10 @@
import Image from "next/image"; import Image from "next/image";
import { Star } from "@phosphor-icons/react"; import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
import Avatar, { import Avatar, {
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from "@/components/atoms/Avatar/Avatar"; } from "@/components/atoms/Avatar/Avatar";
function StarRating({ rating }: { rating: number }) {
const stars = [];
const clampedRating = Math.max(0, Math.min(5, rating));
for (let i = 1; i <= 5; i++) {
stars.push(
<Star
key={i}
weight={i <= clampedRating ? "fill" : "regular"}
className="h-4 w-4 text-neutral-900 dark:text-yellow-500"
/>,
);
}
return <>{stars}</>;
}
interface StoreCardProps { interface StoreCardProps {
agentName: string; agentName: string;
agentImage: string; agentImage: string;
@@ -49,7 +34,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
return ( return (
<div <div
className="flex h-[27rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-shadow duration-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:hover:shadow-gray-700 dark:focus-visible:ring-neutral-50" className="flex h-[27rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-all duration-300 hover:shadow-lg dark:hover:shadow-gray-700"
onClick={handleClick} onClick={handleClick}
data-testid="store-card" data-testid="store-card"
role="button" role="button"
@@ -91,7 +76,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
<div className="mt-3 flex w-full flex-1 flex-col px-4"> <div className="mt-3 flex w-full flex-1 flex-col px-4">
{/* Second Section: Agent Name and Creator Name */} {/* Second Section: Agent Name and Creator Name */}
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-neutral-800 dark:text-neutral-100"> <h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-[#272727] dark:text-neutral-100">
{agentName} {agentName}
</h3> </h3>
{!hideAvatar && creatorName && ( {!hideAvatar && creatorName && (
@@ -122,11 +107,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
{rating.toFixed(1)} {rating.toFixed(1)}
</span> </span>
<div <div
className="inline-flex items-center gap-0.5" className="inline-flex items-center"
role="img" role="img"
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`} aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
> >
<StarRating rating={rating} /> {StarRatingIcons(rating)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,151 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { FilterChip } from "./FilterChip";
const meta: Meta<typeof FilterChip> = {
title: "Atoms/FilterChip",
component: FilterChip,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
argTypes: {
size: {
control: "select",
options: ["sm", "md", "lg"],
},
},
};
export default meta;
type Story = StoryObj<typeof FilterChip>;
export const Default: Story = {
args: {
label: "Marketing",
},
};
export const Selected: Story = {
args: {
label: "Marketing",
selected: true,
},
};
export const Dismissible: Story = {
args: {
label: "Marketing",
selected: true,
dismissible: true,
},
};
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<FilterChip label="Small" size="sm" />
<FilterChip label="Medium" size="md" />
<FilterChip label="Large" size="lg" />
</div>
),
};
export const Disabled: Story = {
args: {
label: "Disabled",
disabled: true,
},
};
function FilterChipGroupDemo() {
const filters = [
"Marketing",
"Sales",
"Development",
"Design",
"Research",
"Analytics",
];
const [selected, setSelected] = useState<string[]>(["Marketing"]);
function handleToggle(filter: string) {
setSelected((prev) =>
prev.includes(filter)
? prev.filter((f) => f !== filter)
: [...prev, filter],
);
}
return (
<div className="flex flex-wrap gap-3">
{filters.map((filter) => (
<FilterChip
key={filter}
label={filter}
selected={selected.includes(filter)}
onClick={() => handleToggle(filter)}
/>
))}
</div>
);
}
export const FilterGroup: Story = {
render: () => <FilterChipGroupDemo />,
};
function SingleSelectDemo() {
const filters = ["All", "Featured", "Popular", "New"];
const [selected, setSelected] = useState("All");
return (
<div className="flex flex-wrap gap-3">
{filters.map((filter) => (
<FilterChip
key={filter}
label={filter}
selected={selected === filter}
onClick={() => setSelected(filter)}
/>
))}
</div>
);
}
export const SingleSelect: Story = {
render: () => <SingleSelectDemo />,
};
function DismissibleDemo() {
const [filters, setFilters] = useState([
"Marketing",
"Sales",
"Development",
]);
function handleDismiss(filter: string) {
setFilters((prev) => prev.filter((f) => f !== filter));
}
return (
<div className="flex flex-wrap gap-3">
{filters.map((filter) => (
<FilterChip
key={filter}
label={filter}
selected
dismissible
onDismiss={() => handleDismiss(filter)}
/>
))}
{filters.length === 0 && (
<span className="text-neutral-500">No filters selected</span>
)}
</div>
);
}
export const DismissibleGroup: Story = {
render: () => <DismissibleDemo />,
};

View File

@@ -1,100 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { X } from "@phosphor-icons/react";
type FilterChipSize = "sm" | "md" | "lg";
interface FilterChipProps {
/** The label text displayed in the chip */
label: string;
/** Whether the chip is currently selected */
selected?: boolean;
/** Callback when the chip is clicked */
onClick?: () => void;
/** Whether to show a dismiss/remove button */
dismissible?: boolean;
/** Callback when the dismiss button is clicked */
onDismiss?: () => void;
/** Size variant of the chip */
size?: FilterChipSize;
/** Whether the chip is disabled */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
const sizeStyles: Record<FilterChipSize, string> = {
sm: "px-3 py-1 text-sm gap-1.5",
md: "px-4 py-1.5 text-base gap-2",
lg: "px-6 py-2 text-lg gap-2.5 lg:text-xl lg:leading-9",
};
const iconSizes: Record<FilterChipSize, string> = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5",
};
/**
* A filter chip component for selecting/deselecting filter options.
* Supports single and multi-select patterns with proper accessibility.
*/
export function FilterChip({
label,
selected = false,
onClick,
dismissible = false,
onDismiss,
size = "md",
disabled = false,
className,
}: FilterChipProps) {
function handleDismiss(e: React.MouseEvent) {
e.stopPropagation();
onDismiss?.();
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={selected}
className={cn(
// Base styles
"inline-flex items-center justify-center rounded-full border font-medium transition-colors",
// Focus styles
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-50",
// Size styles
sizeStyles[size],
// State styles
selected
? "border-neutral-900 bg-neutral-100 text-neutral-800 dark:border-neutral-100 dark:bg-neutral-800 dark:text-neutral-200"
: "border-neutral-400 bg-transparent text-neutral-600 hover:bg-neutral-50 dark:border-neutral-500 dark:text-neutral-300 dark:hover:bg-neutral-800",
// Disabled styles
disabled && "pointer-events-none opacity-50",
className,
)}
>
<span>{label}</span>
{dismissible && selected && (
<span
role="button"
tabIndex={0}
onClick={handleDismiss}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDismiss(e as unknown as React.MouseEvent);
}
}}
className="rounded-full p-0.5 hover:bg-neutral-200 dark:hover:bg-neutral-700"
aria-label={`Remove ${label} filter`}
>
<X className={iconSizes[size]} weight="bold" />
</span>
)}
</button>
);
}

View File

@@ -1,72 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Separator } from "./Separator";
const meta: Meta<typeof Separator> = {
title: "Atoms/Separator",
component: Separator,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof Separator>;
export const Horizontal: Story = {
render: () => (
<div className="w-full max-w-md">
<p className="mb-4 text-neutral-700 dark:text-neutral-300">
Content above the separator
</p>
<Separator />
<p className="mt-4 text-neutral-700 dark:text-neutral-300">
Content below the separator
</p>
</div>
),
};
export const Vertical: Story = {
render: () => (
<div className="flex h-16 items-center gap-4">
<span className="text-neutral-700 dark:text-neutral-300">Left</span>
<Separator orientation="vertical" />
<span className="text-neutral-700 dark:text-neutral-300">Right</span>
</div>
),
};
export const WithCustomStyles: Story = {
render: () => (
<div className="w-full max-w-md space-y-4">
<Separator className="bg-violet-500" />
<Separator className="h-0.5 bg-gradient-to-r from-violet-500 to-blue-500" />
<Separator className="bg-neutral-400 dark:bg-neutral-600" />
</div>
),
};
export const InSection: Story = {
render: () => (
<div className="w-full max-w-md space-y-6">
<section>
<h2 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Featured Agents
</h2>
<p className="text-neutral-600 dark:text-neutral-400">
Browse our collection of featured AI agents.
</p>
</section>
<Separator className="my-6" />
<section>
<h2 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Top Creators
</h2>
<p className="text-neutral-600 dark:text-neutral-400">
Meet the creators behind the most popular agents.
</p>
</section>
</div>
),
};

View File

@@ -1,43 +0,0 @@
import { cn } from "@/lib/utils";
type SeparatorOrientation = "horizontal" | "vertical";
interface SeparatorProps {
/** The orientation of the separator */
orientation?: SeparatorOrientation;
/** Whether the separator is purely decorative (true) or represents a semantic boundary (false) */
decorative?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* A visual separator that divides content.
* Uses semantic `<hr>` for horizontal separators and a styled `<div>` for vertical.
*/
export function Separator({
orientation = "horizontal",
decorative = true,
className,
}: SeparatorProps) {
const baseStyles = "shrink-0 bg-neutral-200 dark:bg-neutral-800";
if (orientation === "horizontal") {
return (
<hr
className={cn(baseStyles, "h-px w-full border-0", className)}
aria-hidden={decorative}
role={decorative ? "none" : "separator"}
/>
);
}
return (
<div
className={cn(baseStyles, "h-full w-px", className)}
aria-hidden={decorative}
role={decorative ? "none" : "separator"}
aria-orientation="vertical"
/>
);
}

View File

@@ -1,128 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FadeIn } from "./FadeIn";
const meta: Meta<typeof FadeIn> = {
title: "Molecules/FadeIn",
component: FadeIn,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
argTypes: {
direction: {
control: "select",
options: ["up", "down", "left", "right", "none"],
},
},
};
export default meta;
type Story = StoryObj<typeof FadeIn>;
const DemoCard = ({ title }: { title: string }) => (
<div className="rounded-xl bg-neutral-100 p-6 dark:bg-neutral-800">
<h3 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
<p className="text-neutral-600 dark:text-neutral-400">
This card fades in with a smooth animation.
</p>
</div>
);
export const Default: Story = {
args: {
direction: "up",
children: <DemoCard title="Fade Up" />,
},
};
export const FadeDown: Story = {
args: {
direction: "down",
children: <DemoCard title="Fade Down" />,
},
};
export const FadeLeft: Story = {
args: {
direction: "left",
children: <DemoCard title="Fade Left" />,
},
};
export const FadeRight: Story = {
args: {
direction: "right",
children: <DemoCard title="Fade Right" />,
},
};
export const FadeOnly: Story = {
args: {
direction: "none",
children: <DemoCard title="Fade Only (No Direction)" />,
},
};
export const WithDelay: Story = {
args: {
direction: "up",
delay: 0.5,
children: <DemoCard title="Delayed Fade (0.5s)" />,
},
};
export const SlowAnimation: Story = {
args: {
direction: "up",
duration: 1.5,
children: <DemoCard title="Slow Animation (1.5s)" />,
},
};
export const LargeDistance: Story = {
args: {
direction: "up",
distance: 60,
children: <DemoCard title="Large Distance (60px)" />,
},
};
export const MultipleElements: Story = {
render: () => (
<div className="space-y-4">
<FadeIn direction="up" delay={0}>
<DemoCard title="First Card" />
</FadeIn>
<FadeIn direction="up" delay={0.1}>
<DemoCard title="Second Card" />
</FadeIn>
<FadeIn direction="up" delay={0.2}>
<DemoCard title="Third Card" />
</FadeIn>
</div>
),
};
export const HeroExample: Story = {
render: () => (
<div className="text-center">
<FadeIn direction="down" delay={0}>
<h1 className="mb-4 text-4xl font-bold text-neutral-900 dark:text-neutral-100">
Welcome to the Marketplace
</h1>
</FadeIn>
<FadeIn direction="up" delay={0.2}>
<p className="mb-8 text-xl text-neutral-600 dark:text-neutral-400">
Discover AI agents built by the community
</p>
</FadeIn>
<FadeIn direction="up" delay={0.4}>
<button className="rounded-full bg-violet-600 px-8 py-3 text-white hover:bg-violet-700">
Get Started
</button>
</FadeIn>
</div>
),
};

View File

@@ -1,109 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { ReactNode } from "react";
type FadeDirection = "up" | "down" | "left" | "right" | "none";
interface FadeInProps {
/** Content to animate */
children: ReactNode;
/** Direction the content fades in from */
direction?: FadeDirection;
/** Distance to travel in pixels (only applies when direction is not "none") */
distance?: number;
/** Animation duration in seconds */
duration?: number;
/** Delay before animation starts in seconds */
delay?: number;
/** Whether to trigger animation when element enters viewport */
viewport?: boolean;
/** How much of element must be visible to trigger (0-1) */
viewportAmount?: number;
/** Whether animation should only trigger once */
once?: boolean;
/** Additional CSS classes */
className?: string;
/** HTML element to render as */
as?: keyof JSX.IntrinsicElements;
}
function getDirectionOffset(
direction: FadeDirection,
distance: number,
): { x: number; y: number } {
switch (direction) {
case "up":
return { x: 0, y: distance };
case "down":
return { x: 0, y: -distance };
case "left":
return { x: distance, y: 0 };
case "right":
return { x: -distance, y: 0 };
case "none":
default:
return { x: 0, y: 0 };
}
}
/**
* A fade-in animation wrapper component.
* Animates children with a fade effect and optional directional slide.
* Respects user's reduced motion preferences.
*/
export function FadeIn({
children,
direction = "up",
distance = 24,
duration = 0.5,
delay = 0,
viewport = true,
viewportAmount = 0.2,
once = true,
className,
as = "div",
}: FadeInProps) {
const shouldReduceMotion = useReducedMotion();
const offset = getDirectionOffset(direction, distance);
// If user prefers reduced motion, render without animation
if (shouldReduceMotion) {
const Component = as as keyof JSX.IntrinsicElements;
return <Component className={className}>{children}</Component>;
}
const variants: Variants = {
hidden: {
opacity: 0,
x: offset.x,
y: offset.y,
},
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration,
delay,
ease: [0.25, 0.1, 0.25, 1], // Custom easing for smooth feel
},
},
};
const MotionComponent = motion[as as keyof typeof motion] as typeof motion.div;
return (
<MotionComponent
className={cn(className)}
initial="hidden"
animate={viewport ? undefined : "visible"}
whileInView={viewport ? "visible" : undefined}
viewport={viewport ? { once, amount: viewportAmount } : undefined}
variants={variants}
>
{children}
</MotionComponent>
);
}

View File

@@ -1,180 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { StaggeredList } from "./StaggeredList";
const meta: Meta<typeof StaggeredList> = {
title: "Molecules/StaggeredList",
component: StaggeredList,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
argTypes: {
direction: {
control: "select",
options: ["up", "down", "left", "right", "none"],
},
},
};
export default meta;
type Story = StoryObj<typeof StaggeredList>;
const DemoCard = ({ title, index }: { title: string; index: number }) => (
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-neutral-800">
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Card #{index + 1} with staggered animation
</p>
</div>
);
const items = ["First Item", "Second Item", "Third Item", "Fourth Item"];
export const Default: Story = {
args: {
direction: "up",
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FadeDown: Story = {
args: {
direction: "down",
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FadeLeft: Story = {
args: {
direction: "left",
className: "flex gap-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FadeRight: Story = {
args: {
direction: "right",
className: "flex gap-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FastStagger: Story = {
args: {
direction: "up",
staggerDelay: 0.05,
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const SlowStagger: Story = {
args: {
direction: "up",
staggerDelay: 0.3,
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const WithInitialDelay: Story = {
args: {
direction: "up",
initialDelay: 0.5,
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const GridLayout: Story = {
args: {
direction: "up",
staggerDelay: 0.08,
className: "grid grid-cols-2 gap-4 md:grid-cols-4",
children: [
...items,
"Fifth Item",
"Sixth Item",
"Seventh Item",
"Eighth Item",
].map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const AgentCardsExample: Story = {
render: () => {
const agents = [
{ name: "SEO Optimizer", runs: 1234 },
{ name: "Content Writer", runs: 987 },
{ name: "Data Analyzer", runs: 756 },
{ name: "Code Reviewer", runs: 543 },
];
return (
<StaggeredList
direction="up"
staggerDelay={0.1}
className="grid grid-cols-2 gap-6 md:grid-cols-4"
>
{agents.map((agent, i) => (
<div
key={i}
className="rounded-2xl bg-white p-4 shadow-md dark:bg-neutral-900"
>
<div className="mb-3 aspect-video rounded-xl bg-gradient-to-br from-violet-500 to-blue-500" />
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
{agent.name}
</h3>
<p className="text-sm text-neutral-500">{agent.runs} runs</p>
</div>
))}
</StaggeredList>
);
},
};
export const CreatorCardsExample: Story = {
render: () => {
const creators = [
{ name: "Alice", agents: 12 },
{ name: "Bob", agents: 8 },
{ name: "Charlie", agents: 15 },
{ name: "Diana", agents: 6 },
];
const colors = [
"bg-violet-100 dark:bg-violet-900/30",
"bg-blue-100 dark:bg-blue-900/30",
"bg-green-100 dark:bg-green-900/30",
"bg-orange-100 dark:bg-orange-900/30",
];
return (
<StaggeredList
direction="up"
staggerDelay={0.12}
className="grid grid-cols-2 gap-6 md:grid-cols-4"
>
{creators.map((creator, i) => (
<div
key={i}
className={`rounded-2xl p-5 ${colors[i % colors.length]}`}
>
<div className="mb-3 h-12 w-12 rounded-full bg-neutral-300 dark:bg-neutral-700" />
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
{creator.name}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{creator.agents} agents
</p>
</div>
))}
</StaggeredList>
);
},
};

View File

@@ -1,130 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { ReactNode } from "react";
type StaggerDirection = "up" | "down" | "left" | "right" | "none";
interface StaggeredListProps {
/** Array of items to render with staggered animation */
children: ReactNode[];
/** Direction items animate from */
direction?: StaggerDirection;
/** Distance to travel in pixels */
distance?: number;
/** Base duration for each item's animation */
duration?: number;
/** Delay between each item's animation start */
staggerDelay?: number;
/** Initial delay before first item animates */
initialDelay?: number;
/** Whether to trigger animation when element enters viewport */
viewport?: boolean;
/** How much of container must be visible to trigger */
viewportAmount?: number;
/** Whether animation should only trigger once */
once?: boolean;
/** Additional CSS classes for the container */
className?: string;
/** Additional CSS classes for each item wrapper */
itemClassName?: string;
}
function getDirectionOffset(
direction: StaggerDirection,
distance: number,
): { x: number; y: number } {
switch (direction) {
case "up":
return { x: 0, y: distance };
case "down":
return { x: 0, y: -distance };
case "left":
return { x: distance, y: 0 };
case "right":
return { x: -distance, y: 0 };
case "none":
default:
return { x: 0, y: 0 };
}
}
/**
* Animates a list of children with staggered fade-in effects.
* Each child appears sequentially with a configurable delay.
* Respects user's reduced motion preferences.
*/
export function StaggeredList({
children,
direction = "up",
distance = 20,
duration = 0.4,
staggerDelay = 0.1,
initialDelay = 0,
viewport = true,
viewportAmount = 0.1,
once = true,
className,
itemClassName,
}: StaggeredListProps) {
const shouldReduceMotion = useReducedMotion();
const offset = getDirectionOffset(direction, distance);
// If user prefers reduced motion, render without animation
if (shouldReduceMotion) {
return (
<div className={className}>
{children.map((child, index) => (
<div key={index} className={itemClassName}>
{child}
</div>
))}
</div>
);
}
const containerVariants: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay,
delayChildren: initialDelay,
},
},
};
const itemVariants: Variants = {
hidden: {
opacity: 0,
x: offset.x,
y: offset.y,
},
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
return (
<motion.div
className={cn(className)}
initial="hidden"
animate={viewport ? undefined : "visible"}
whileInView={viewport ? "visible" : undefined}
viewport={viewport ? { once, amount: viewportAmount } : undefined}
variants={containerVariants}
>
{children.map((child, index) => (
<motion.div key={index} className={itemClassName} variants={itemVariants}>
{child}
</motion.div>
))}
</motion.div>
);
}