mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-21 04:57:58 -05:00
Compare commits
3 Commits
dependabot
...
testing-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919cc877ad | ||
|
|
7756e2d12d | ||
|
|
bc75d70e7d |
2
.github/workflows/classic-frontend-ci.yml
vendored
2
.github/workflows/classic-frontend-ci.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create PR ${{ env.BUILD_BRANCH }} -> ${{ github.ref_name }}
|
- name: Create PR ${{ env.BUILD_BRANCH }} -> ${{ github.ref_name }}
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: peter-evans/create-pull-request@v8
|
uses: peter-evans/create-pull-request@v7
|
||||||
with:
|
with:
|
||||||
add-paths: classic/frontend/build/web
|
add-paths: classic/frontend/build/web
|
||||||
base: ${{ github.ref_name }}
|
base: ${{ github.ref_name }}
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ from collections.abc import AsyncGenerator
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from langfuse import Langfuse
|
from langfuse import get_client, propagate_attributes
|
||||||
from openai import (
|
from langfuse.openai import openai # type: ignore
|
||||||
APIConnectionError,
|
from openai import APIConnectionError, APIError, APIStatusError, RateLimitError
|
||||||
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 (
|
||||||
@@ -21,7 +16,6 @@ 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,
|
||||||
@@ -50,10 +44,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
config = ChatConfig()
|
config = ChatConfig()
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
client = AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
client = openai.AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
||||||
|
|
||||||
# Langfuse client (lazy initialization)
|
|
||||||
_langfuse_client: Langfuse | None = None
|
langfuse = get_client()
|
||||||
|
|
||||||
|
|
||||||
class LangfuseNotConfiguredError(Exception):
|
class LangfuseNotConfiguredError(Exception):
|
||||||
@@ -69,65 +63,6 @@ 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.
|
||||||
|
|
||||||
@@ -139,8 +74,6 @@ 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)
|
||||||
|
|
||||||
@@ -158,7 +91,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, prompt
|
return compiled, understanding
|
||||||
|
|
||||||
|
|
||||||
async def _generate_session_title(message: str) -> str | None:
|
async def _generate_session_title(message: str) -> str | None:
|
||||||
@@ -217,6 +150,7 @@ 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,
|
||||||
@@ -256,11 +190,6 @@ 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)
|
||||||
@@ -336,297 +265,259 @@ 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, langfuse_prompt = await _build_system_prompt(user_id)
|
system_prompt, understanding = 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
|
||||||
try:
|
input = message
|
||||||
langfuse = _get_langfuse_client()
|
if not message and tool_call_response:
|
||||||
env = _get_environment()
|
input = tool_call_response
|
||||||
trace = langfuse.start_observation(
|
|
||||||
name="chat_completion",
|
langfuse = get_client()
|
||||||
input={"messages": trace_input_messages},
|
with langfuse.start_as_current_observation(
|
||||||
metadata={
|
as_type="span",
|
||||||
"environment": env,
|
name="user-copilot-request",
|
||||||
"model": config.model,
|
input=input,
|
||||||
"message_count": len(session.messages),
|
) as span:
|
||||||
"prompt_name": langfuse_prompt.name if langfuse_prompt else None,
|
with propagate_attributes(
|
||||||
"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=[env, "copilot"],
|
tags=["copilot"],
|
||||||
)
|
metadata={
|
||||||
except Exception as e:
|
"users_information": format_understanding_for_prompt(understanding)[
|
||||||
logger.warning(f"Failed to create Langfuse trace: {e}")
|
:200
|
||||||
|
] # 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,
|
|
||||||
)
|
)
|
||||||
if trace
|
accumulated_tool_calls: list[dict[str, Any]] = []
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
||||||
async for chunk in _stream_chat_chunks(
|
has_yielded_end = False
|
||||||
session=session,
|
has_yielded_error = False
|
||||||
tools=tools,
|
has_done_tool_call = False
|
||||||
system_prompt=system_prompt,
|
has_received_text = False
|
||||||
text_block_id=text_block_id,
|
text_streaming_ended = False
|
||||||
):
|
tool_response_messages: list[ChatMessage] = []
|
||||||
|
should_retry = False
|
||||||
|
|
||||||
if isinstance(chunk, StreamTextStart):
|
# Generate unique IDs for AI SDK protocol
|
||||||
# Emit text-start before first text delta
|
import uuid as uuid_module
|
||||||
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, StreamTextDelta):
|
elif isinstance(chunk, StreamTextEnd):
|
||||||
delta = chunk.delta or ""
|
# Emit text-end after text completes
|
||||||
assert assistant_response.content is not None
|
if has_received_text and not text_streaming_ended:
|
||||||
assistant_response.content += delta
|
text_streaming_ended = True
|
||||||
has_received_text = True
|
if assistant_response.content:
|
||||||
yield chunk
|
logger.warn(
|
||||||
elif isinstance(chunk, StreamTextEnd):
|
f"StreamTextEnd: Attempting to set output {assistant_response.content}"
|
||||||
# Emit text-end after text completes
|
)
|
||||||
if has_received_text and not text_streaming_ended:
|
span.update_trace(output=assistant_response.content)
|
||||||
text_streaming_ended = True
|
span.update(output=assistant_response.content)
|
||||||
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, StreamError):
|
elif isinstance(chunk, StreamToolInputAvailable):
|
||||||
has_yielded_error = True
|
# Accumulate tool calls in OpenAI format
|
||||||
elif isinstance(chunk, StreamUsage):
|
accumulated_tool_calls.append(
|
||||||
session.usage.append(
|
{
|
||||||
Usage(
|
"id": chunk.toolCallId,
|
||||||
prompt_tokens=chunk.promptTokens,
|
"type": "function",
|
||||||
completion_tokens=chunk.completionTokens,
|
"function": {
|
||||||
total_tokens=chunk.totalTokens,
|
"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, 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:
|
||||||
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
|
# Non-retryable error or max retries exceeded
|
||||||
except Exception as e:
|
# Save any partial progress before reporting error
|
||||||
logger.error(f"Error during stream: {e!s}", exc_info=True)
|
messages_to_save: list[ChatMessage] = []
|
||||||
|
|
||||||
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
|
# Add assistant message if it has content or tool calls
|
||||||
is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError))
|
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)
|
||||||
|
|
||||||
if is_retryable and retry_count < config.max_retries:
|
# 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"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
|
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
|
||||||
)
|
)
|
||||||
should_retry = True
|
async for chunk in stream_chat_completion(
|
||||||
else:
|
session_id=session.session_id,
|
||||||
# Non-retryable error or max retries exceeded
|
user_id=user_id,
|
||||||
# Save any partial progress before reporting error
|
retry_count=retry_count + 1,
|
||||||
messages_to_save: list[ChatMessage] = []
|
session=session,
|
||||||
|
context=context,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
return # Exit after retry to avoid double-saving in finally block
|
||||||
|
|
||||||
# Add assistant message if it has content or tool calls
|
# Normal completion path - save session and handle tool call continuation
|
||||||
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"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
|
f"Normal completion path: session={session.session_id}, "
|
||||||
)
|
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 [])}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add tool response messages after assistant message
|
# Build the messages list in the correct order
|
||||||
messages_to_save.extend(tool_response_messages)
|
messages_to_save: list[ChatMessage] = []
|
||||||
logger.info(
|
|
||||||
f"Saving {len(tool_response_messages)} tool response messages, "
|
|
||||||
f"total_to_save={len(messages_to_save)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.messages.extend(messages_to_save)
|
# Add assistant message with tool_calls if any
|
||||||
logger.info(
|
if accumulated_tool_calls:
|
||||||
f"Extended session messages, new message_count={len(session.messages)}"
|
assistant_response.tool_calls = accumulated_tool_calls
|
||||||
)
|
logger.info(
|
||||||
await upsert_chat_session(session)
|
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
||||||
|
)
|
||||||
# If we did a tool call, stream the chat completion again to get the next response
|
if assistant_response.content or assistant_response.tool_calls:
|
||||||
if has_done_tool_call:
|
messages_to_save.append(assistant_response)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Tool call executed, streaming chat completion again to get assistant response"
|
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
generation.end()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to end Langfuse generation: {e}")
|
|
||||||
|
|
||||||
if trace is not None:
|
# Add tool response messages after assistant message
|
||||||
try:
|
messages_to_save.extend(tool_response_messages)
|
||||||
if accumulated_tool_calls:
|
logger.info(
|
||||||
trace.update_trace(output={"tool_calls": accumulated_tool_calls})
|
f"Saving {len(tool_response_messages)} tool response messages, "
|
||||||
else:
|
f"total_to_save={len(messages_to_save)}"
|
||||||
trace.update_trace(output={"response": assistant_response.content})
|
)
|
||||||
trace.end()
|
|
||||||
except Exception as e:
|
session.messages.extend(messages_to_save)
|
||||||
logger.warning(f"Failed to end Langfuse trace: {e}")
|
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"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# Retry configuration for OpenAI API calls
|
# Retry configuration for OpenAI API calls
|
||||||
@@ -900,5 +791,4 @@ 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
|
||||||
|
|||||||
@@ -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(),
|
||||||
"agent_output": AgentOutputTool(),
|
"view_agent_output": AgentOutputTool(),
|
||||||
"search_docs": SearchDocsTool(),
|
"search_docs": SearchDocsTool(),
|
||||||
"get_doc_page": GetDocPageTool(),
|
"get_doc_page": GetDocPageTool(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
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,
|
||||||
@@ -59,6 +61,7 @@ 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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -103,7 +104,7 @@ class AgentOutputTool(BaseTool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "agent_output"
|
return "view_agent_output"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
@@ -328,6 +329,7 @@ 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,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
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 (
|
||||||
@@ -78,6 +80,7 @@ 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,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
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 (
|
||||||
@@ -85,6 +87,7 @@ 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,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -35,6 +37,7 @@ 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:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -55,6 +56,7 @@ 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,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -41,6 +43,7 @@ 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:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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 (
|
||||||
@@ -71,6 +73,7 @@ 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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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
|
||||||
@@ -154,6 +155,7 @@ 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,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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
|
||||||
@@ -127,6 +129,7 @@ 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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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
|
||||||
@@ -87,6 +88,7 @@ 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,
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ 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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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,
|
||||||
@@ -23,19 +24,17 @@ 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 {
|
||||||
credentialsUiSchema,
|
credentialFields,
|
||||||
|
requiredCredentials,
|
||||||
handleManualRun,
|
handleManualRun,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
openCronSchedulerDialog,
|
openCronSchedulerDialog,
|
||||||
setOpenCronSchedulerDialog,
|
setOpenCronSchedulerDialog,
|
||||||
inputValues,
|
inputValues,
|
||||||
credentialValues,
|
credentialValues,
|
||||||
handleCredentialChange,
|
handleCredentialFieldChange,
|
||||||
isExecutingGraph,
|
isExecutingGraph,
|
||||||
} = useRunInputDialog({ setIsOpen });
|
} = useRunInputDialog({ setIsOpen });
|
||||||
|
|
||||||
@@ -67,7 +66,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() && (
|
{hasCredentials() && credentialFields.length > 0 && (
|
||||||
<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">
|
||||||
@@ -75,16 +74,12 @@ 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">
|
||||||
<FormRenderer
|
<CredentialsGroupedView
|
||||||
jsonSchema={credentialsSchema as RJSFSchema}
|
credentialFields={credentialFields}
|
||||||
handleChange={(v) => handleCredentialChange(v.formData)}
|
requiredCredentials={requiredCredentials}
|
||||||
uiSchema={credentialsUiSchema}
|
inputCredentials={credentialValues}
|
||||||
initialValues={{}}
|
inputValues={inputValues}
|
||||||
formContext={{
|
onCredentialChange={handleCredentialFieldChange}
|
||||||
showHandles: false,
|
|
||||||
size: "large",
|
|
||||||
showOptionalToggle: false,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ 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 { useMemo, useState } from "react";
|
import { useCallback, 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,
|
||||||
@@ -120,27 +119,32 @@ export const useRunInputDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// We are rendering the credentials field differently compared to other fields.
|
// Convert credentials schema to credential fields array for CredentialsGroupedView
|
||||||
// In the node, we have the field name as "credential" - so our library catches it and renders it differently.
|
const credentialFields: CredentialField[] = useMemo(() => {
|
||||||
// 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.
|
if (!credentialsSchema?.properties) return [];
|
||||||
|
return Object.entries(credentialsSchema.properties);
|
||||||
|
}, [credentialsSchema]);
|
||||||
|
|
||||||
const credentialsUiSchema = useMemo(() => {
|
// Get required credentials as a Set
|
||||||
const dynamicUiSchema: any = { ...uiSchema };
|
const requiredCredentials = useMemo(() => {
|
||||||
|
return new Set<string>(credentialsSchema?.required || []);
|
||||||
|
}, [credentialsSchema]);
|
||||||
|
|
||||||
if (credentialsSchema?.properties) {
|
// Handler for individual credential changes
|
||||||
Object.keys(credentialsSchema.properties).forEach((fieldName) => {
|
const handleCredentialFieldChange = useCallback(
|
||||||
const fieldSchema = credentialsSchema.properties[fieldName];
|
(key: string, value?: CredentialsMetaInput) => {
|
||||||
if (isCredentialFieldSchema(fieldSchema)) {
|
setCredentialValues((prev) => {
|
||||||
dynamicUiSchema[fieldName] = {
|
if (value) {
|
||||||
...dynamicUiSchema[fieldName],
|
return { ...prev, [key]: value };
|
||||||
"ui:field": "custom/credential_field",
|
} else {
|
||||||
};
|
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)
|
||||||
@@ -173,12 +177,14 @@ export const useRunInputDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialsUiSchema,
|
credentialFields,
|
||||||
|
requiredCredentials,
|
||||||
inputValues,
|
inputValues,
|
||||||
credentialValues,
|
credentialValues,
|
||||||
isExecutingGraph,
|
isExecutingGraph,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleCredentialChange,
|
handleCredentialChange,
|
||||||
|
handleCredentialFieldChange,
|
||||||
handleManualRun,
|
handleManualRun,
|
||||||
openCronSchedulerDialog,
|
openCronSchedulerDialog,
|
||||||
setOpenCronSchedulerDialog,
|
setOpenCronSchedulerDialog,
|
||||||
|
|||||||
@@ -139,14 +139,6 @@ 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 key–value 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]);
|
||||||
|
|
||||||
@@ -158,6 +150,14 @@ 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 (
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
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 { CaretDownIcon, CopyIcon, CheckIcon } from "@phosphor-icons/react";
|
import { 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 {
|
const { outputData, copiedKey, handleCopy, executionResultId, inputData } =
|
||||||
outputData,
|
useNodeOutput(nodeId);
|
||||||
isExpanded,
|
|
||||||
setIsExpanded,
|
|
||||||
copiedKey,
|
|
||||||
handleCopy,
|
|
||||||
executionResultId,
|
|
||||||
inputData,
|
|
||||||
} = useNodeOutput(nodeId);
|
|
||||||
|
|
||||||
if (Object.keys(outputData).length === 0) {
|
if (Object.keys(outputData).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -25,122 +24,117 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tutorial-id={`node-output`}
|
data-tutorial-id={`node-output`}
|
||||||
className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4"
|
className="rounded-b-xl border-t border-zinc-200 px-4 py-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<Accordion type="single" collapsible defaultValue="node-output">
|
||||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
<AccordionItem value="node-output" className="border-none">
|
||||||
Node Output
|
<AccordionTrigger className="py-2 hover:no-underline">
|
||||||
</Text>
|
<Text
|
||||||
<Button
|
variant="body-medium"
|
||||||
variant="ghost"
|
className="!font-semibold text-slate-700"
|
||||||
size="small"
|
>
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
Node Output
|
||||||
className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
|
</Text>
|
||||||
>
|
</AccordionTrigger>
|
||||||
<CaretDownIcon
|
<AccordionContent className="pt-2">
|
||||||
size={16}
|
<div className="flex max-w-[350px] flex-col gap-4">
|
||||||
weight="bold"
|
<div className="space-y-2">
|
||||||
className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
<Text variant="small-medium">Input</Text>
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
<ContentRenderer value={inputData} shortContent={false} />
|
||||||
<>
|
|
||||||
<div className="flex max-w-[350px] flex-col gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Text variant="small-medium">Input</Text>
|
|
||||||
|
|
||||||
<ContentRenderer value={inputData} shortContent={false} />
|
<div className="mt-1 flex justify-end gap-1">
|
||||||
|
<NodeDataViewer
|
||||||
<div className="mt-1 flex justify-end gap-1">
|
data={inputData}
|
||||||
<NodeDataViewer
|
pinName="Input"
|
||||||
data={inputData}
|
execId={executionResultId}
|
||||||
pinName="Input"
|
/>
|
||||||
execId={executionResultId}
|
<Button
|
||||||
/>
|
variant="secondary"
|
||||||
<Button
|
size="small"
|
||||||
variant="secondary"
|
onClick={() => handleCopy("input", inputData)}
|
||||||
size="small"
|
className={cn(
|
||||||
onClick={() => handleCopy("input", inputData)}
|
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||||
className={cn(
|
copiedKey === "input" &&
|
||||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||||
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" ? (
|
) : (
|
||||||
<CheckIcon size={12} className="text-green-600" />
|
<CopyIcon size={12} />
|
||||||
) : (
|
)}
|
||||||
<CopyIcon size={12} />
|
</Button>
|
||||||
)}
|
</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 outputData={outputData} execId={executionResultId} />
|
<ViewMoreData
|
||||||
)}
|
outputData={outputData}
|
||||||
</>
|
execId={executionResultId}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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();
|
||||||
|
|
||||||
@@ -37,13 +36,10 @@ export const useNodeOutput = (nodeId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
outputData: outputData,
|
outputData,
|
||||||
inputData: inputData,
|
inputData,
|
||||||
isExpanded: isExpanded,
|
copiedKey,
|
||||||
setIsExpanded: setIsExpanded,
|
handleCopy,
|
||||||
copiedKey: copiedKey,
|
|
||||||
setCopiedKey: setCopiedKey,
|
|
||||||
handleCopy: handleCopy,
|
|
||||||
executionResultId: nodeExecutionResult?.node_exec_id,
|
executionResultId: nodeExecutionResult?.node_exec_id,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,12 +61,18 @@ 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: link.source_name,
|
sourceHandle: cleanupSourceName(link.source_name),
|
||||||
targetHandle: link.sink_name,
|
targetHandle: link.sink_name,
|
||||||
data: {
|
data: {
|
||||||
isStatic: link.is_static,
|
isStatic: link.is_static,
|
||||||
|
|||||||
@@ -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,6 +19,8 @@ export function ModalRunSection() {
|
|||||||
setInputValue,
|
setInputValue,
|
||||||
agentInputFields,
|
agentInputFields,
|
||||||
agentCredentialsInputFields,
|
agentCredentialsInputFields,
|
||||||
|
inputCredentials,
|
||||||
|
setInputCredentialsValue,
|
||||||
} = useRunAgentModalContext();
|
} = useRunAgentModalContext();
|
||||||
|
|
||||||
const inputFields = Object.entries(agentInputFields || {});
|
const inputFields = Object.entries(agentInputFields || {});
|
||||||
@@ -102,6 +104,9 @@ export function ModalRunSection() {
|
|||||||
<CredentialsGroupedView
|
<CredentialsGroupedView
|
||||||
credentialFields={credentialFields}
|
credentialFields={credentialFields}
|
||||||
requiredCredentials={requiredCredentials}
|
requiredCredentials={requiredCredentials}
|
||||||
|
inputCredentials={inputCredentials}
|
||||||
|
inputValues={inputValues}
|
||||||
|
onCredentialChange={setInputCredentialsValue}
|
||||||
/>
|
/>
|
||||||
</ModalSection>
|
</ModalSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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";
|
||||||
@@ -41,12 +43,14 @@ 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]">
|
||||||
<h2
|
<FadeIn direction="left" duration={0.5}>
|
||||||
style={{ marginBottom: margin }}
|
<h2
|
||||||
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
|
style={{ marginBottom: margin }}
|
||||||
>
|
className="font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200"
|
||||||
{sectionTitle}
|
>
|
||||||
</h2>
|
{sectionTitle}
|
||||||
|
</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
|
||||||
@@ -54,32 +58,38 @@ export const AgentsSection = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Carousel View */}
|
{/* Mobile Carousel View */}
|
||||||
<Carousel
|
<FadeIn direction="up" className="md:hidden">
|
||||||
className="md:hidden"
|
<Carousel
|
||||||
opts={{
|
opts={{
|
||||||
loop: true,
|
loop: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CarouselContent>
|
<CarouselContent>
|
||||||
{displayedAgents.map((agent, index) => (
|
{displayedAgents.map((agent, index) => (
|
||||||
<CarouselItem key={index} className="min-w-64 max-w-71">
|
<CarouselItem key={index} className="min-w-64 max-w-71">
|
||||||
<StoreCard
|
<StoreCard
|
||||||
agentName={agent.agent_name}
|
agentName={agent.agent_name}
|
||||||
agentImage={agent.agent_image}
|
agentImage={agent.agent_image}
|
||||||
description={agent.description}
|
description={agent.description}
|
||||||
runs={agent.runs}
|
runs={agent.runs}
|
||||||
rating={agent.rating}
|
rating={agent.rating}
|
||||||
avatarSrc={agent.creator_avatar}
|
avatarSrc={agent.creator_avatar}
|
||||||
creatorName={agent.creator}
|
creatorName={agent.creator}
|
||||||
hideAvatar={hideAvatars}
|
hideAvatar={hideAvatars}
|
||||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||||
/>
|
/>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
))}
|
))}
|
||||||
</CarouselContent>
|
</CarouselContent>
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
</FadeIn>
|
||||||
|
|
||||||
<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">
|
{/* 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"
|
||||||
|
>
|
||||||
{displayedAgents.map((agent, index) => (
|
{displayedAgents.map((agent, index) => (
|
||||||
<StoreCard
|
<StoreCard
|
||||||
key={index}
|
key={index}
|
||||||
@@ -94,7 +104,7 @@ export const AgentsSection = ({
|
|||||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</StaggeredList>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 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">
|
<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">
|
||||||
<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>
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ 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-all duration-200 hover:brightness-95`}
|
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`}
|
||||||
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">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"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";
|
||||||
@@ -19,11 +21,17 @@ 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]">
|
||||||
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
<FadeIn direction="left" duration={0.5}>
|
||||||
{title}
|
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||||
</h2>
|
{title}
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<StaggeredList
|
||||||
|
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}
|
||||||
@@ -35,7 +43,7 @@ export const FeaturedCreators = ({
|
|||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</StaggeredList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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";
|
||||||
@@ -25,40 +26,44 @@ export const FeaturedSection = ({ featuredAgents }: FeaturedSectionProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full">
|
<section className="w-full">
|
||||||
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
<FadeIn direction="left" duration={0.5}>
|
||||||
Featured agents
|
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||||
</h2>
|
Featured agents
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
|
||||||
<Carousel
|
<FadeIn direction="up" duration={0.6} delay={0.1}>
|
||||||
opts={{
|
<Carousel
|
||||||
align: "center",
|
opts={{
|
||||||
containScroll: "trimSnaps",
|
align: "center",
|
||||||
}}
|
containScroll: "trimSnaps",
|
||||||
>
|
}}
|
||||||
<CarouselContent>
|
>
|
||||||
{featuredAgents.map((agent, index) => (
|
<CarouselContent>
|
||||||
<CarouselItem
|
{featuredAgents.map((agent, index) => (
|
||||||
key={index}
|
<CarouselItem
|
||||||
className="h-[480px] md:basis-1/2 lg:basis-1/3"
|
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"
|
|
||||||
>
|
>
|
||||||
<FeaturedAgentCard
|
<Link
|
||||||
agent={agent}
|
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
|
||||||
backgroundColor={getBackgroundColor(index)}
|
className="block h-full"
|
||||||
/>
|
>
|
||||||
</Link>
|
<FeaturedAgentCard
|
||||||
</CarouselItem>
|
agent={agent}
|
||||||
))}
|
backgroundColor={getBackgroundColor(index)}
|
||||||
</CarouselContent>
|
/>
|
||||||
<div className="relative mt-4">
|
</Link>
|
||||||
<CarouselIndicator />
|
</CarouselItem>
|
||||||
<CarouselPrevious afterClick={handlePrevSlide} />
|
))}
|
||||||
<CarouselNext afterClick={handleNextSlide} />
|
</CarouselContent>
|
||||||
</div>
|
<div className="relative mt-4">
|
||||||
</Carousel>
|
<CarouselIndicator />
|
||||||
|
<CarouselPrevious afterClick={handlePrevSlide} />
|
||||||
|
<CarouselNext afterClick={handleNextSlide} />
|
||||||
|
</div>
|
||||||
|
</Carousel>
|
||||||
|
</FadeIn>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
import { FilterChip } from "@/components/atoms/FilterChip/FilterChip";
|
||||||
import { useFilterChips } from "./useFilterChips";
|
import { useFilterChips } from "./useFilterChips";
|
||||||
|
|
||||||
interface FilterChipsProps {
|
interface FilterChipsProps {
|
||||||
@@ -9,8 +9,6 @@ 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,
|
||||||
@@ -22,18 +20,20 @@ export const FilterChips = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<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"
|
||||||
|
>
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<Badge
|
<FilterChip
|
||||||
key={badge}
|
key={badge}
|
||||||
variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
|
label={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"
|
selected={selectedFilters.includes(badge)}
|
||||||
onClick={() => handleBadgeClick(badge)}
|
onClick={() => handleBadgeClick(badge)}
|
||||||
>
|
size="lg"
|
||||||
<div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
|
className="mb-2 lg:mb-3"
|
||||||
{badge}
|
/>
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"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";
|
||||||
@@ -9,30 +10,36 @@ 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">
|
||||||
<div className="mb-4 text-center md:mb-8">
|
<FadeIn direction="down" duration={0.6} delay={0}>
|
||||||
<h1 className="text-center">
|
<div className="mb-4 text-center md:mb-8">
|
||||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
<h1 className="text-center">
|
||||||
Explore AI agents built for{" "}
|
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||||
</span>
|
Explore AI agents built for{" "}
|
||||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
|
</span>
|
||||||
you
|
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
|
||||||
</span>
|
you
|
||||||
<br />
|
</span>
|
||||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
<br />
|
||||||
by the{" "}
|
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||||
</span>
|
by the{" "}
|
||||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
|
</span>
|
||||||
community
|
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
|
||||||
</span>
|
community
|
||||||
</h1>
|
</span>
|
||||||
</div>
|
</h1>
|
||||||
<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>
|
||||||
Bringing you AI agents designed by thinkers from around the world
|
</FadeIn>
|
||||||
</h3>
|
<FadeIn direction="up" duration={0.6} delay={0.15}>
|
||||||
<div className="mb-4 flex justify-center sm:mb-5">
|
<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">
|
||||||
<SearchBar height="h-[74px]" />
|
Bringing you AI agents designed by thinkers from around the world
|
||||||
</div>
|
</h3>
|
||||||
<div>
|
</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="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<FilterChips
|
<FilterChips
|
||||||
badges={searchTerms}
|
badges={searchTerms}
|
||||||
@@ -40,7 +47,7 @@ export const HeroSection = () => {
|
|||||||
multiSelect={false}
|
multiSelect={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FadeIn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
import { Separator } from "@/components/atoms/Separator/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";
|
||||||
@@ -54,11 +55,13 @@ export const MainMarkeplacePage = () => {
|
|||||||
<FeaturedCreators featuredCreators={featuredCreators.creators} />
|
<FeaturedCreators featuredCreators={featuredCreators.creators} />
|
||||||
)}
|
)}
|
||||||
<Separator className="mb-[25px] mt-[60px]" />
|
<Separator className="mb-[25px] mt-[60px]" />
|
||||||
<BecomeACreator
|
<FadeIn direction="up" duration={0.6}>
|
||||||
title="Become a Creator"
|
<BecomeACreator
|
||||||
description="Join our ever-growing community of hackers and tinkerers"
|
title="Become a Creator"
|
||||||
buttonText="Become a Creator"
|
description="Join our ever-growing community of hackers and tinkerers"
|
||||||
/>
|
buttonText="Become a Creator"
|
||||||
|
/>
|
||||||
|
</FadeIn>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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-[#646464] dark:text-neutral-400",
|
iconColor = "text-neutral-500 dark:text-neutral-400",
|
||||||
textColor = "text-[#707070] dark:text-neutral-200",
|
textColor = "text-neutral-500 dark:text-neutral-200",
|
||||||
placeholderColor = "text-[#707070] dark:text-neutral-400",
|
placeholderColor = "text-neutral-500 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,10 +32,13 @@ 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="text"
|
type="search"
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
|
import { Star } from "@phosphor-icons/react";
|
||||||
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;
|
||||||
@@ -34,7 +49,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-all duration-300 hover:shadow-lg dark:hover:shadow-gray-700"
|
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"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
data-testid="store-card"
|
data-testid="store-card"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -76,7 +91,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-[#272727] dark:text-neutral-100">
|
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||||
{agentName}
|
{agentName}
|
||||||
</h3>
|
</h3>
|
||||||
{!hideAvatar && creatorName && (
|
{!hideAvatar && creatorName && (
|
||||||
@@ -107,11 +122,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
|
|||||||
{rating.toFixed(1)}
|
{rating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="inline-flex items-center"
|
className="inline-flex items-center gap-0.5"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
|
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
|
||||||
>
|
>
|
||||||
{StarRatingIcons(rating)}
|
<StarRating rating={rating} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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 />,
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,30 +5,37 @@ 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 { SlidersHorizontal } from "@phosphor-icons/react";
|
import { SlidersHorizontalIcon } 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(
|
||||||
() =>
|
() =>
|
||||||
@@ -87,11 +94,11 @@ export function CredentialsGroupedView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (savedCredential) {
|
if (savedCredential) {
|
||||||
setInputCredentialsValue(key, {
|
onCredentialChange(key, {
|
||||||
id: savedCredential.id,
|
id: savedCredential.id,
|
||||||
provider: savedCredential.provider,
|
provider: savedCredential.provider,
|
||||||
type: savedCredential.type,
|
type: savedCredential.type as CredentialsType,
|
||||||
title: (savedCredential as { title?: string }).title,
|
title: savedCredential.title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +110,7 @@ export function CredentialsGroupedView({
|
|||||||
systemCredentialFields,
|
systemCredentialFields,
|
||||||
requiredCredentials,
|
requiredCredentials,
|
||||||
inputCredentials,
|
inputCredentials,
|
||||||
setInputCredentialsValue,
|
onCredentialChange,
|
||||||
isLoadingProviders,
|
isLoadingProviders,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -123,7 +130,7 @@ export function CredentialsGroupedView({
|
|||||||
}
|
}
|
||||||
selectedCredentials={selectedCred}
|
selectedCredentials={selectedCred}
|
||||||
onSelectCredentials={(value) => {
|
onSelectCredentials={(value) => {
|
||||||
setInputCredentialsValue(key, value);
|
onCredentialChange(key, value);
|
||||||
}}
|
}}
|
||||||
siblingInputs={inputValues}
|
siblingInputs={inputValues}
|
||||||
isOptional={!requiredCredentials.has(key)}
|
isOptional={!requiredCredentials.has(key)}
|
||||||
@@ -143,7 +150,8 @@ 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">
|
||||||
<SlidersHorizontal size={16} weight="bold" /> System credentials
|
<SlidersHorizontalIcon size={16} weight="bold" /> System
|
||||||
|
credentials
|
||||||
{hasMissingSystemCredentials && (
|
{hasMissingSystemCredentials && (
|
||||||
<span className="text-destructive">(missing)</span>
|
<span className="text-destructive">(missing)</span>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +171,7 @@ export function CredentialsGroupedView({
|
|||||||
}
|
}
|
||||||
selectedCredentials={selectedCred}
|
selectedCredentials={selectedCred}
|
||||||
onSelectCredentials={(value) => {
|
onSelectCredentials={(value) => {
|
||||||
setInputCredentialsValue(key, value);
|
onCredentialChange(key, value);
|
||||||
}}
|
}}
|
||||||
siblingInputs={inputValues}
|
siblingInputs={inputValues}
|
||||||
isOptional={!requiredCredentials.has(key)}
|
isOptional={!requiredCredentials.has(key)}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CredentialsProvidersContextType } from "@/providers/agent-credentials/credentials-provider";
|
import { CredentialsProvidersContextType } from "@/providers/agent-credentials/credentials-provider";
|
||||||
import { getSystemCredentials } from "../../../../../../../../../../../components/contextual/CredentialsInput/helpers";
|
import { getSystemCredentials } from "../../helpers";
|
||||||
|
|
||||||
export type CredentialField = [string, any];
|
export type CredentialField = [string, any];
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user