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
# On Windows, unpacking cached dependencies takes longer than just installing them
if: runner.os != 'Windows'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ runner.os == 'macOS' && '~/Library/Caches/pypoetry' || '~/.cache/pypoetry' }}
key: poetry-${{ runner.os }}-${{ hashFiles('classic/original_autogpt/poetry.lock') }}

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ jobs:
python-version: ${{ env.min-python-version }}
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-poetry-${{ hashFiles(format('{0}/poetry.lock', matrix.sub-package)) }}
@@ -130,7 +130,7 @@ jobs:
python-version: ${{ env.min-python-version }}
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
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
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
@@ -91,7 +91,7 @@ jobs:
echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV
- name: Cache frontend dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
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
- name: Set up Docker image cache
id: docker-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/docker-cache
# 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
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
@@ -107,7 +107,7 @@ jobs:
echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV
- name: Cache frontend dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
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
- name: Set up Docker image cache
id: docker-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/docker-cache
# 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
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
@@ -89,7 +89,7 @@ jobs:
echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV
- name: Cache frontend dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
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
- name: Set up Docker image cache
id: docker-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/docker-cache
# Use a versioned key for cache invalidation when image list changes

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ jobs:
python-version: "3.11"
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
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
- name: Set up Python dependency cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
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
- name: Cache dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ steps.cache-key.outputs.key }}
@@ -73,7 +73,7 @@ jobs:
run: corepack enable
- name: Restore dependencies cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }}
@@ -108,7 +108,7 @@ jobs:
run: corepack enable
- name: Restore dependencies cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }}
@@ -164,7 +164,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
uses: actions/cache@v5
with:
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') }}
@@ -219,7 +219,7 @@ jobs:
fi
- name: Restore dependencies cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
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
- name: Cache dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ steps.cache-key.outputs.key }}
@@ -88,7 +88,7 @@ jobs:
docker compose -f ../docker-compose.yml --profile local --profile deps_backend up -d
- name: Restore dependencies cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }}

View File

@@ -4,9 +4,14 @@ from collections.abc import AsyncGenerator
from typing import Any
import orjson
from langfuse import get_client, propagate_attributes
from langfuse.openai import openai # type: ignore
from openai import APIConnectionError, APIError, APIStatusError, RateLimitError
from langfuse import Langfuse
from openai import (
APIConnectionError,
APIError,
APIStatusError,
AsyncOpenAI,
RateLimitError,
)
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
from backend.data.understanding import (
@@ -16,6 +21,7 @@ from backend.data.understanding import (
from backend.util.exceptions import NotFoundError
from backend.util.settings import Settings
from . import db as chat_db
from .config import ChatConfig
from .model import (
ChatMessage,
@@ -44,10 +50,10 @@ logger = logging.getLogger(__name__)
config = ChatConfig()
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 = get_client()
# Langfuse client (lazy initialization)
_langfuse_client: Langfuse | None = None
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]:
"""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)
"""
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)
@@ -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"
compiled = prompt.compile(users_information=context)
return compiled, understanding
return compiled, prompt
async def _generate_session_title(message: str) -> str | None:
@@ -150,7 +217,6 @@ async def assign_user_to_session(
async def stream_chat_completion(
session_id: str,
message: str | None = None,
tool_call_response: str | None = None,
is_user_message: bool = True,
user_id: str | None = None,
retry_count: int = 0,
@@ -190,6 +256,11 @@ async def stream_chat_completion(
yield StreamFinish()
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)
if session is None:
session = await get_chat_session(session_id, user_id)
@@ -265,259 +336,297 @@ async def stream_chat_completion(
asyncio.create_task(_update_title())
# 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)
# Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes
input = message
if not message and tool_call_response:
input = tool_call_response
langfuse = get_client()
with langfuse.start_as_current_observation(
as_type="span",
name="user-copilot-request",
input=input,
) as span:
with propagate_attributes(
try:
langfuse = _get_langfuse_client()
env = _get_environment()
trace = langfuse.start_observation(
name="chat_completion",
input={"messages": trace_input_messages},
metadata={
"environment": env,
"model": config.model,
"message_count": len(session.messages),
"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,
user_id=user_id,
tags=["copilot"],
metadata={
"users_information": format_understanding_for_prompt(understanding)[
:200
] # langfuse only accepts upto to 200 chars
},
):
tags=[env, "copilot"],
)
except Exception as e:
logger.warning(f"Failed to create Langfuse trace: {e}")
# Initialize variables that will be used in finally block (must be defined before try)
assistant_response = ChatMessage(
role="assistant",
content="",
# Initialize variables that will be used in finally block (must be defined before try)
assistant_response = ChatMessage(
role="assistant",
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
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
try:
async for chunk in _stream_chat_chunks(
session=session,
tools=tools,
system_prompt=system_prompt,
text_block_id=text_block_id,
):
# 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)
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
if isinstance(chunk, StreamTextStart):
# Emit text-start before first text delta
if not has_received_text:
yield chunk
elif isinstance(chunk, StreamTextEnd):
# Emit text-end after text completes
if has_received_text and not text_streaming_ended:
text_streaming_ended = True
if assistant_response.content:
logger.warn(
f"StreamTextEnd: Attempting to set output {assistant_response.content}"
)
span.update_trace(output=assistant_response.content)
span.update(output=assistant_response.content)
yield chunk
elif isinstance(chunk, StreamToolInputStart):
# Emit text-end before first tool call, but only if we've received text
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
elif isinstance(chunk, StreamTextEnd):
# Emit text-end after text completes
if has_received_text and not text_streaming_ended:
text_streaming_ended = True
yield chunk
elif isinstance(chunk, StreamToolInputStart):
# 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:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
has_yielded_end = 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, 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,
)
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:
# Non-retryable error or max retries exceeded
# Save any partial progress before reporting error
messages_to_save: list[ChatMessage] = []
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
except Exception as e:
logger.error(f"Error during stream: {e!s}", exc_info=True)
# 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)
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError))
# 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:
if is_retryable and retry_count < config.max_retries:
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(
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
should_retry = True
else:
# Non-retryable error or max retries exceeded
# Save any partial progress before reporting error
messages_to_save: list[ChatMessage] = []
# 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(
f"Normal completion path: session={session.session_id}, "
f"current message_count={len(session.messages)}"
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
)
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
messages_to_save: list[ChatMessage] = []
# Add tool response messages after assistant message
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
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 [])}"
)
session.messages.extend(messages_to_save)
logger.info(
f"Extended session messages, new message_count={len(session.messages)}"
)
await upsert_chat_session(session)
# Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages)
# If we did a tool call, stream the chat completion again to get the next response
if has_done_tool_call:
logger.info(
f"Saving {len(tool_response_messages)} tool response messages, "
f"total_to_save={len(messages_to_save)}"
"Tool call executed, streaming chat completion again to get assistant response"
)
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)
logger.info(
f"Extended session messages, new message_count={len(session.messages)}"
)
await upsert_chat_session(session)
# If we did a tool call, stream the chat completion again to get the next response
if has_done_tool_call:
logger.info(
"Tool call executed, streaming chat completion again to get assistant response"
finally:
# Always end Langfuse observations to prevent resource leaks
# Guard against None and catch errors to avoid masking original exceptions
if generation is not None:
try:
latest_usage = session.usage[-1] if session.usage else None
generation.update(
model=config.model,
output={
"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(
session_id=session.session_id,
user_id=user_id,
session=session, # Pass session object to avoid Redis refetch
context=context,
tool_call_response=str(tool_response_messages),
):
yield chunk
generation.end()
except Exception as e:
logger.warning(f"Failed to end Langfuse generation: {e}")
if trace is not None:
try:
if accumulated_tool_calls:
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
@@ -791,4 +900,5 @@ async def _yield_tool_call(
session=session,
)
logger.info(f"Yielding Tool execution response: {tool_execution_response}")
yield tool_execution_response

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ import logging
from pathlib import Path
from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tools.base import BaseTool
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
return f"{DOCS_BASE_URL}/{url_path}"
@observe(as_type="tool", name="get_doc_page")
async def _execute(
self,
user_id: str | None,

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
import logging
from typing import Any
from langfuse import observe
from prisma.enums import ContentType
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
return f"{DOCS_BASE_URL}/{url_path}"
@observe(as_type="tool", name="search_docs")
async def _execute(
self,
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:
"""Format business understanding as text for system prompt injection."""
if not understanding:
return ""
sections = []
# User info section

View File

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

View File

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

View File

@@ -139,6 +139,14 @@ export const useFlow = () => {
useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState();
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]);
@@ -150,14 +158,6 @@ export const useFlow = () => {
}
}, [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
useEffect(() => {
if (

View File

@@ -1,21 +1,22 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/molecules/Accordion/Accordion";
import { 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 { ContentRenderer } from "./components/ContentRenderer";
import { useNodeOutput } from "./useNodeOutput";
import { ViewMoreData } from "./components/ViewMoreData";
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
const { outputData, copiedKey, handleCopy, executionResultId, inputData } =
useNodeOutput(nodeId);
const {
outputData,
isExpanded,
setIsExpanded,
copiedKey,
handleCopy,
executionResultId,
inputData,
} = useNodeOutput(nodeId);
if (Object.keys(outputData).length === 0) {
return null;
@@ -24,117 +25,122 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
return (
<div
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">
<AccordionItem value="node-output" className="border-none">
<AccordionTrigger className="py-2 hover:no-underline">
<Text
variant="body-medium"
className="!font-semibold text-slate-700"
>
Node Output
</Text>
</AccordionTrigger>
<AccordionContent className="pt-2">
<div className="flex max-w-[350px] flex-col gap-4">
<div className="space-y-2">
<Text variant="small-medium">Input</Text>
<div className="flex items-center justify-between">
<Text variant="body-medium" className="!font-semibold text-slate-700">
Node Output
</Text>
<Button
variant="ghost"
size="small"
onClick={() => setIsExpanded(!isExpanded)}
className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
>
<CaretDownIcon
size={16}
weight="bold"
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">
<NodeDataViewer
data={inputData}
pinName="Input"
execId={executionResultId}
/>
<Button
variant="secondary"
size="small"
onClick={() => handleCopy("input", inputData)}
className={cn(
"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" />
) : (
<CopyIcon size={12} />
)}
</Button>
</div>
<ContentRenderer value={inputData} shortContent={false} />
<div className="mt-1 flex justify-end gap-1">
<NodeDataViewer
data={inputData}
pinName="Input"
execId={executionResultId}
/>
<Button
variant="secondary"
size="small"
onClick={() => handleCopy("input", inputData)}
className={cn(
"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" />
) : (
<CopyIcon size={12} />
)}
</Button>
</div>
</div>
{Object.entries(outputData)
.slice(0, 2)
.map(([key, value]) => (
<div key={key} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Text
variant="small-medium"
className="!font-semibold text-slate-600"
>
Pin:
</Text>
<Text variant="small" className="text-slate-700">
{beautifyString(key)}
</Text>
</div>
<div className="w-full space-y-2">
<Text
variant="small"
className="!font-semibold text-slate-600"
>
Data:
</Text>
<div className="relative space-y-2">
{value.map((item, index) => (
<div key={index}>
<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>
{Object.entries(outputData)
.slice(0, 2)
.map(([key, value]) => (
<div key={key} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Text
variant="small-medium"
className="!font-semibold text-slate-600"
>
Pin:
</Text>
<Text variant="small" className="text-slate-700">
{beautifyString(key)}
</Text>
</div>
<div className="w-full space-y-2">
<Text
variant="small"
className="!font-semibold text-slate-600"
>
Data:
</Text>
<div className="relative space-y-2">
{value.map((item, index) => (
<div key={index}>
<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>
))}
</div>
</div>
))}
</div>
{Object.keys(outputData).length > 2 && (
<ViewMoreData
outputData={outputData}
execId={executionResultId}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
{Object.keys(outputData).length > 2 && (
<ViewMoreData outputData={outputData} execId={executionResultId} />
)}
</>
)}
</div>
);
};

View File

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

View File

@@ -61,18 +61,12 @@ export const convertNodesPlusBlockInfoIntoCustomNodes = (
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 => ({
id: link.id ?? "",
type: "custom" as const,
source: link.source_id,
target: link.sink_id,
sourceHandle: cleanupSourceName(link.source_name),
sourceHandle: link.source_name,
targetHandle: link.sink_name,
data: {
isStatic: link.is_static,

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ import {
CarouselContent,
CarouselItem,
} from "@/components/__legacy__/ui/carousel";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
import { useAgentsSection } from "./useAgentsSection";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import { StoreCard } from "../StoreCard/StoreCard";
@@ -43,14 +41,12 @@ export const AgentsSection = ({
return (
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
<FadeIn direction="left" duration={0.5}>
<h2
style={{ marginBottom: margin }}
className="font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200"
>
{sectionTitle}
</h2>
</FadeIn>
<h2
style={{ marginBottom: margin }}
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
>
{sectionTitle}
</h2>
{!displayedAgents || displayedAgents.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400">
No agents found
@@ -58,38 +54,32 @@ export const AgentsSection = ({
) : (
<>
{/* Mobile Carousel View */}
<FadeIn direction="up" className="md:hidden">
<Carousel
opts={{
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"
<Carousel
className="md:hidden"
opts={{
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>
<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) => (
<StoreCard
key={index}
@@ -104,7 +94,7 @@ export const AgentsSection = ({
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
))}
</StaggeredList>
</div>
</>
)}
</div>

View File

@@ -38,7 +38,7 @@ export function BecomeACreator({
<PublishAgentModal
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">
{buttonText}
</span>

View File

@@ -20,18 +20,9 @@ export const CreatorCard = ({
}: CreatorCardProps) => {
return (
<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}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
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="absolute inset-0 overflow-hidden rounded-full">

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { FilterChip } from "@/components/atoms/FilterChip/FilterChip";
import { Badge } from "@/components/__legacy__/ui/badge";
import { useFilterChips } from "./useFilterChips";
interface FilterChipsProps {
@@ -9,6 +9,8 @@ interface FilterChipsProps {
multiSelect?: boolean;
}
// Some flaws in its logic
// FRONTEND-TODO : This needs to be fixed
export const FilterChips = ({
badges,
onFilterChange,
@@ -20,20 +22,18 @@ export const FilterChips = ({
});
return (
<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"
role="group"
aria-label="Filter options"
>
<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">
{badges.map((badge) => (
<FilterChip
<Badge
key={badge}
label={badge}
selected={selectedFilters.includes(badge)}
variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
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)}
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>
);

View File

@@ -1,6 +1,5 @@
"use client";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { FilterChips } from "../FilterChips/FilterChips";
import { SearchBar } from "../SearchBar/SearchBar";
import { useHeroSection } from "./useHeroSection";
@@ -10,36 +9,30 @@ export const HeroSection = () => {
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="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">
<h1 className="text-center">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
Explore AI agents built for{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
you
</span>
<br />
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
by the{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
community
</span>
</h1>
</div>
</FadeIn>
<FadeIn direction="up" duration={0.6} delay={0.15}>
<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">
Bringing you AI agents designed by thinkers from around the world
</h3>
</FadeIn>
<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="mb-4 text-center md:mb-8">
<h1 className="text-center">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
Explore AI agents built for{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
you
</span>
<br />
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
by the{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
community
</span>
</h1>
</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">
Bringing you AI agents designed by thinkers from around the world
</h3>
<div className="mb-4 flex justify-center sm:mb-5">
<SearchBar height="h-[74px]" />
</div>
<div>
<div className="flex justify-center">
<FilterChips
badges={searchTerms}
@@ -47,7 +40,7 @@ export const HeroSection = () => {
multiSelect={false}
/>
</div>
</FadeIn>
</div>
</div>
</div>
);

View File

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

View File

@@ -16,9 +16,9 @@ interface SearchBarProps {
export const SearchBar = ({
placeholder = 'Search for tasks like "optimise SEO"',
backgroundColor = "bg-neutral-100 dark:bg-neutral-800",
iconColor = "text-neutral-500 dark:text-neutral-400",
textColor = "text-neutral-500 dark:text-neutral-200",
placeholderColor = "text-neutral-500 dark:text-neutral-400",
iconColor = "text-[#646464] dark:text-neutral-400",
textColor = "text-[#707070] dark:text-neutral-200",
placeholderColor = "text-[#707070] dark:text-neutral-400",
width = "w-9/10 lg:w-[56.25rem]",
height = "h-[60px]",
}: SearchBarProps) => {
@@ -32,13 +32,10 @@ export const SearchBar = ({
>
<MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} />
<input
type="search"
name="search"
autoComplete="off"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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`}
data-testid="store-search-input"
/>

View File

@@ -1,25 +1,10 @@
import Image from "next/image";
import { Star } from "@phosphor-icons/react";
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
import Avatar, {
AvatarFallback,
AvatarImage,
} 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 {
agentName: string;
agentImage: string;
@@ -49,7 +34,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
return (
<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}
data-testid="store-card"
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">
{/* Second Section: Agent Name and Creator Name */}
<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}
</h3>
{!hideAvatar && creatorName && (
@@ -122,11 +107,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
{rating.toFixed(1)}
</span>
<div
className="inline-flex items-center gap-0.5"
className="inline-flex items-center"
role="img"
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
>
<StarRating rating={rating} />
{StarRatingIcons(rating)}
</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>
);
}