Compare commits
2 Commits
fix/reduce
...
testing-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa75c8da4 | ||
|
|
919cc877ad |
38
.github/workflows/platform-frontend-ci.yml
vendored
@@ -128,7 +128,7 @@ jobs:
|
|||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
exitOnceUploaded: true
|
exitOnceUploaded: true
|
||||||
|
|
||||||
e2e_test:
|
test:
|
||||||
runs-on: big-boi
|
runs-on: big-boi
|
||||||
needs: setup
|
needs: setup
|
||||||
strategy:
|
strategy:
|
||||||
@@ -258,39 +258,3 @@ jobs:
|
|||||||
- name: Print Final Docker Compose logs
|
- name: Print Final Docker Compose logs
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f ../docker-compose.yml logs
|
run: docker compose -f ../docker-compose.yml logs
|
||||||
|
|
||||||
integration_test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22.18.0"
|
|
||||||
|
|
||||||
- name: Enable corepack
|
|
||||||
run: corepack enable
|
|
||||||
|
|
||||||
- name: Restore dependencies cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pnpm-store
|
|
||||||
key: ${{ needs.setup.outputs.cache-key }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
|
||||||
${{ runner.os }}-pnpm-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Generate API client
|
|
||||||
run: pnpm generate:api
|
|
||||||
|
|
||||||
- name: Run Integration Tests
|
|
||||||
run: pnpm test:unit
|
|
||||||
|
|||||||
@@ -1552,7 +1552,7 @@ async def review_store_submission(
|
|||||||
|
|
||||||
# Generate embedding for approved listing (blocking - admin operation)
|
# Generate embedding for approved listing (blocking - admin operation)
|
||||||
# Inside transaction: if embedding fails, entire transaction rolls back
|
# Inside transaction: if embedding fails, entire transaction rolls back
|
||||||
await ensure_embedding(
|
embedding_success = await ensure_embedding(
|
||||||
version_id=store_listing_version_id,
|
version_id=store_listing_version_id,
|
||||||
name=store_listing_version.name,
|
name=store_listing_version.name,
|
||||||
description=store_listing_version.description,
|
description=store_listing_version.description,
|
||||||
@@ -1560,6 +1560,12 @@ async def review_store_submission(
|
|||||||
categories=store_listing_version.categories or [],
|
categories=store_listing_version.categories or [],
|
||||||
tx=tx,
|
tx=tx,
|
||||||
)
|
)
|
||||||
|
if not embedding_success:
|
||||||
|
raise ValueError(
|
||||||
|
f"Failed to generate embedding for listing {store_listing_version_id}. "
|
||||||
|
"This is likely due to OpenAI API being unavailable. "
|
||||||
|
"Please try again later or contact support if the issue persists."
|
||||||
|
)
|
||||||
|
|
||||||
await prisma.models.StoreListing.prisma(tx).update(
|
await prisma.models.StoreListing.prisma(tx).update(
|
||||||
where={"id": store_listing_version.StoreListing.id},
|
where={"id": store_listing_version.StoreListing.id},
|
||||||
|
|||||||
@@ -63,42 +63,49 @@ def build_searchable_text(
|
|||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
async def generate_embedding(text: str) -> list[float]:
|
async def generate_embedding(text: str) -> list[float] | None:
|
||||||
"""
|
"""
|
||||||
Generate embedding for text using OpenAI API.
|
Generate embedding for text using OpenAI API.
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
Returns None if embedding generation fails.
|
||||||
|
Fail-fast: no retries to maintain consistency with approval flow.
|
||||||
"""
|
"""
|
||||||
client = get_openai_client()
|
try:
|
||||||
if not client:
|
client = get_openai_client()
|
||||||
raise RuntimeError("openai_internal_api_key not set, cannot generate embedding")
|
if not client:
|
||||||
|
logger.error("openai_internal_api_key not set, cannot generate embedding")
|
||||||
|
return None
|
||||||
|
|
||||||
# Truncate text to token limit using tiktoken
|
# Truncate text to token limit using tiktoken
|
||||||
# Character-based truncation is insufficient because token ratios vary by content type
|
# Character-based truncation is insufficient because token ratios vary by content type
|
||||||
enc = encoding_for_model(EMBEDDING_MODEL)
|
enc = encoding_for_model(EMBEDDING_MODEL)
|
||||||
tokens = enc.encode(text)
|
tokens = enc.encode(text)
|
||||||
if len(tokens) > EMBEDDING_MAX_TOKENS:
|
if len(tokens) > EMBEDDING_MAX_TOKENS:
|
||||||
tokens = tokens[:EMBEDDING_MAX_TOKENS]
|
tokens = tokens[:EMBEDDING_MAX_TOKENS]
|
||||||
truncated_text = enc.decode(tokens)
|
truncated_text = enc.decode(tokens)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens"
|
f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
truncated_text = text
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
response = await client.embeddings.create(
|
||||||
|
model=EMBEDDING_MODEL,
|
||||||
|
input=truncated_text,
|
||||||
)
|
)
|
||||||
else:
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
truncated_text = text
|
|
||||||
|
|
||||||
start_time = time.time()
|
embedding = response.data[0].embedding
|
||||||
response = await client.embeddings.create(
|
logger.info(
|
||||||
model=EMBEDDING_MODEL,
|
f"Generated embedding: {len(embedding)} dims, "
|
||||||
input=truncated_text,
|
f"{len(tokens)} tokens, {latency_ms:.0f}ms"
|
||||||
)
|
)
|
||||||
latency_ms = (time.time() - start_time) * 1000
|
return embedding
|
||||||
|
|
||||||
embedding = response.data[0].embedding
|
except Exception as e:
|
||||||
logger.info(
|
logger.error(f"Failed to generate embedding: {e}")
|
||||||
f"Generated embedding: {len(embedding)} dims, "
|
return None
|
||||||
f"{len(tokens)} tokens, {latency_ms:.0f}ms"
|
|
||||||
)
|
|
||||||
return embedding
|
|
||||||
|
|
||||||
|
|
||||||
async def store_embedding(
|
async def store_embedding(
|
||||||
@@ -137,45 +144,48 @@ async def store_content_embedding(
|
|||||||
|
|
||||||
New function for unified content embedding storage.
|
New function for unified content embedding storage.
|
||||||
Uses raw SQL since Prisma doesn't natively support pgvector.
|
Uses raw SQL since Prisma doesn't natively support pgvector.
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
client = tx if tx else prisma.get_client()
|
try:
|
||||||
|
client = tx if tx else prisma.get_client()
|
||||||
|
|
||||||
# Convert embedding to PostgreSQL vector format
|
# Convert embedding to PostgreSQL vector format
|
||||||
embedding_str = embedding_to_vector_string(embedding)
|
embedding_str = embedding_to_vector_string(embedding)
|
||||||
metadata_json = dumps(metadata or {})
|
metadata_json = dumps(metadata or {})
|
||||||
|
|
||||||
# Upsert the embedding
|
# Upsert the embedding
|
||||||
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
|
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
|
||||||
# Use unqualified ::vector - pgvector is in search_path on all environments
|
# Use unqualified ::vector - pgvector is in search_path on all environments
|
||||||
await execute_raw_with_schema(
|
await execute_raw_with_schema(
|
||||||
"""
|
"""
|
||||||
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
||||||
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
|
"id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW())
|
||||||
|
ON CONFLICT ("contentType", "contentId", "userId")
|
||||||
|
DO UPDATE SET
|
||||||
|
"embedding" = $4::vector,
|
||||||
|
"searchableText" = $5,
|
||||||
|
"metadata" = $6::jsonb,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType"
|
||||||
|
AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2
|
||||||
|
AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL))
|
||||||
|
""",
|
||||||
|
content_type,
|
||||||
|
content_id,
|
||||||
|
user_id,
|
||||||
|
embedding_str,
|
||||||
|
searchable_text,
|
||||||
|
metadata_json,
|
||||||
|
client=client,
|
||||||
)
|
)
|
||||||
VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW())
|
|
||||||
ON CONFLICT ("contentType", "contentId", "userId")
|
|
||||||
DO UPDATE SET
|
|
||||||
"embedding" = $4::vector,
|
|
||||||
"searchableText" = $5,
|
|
||||||
"metadata" = $6::jsonb,
|
|
||||||
"updatedAt" = NOW()
|
|
||||||
WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType"
|
|
||||||
AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2
|
|
||||||
AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL))
|
|
||||||
""",
|
|
||||||
content_type,
|
|
||||||
content_id,
|
|
||||||
user_id,
|
|
||||||
embedding_str,
|
|
||||||
searchable_text,
|
|
||||||
metadata_json,
|
|
||||||
client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store embedding for {content_type}:{content_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_embedding(version_id: str) -> dict[str, Any] | None:
|
async def get_embedding(version_id: str) -> dict[str, Any] | None:
|
||||||
@@ -207,31 +217,34 @@ async def get_content_embedding(
|
|||||||
|
|
||||||
New function for unified content embedding retrieval.
|
New function for unified content embedding retrieval.
|
||||||
Returns dict with contentType, contentId, embedding, timestamps or None if not found.
|
Returns dict with contentType, contentId, embedding, timestamps or None if not found.
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
result = await query_raw_with_schema(
|
try:
|
||||||
"""
|
result = await query_raw_with_schema(
|
||||||
SELECT
|
"""
|
||||||
"contentType",
|
SELECT
|
||||||
"contentId",
|
"contentType",
|
||||||
"userId",
|
"contentId",
|
||||||
"embedding"::text as "embedding",
|
"userId",
|
||||||
"searchableText",
|
"embedding"::text as "embedding",
|
||||||
"metadata",
|
"searchableText",
|
||||||
"createdAt",
|
"metadata",
|
||||||
"updatedAt"
|
"createdAt",
|
||||||
FROM {schema_prefix}"UnifiedContentEmbedding"
|
"updatedAt"
|
||||||
WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL))
|
FROM {schema_prefix}"UnifiedContentEmbedding"
|
||||||
""",
|
WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL))
|
||||||
content_type,
|
""",
|
||||||
content_id,
|
content_type,
|
||||||
user_id,
|
content_id,
|
||||||
)
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
if result and len(result) > 0:
|
if result and len(result) > 0:
|
||||||
return result[0]
|
return result[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get embedding for {content_type}:{content_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def ensure_embedding(
|
async def ensure_embedding(
|
||||||
@@ -259,38 +272,46 @@ async def ensure_embedding(
|
|||||||
tx: Optional transaction client
|
tx: Optional transaction client
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if embedding exists/was created
|
True if embedding exists/was created, False on failure
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
# Check if embedding already exists
|
try:
|
||||||
if not force:
|
# Check if embedding already exists
|
||||||
existing = await get_embedding(version_id)
|
if not force:
|
||||||
if existing and existing.get("embedding"):
|
existing = await get_embedding(version_id)
|
||||||
logger.debug(f"Embedding for version {version_id} already exists")
|
if existing and existing.get("embedding"):
|
||||||
return True
|
logger.debug(f"Embedding for version {version_id} already exists")
|
||||||
|
return True
|
||||||
|
|
||||||
# Build searchable text for embedding
|
# Build searchable text for embedding
|
||||||
searchable_text = build_searchable_text(name, description, sub_heading, categories)
|
searchable_text = build_searchable_text(
|
||||||
|
name, description, sub_heading, categories
|
||||||
|
)
|
||||||
|
|
||||||
# Generate new embedding
|
# Generate new embedding
|
||||||
embedding = await generate_embedding(searchable_text)
|
embedding = await generate_embedding(searchable_text)
|
||||||
|
if embedding is None:
|
||||||
|
logger.warning(f"Could not generate embedding for version {version_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Store the embedding with metadata using new function
|
# Store the embedding with metadata using new function
|
||||||
metadata = {
|
metadata = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"subHeading": sub_heading,
|
"subHeading": sub_heading,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
}
|
}
|
||||||
return await store_content_embedding(
|
return await store_content_embedding(
|
||||||
content_type=ContentType.STORE_AGENT,
|
content_type=ContentType.STORE_AGENT,
|
||||||
content_id=version_id,
|
content_id=version_id,
|
||||||
embedding=embedding,
|
embedding=embedding,
|
||||||
searchable_text=searchable_text,
|
searchable_text=searchable_text,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
user_id=None, # Store agents are public
|
user_id=None, # Store agents are public
|
||||||
tx=tx,
|
tx=tx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ensure embedding for version {version_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def delete_embedding(version_id: str) -> bool:
|
async def delete_embedding(version_id: str) -> bool:
|
||||||
@@ -500,24 +521,6 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]:
|
|||||||
success = sum(1 for result in results if result is True)
|
success = sum(1 for result in results if result is True)
|
||||||
failed = len(results) - success
|
failed = len(results) - success
|
||||||
|
|
||||||
# Aggregate unique errors to avoid Sentry spam
|
|
||||||
if failed > 0:
|
|
||||||
# Group errors by type and message
|
|
||||||
error_summary: dict[str, int] = {}
|
|
||||||
for result in results:
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
error_key = f"{type(result).__name__}: {str(result)}"
|
|
||||||
error_summary[error_key] = error_summary.get(error_key, 0) + 1
|
|
||||||
|
|
||||||
# Log aggregated error summary
|
|
||||||
error_details = ", ".join(
|
|
||||||
f"{error} ({count}x)" for error, count in error_summary.items()
|
|
||||||
)
|
|
||||||
logger.error(
|
|
||||||
f"{content_type.value}: {failed}/{len(results)} embeddings failed. "
|
|
||||||
f"Errors: {error_details}"
|
|
||||||
)
|
|
||||||
|
|
||||||
results_by_type[content_type.value] = {
|
results_by_type[content_type.value] = {
|
||||||
"processed": len(missing_items),
|
"processed": len(missing_items),
|
||||||
"success": success,
|
"success": success,
|
||||||
@@ -554,12 +557,11 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def embed_query(query: str) -> list[float]:
|
async def embed_query(query: str) -> list[float] | None:
|
||||||
"""
|
"""
|
||||||
Generate embedding for a search query.
|
Generate embedding for a search query.
|
||||||
|
|
||||||
Same as generate_embedding but with clearer intent.
|
Same as generate_embedding but with clearer intent.
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
return await generate_embedding(query)
|
return await generate_embedding(query)
|
||||||
|
|
||||||
@@ -592,30 +594,40 @@ async def ensure_content_embedding(
|
|||||||
tx: Optional transaction client
|
tx: Optional transaction client
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if embedding exists/was created
|
True if embedding exists/was created, False on failure
|
||||||
|
|
||||||
Raises exceptions on failure - caller should handle.
|
|
||||||
"""
|
"""
|
||||||
# Check if embedding already exists
|
try:
|
||||||
if not force:
|
# Check if embedding already exists
|
||||||
existing = await get_content_embedding(content_type, content_id, user_id)
|
if not force:
|
||||||
if existing and existing.get("embedding"):
|
existing = await get_content_embedding(content_type, content_id, user_id)
|
||||||
logger.debug(f"Embedding for {content_type}:{content_id} already exists")
|
if existing and existing.get("embedding"):
|
||||||
return True
|
logger.debug(
|
||||||
|
f"Embedding for {content_type}:{content_id} already exists"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
# Generate new embedding
|
# Generate new embedding
|
||||||
embedding = await generate_embedding(searchable_text)
|
embedding = await generate_embedding(searchable_text)
|
||||||
|
if embedding is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not generate embedding for {content_type}:{content_id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
# Store the embedding
|
# Store the embedding
|
||||||
return await store_content_embedding(
|
return await store_content_embedding(
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
content_id=content_id,
|
content_id=content_id,
|
||||||
embedding=embedding,
|
embedding=embedding,
|
||||||
searchable_text=searchable_text,
|
searchable_text=searchable_text,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tx=tx,
|
tx=tx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ensure embedding for {content_type}:{content_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_orphaned_embeddings() -> dict[str, Any]:
|
async def cleanup_orphaned_embeddings() -> dict[str, Any]:
|
||||||
@@ -842,8 +854,9 @@ async def semantic_search(
|
|||||||
limit = 100
|
limit = 100
|
||||||
|
|
||||||
# Generate query embedding
|
# Generate query embedding
|
||||||
try:
|
query_embedding = await embed_query(query)
|
||||||
query_embedding = await embed_query(query)
|
|
||||||
|
if query_embedding is not None:
|
||||||
# Semantic search with embeddings
|
# Semantic search with embeddings
|
||||||
embedding_str = embedding_to_vector_string(query_embedding)
|
embedding_str = embedding_to_vector_string(query_embedding)
|
||||||
|
|
||||||
@@ -894,21 +907,24 @@ async def semantic_search(
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
results = await query_raw_with_schema(sql, *params)
|
try:
|
||||||
return [
|
results = await query_raw_with_schema(sql, *params)
|
||||||
{
|
return [
|
||||||
"content_id": row["content_id"],
|
{
|
||||||
"content_type": row["content_type"],
|
"content_id": row["content_id"],
|
||||||
"searchable_text": row["searchable_text"],
|
"content_type": row["content_type"],
|
||||||
"metadata": row["metadata"],
|
"searchable_text": row["searchable_text"],
|
||||||
"similarity": float(row["similarity"]),
|
"metadata": row["metadata"],
|
||||||
}
|
"similarity": float(row["similarity"]),
|
||||||
for row in results
|
}
|
||||||
]
|
for row in results
|
||||||
except Exception as e:
|
]
|
||||||
logger.warning(f"Semantic search failed, falling back to lexical search: {e}")
|
except Exception as e:
|
||||||
|
logger.error(f"Semantic search failed: {e}")
|
||||||
|
# Fall through to lexical search below
|
||||||
|
|
||||||
# Fallback to lexical search if embeddings unavailable
|
# Fallback to lexical search if embeddings unavailable
|
||||||
|
logger.warning("Falling back to lexical search (embeddings unavailable)")
|
||||||
|
|
||||||
params_lexical: list[Any] = [limit]
|
params_lexical: list[Any] = [limit]
|
||||||
user_filter = ""
|
user_filter = ""
|
||||||
|
|||||||
@@ -298,16 +298,17 @@ async def test_schema_handling_error_cases():
|
|||||||
mock_client.execute_raw.side_effect = Exception("Database error")
|
mock_client.execute_raw.side_effect = Exception("Database error")
|
||||||
mock_get_client.return_value = mock_client
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
# Should raise exception on error
|
result = await embeddings.store_content_embedding(
|
||||||
with pytest.raises(Exception, match="Database error"):
|
content_type=ContentType.STORE_AGENT,
|
||||||
await embeddings.store_content_embedding(
|
content_id="test-id",
|
||||||
content_type=ContentType.STORE_AGENT,
|
embedding=[0.1] * EMBEDDING_DIM,
|
||||||
content_id="test-id",
|
searchable_text="test",
|
||||||
embedding=[0.1] * EMBEDDING_DIM,
|
metadata=None,
|
||||||
searchable_text="test",
|
user_id=None,
|
||||||
metadata=None,
|
)
|
||||||
user_id=None,
|
|
||||||
)
|
# Should return False on error, not raise
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ async def test_generate_embedding_no_api_key():
|
|||||||
) as mock_get_client:
|
) as mock_get_client:
|
||||||
mock_get_client.return_value = None
|
mock_get_client.return_value = None
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="openai_internal_api_key not set"):
|
result = await embeddings.generate_embedding("test text")
|
||||||
await embeddings.generate_embedding("test text")
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
@@ -96,8 +97,9 @@ async def test_generate_embedding_api_error():
|
|||||||
) as mock_get_client:
|
) as mock_get_client:
|
||||||
mock_get_client.return_value = mock_client
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
with pytest.raises(Exception, match="API Error"):
|
result = await embeddings.generate_embedding("test text")
|
||||||
await embeddings.generate_embedding("test text")
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
@@ -171,10 +173,11 @@ async def test_store_embedding_database_error(mocker):
|
|||||||
|
|
||||||
embedding = [0.1, 0.2, 0.3]
|
embedding = [0.1, 0.2, 0.3]
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Database error"):
|
result = await embeddings.store_embedding(
|
||||||
await embeddings.store_embedding(
|
version_id="test-version-id", embedding=embedding, tx=mock_client
|
||||||
version_id="test-version-id", embedding=embedding, tx=mock_client
|
)
|
||||||
)
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
@@ -274,16 +277,17 @@ async def test_ensure_embedding_create_new(mock_get, mock_store, mock_generate):
|
|||||||
async def test_ensure_embedding_generation_fails(mock_get, mock_generate):
|
async def test_ensure_embedding_generation_fails(mock_get, mock_generate):
|
||||||
"""Test ensure_embedding when generation fails."""
|
"""Test ensure_embedding when generation fails."""
|
||||||
mock_get.return_value = None
|
mock_get.return_value = None
|
||||||
mock_generate.side_effect = Exception("Generation failed")
|
mock_generate.return_value = None
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Generation failed"):
|
result = await embeddings.ensure_embedding(
|
||||||
await embeddings.ensure_embedding(
|
version_id="test-id",
|
||||||
version_id="test-id",
|
name="Test",
|
||||||
name="Test",
|
description="Test description",
|
||||||
description="Test description",
|
sub_heading="Test heading",
|
||||||
sub_heading="Test heading",
|
categories=["test"],
|
||||||
categories=["test"],
|
)
|
||||||
)
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
|||||||
@@ -186,12 +186,13 @@ async def unified_hybrid_search(
|
|||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# Generate query embedding with graceful degradation
|
# Generate query embedding
|
||||||
try:
|
query_embedding = await embed_query(query)
|
||||||
query_embedding = await embed_query(query)
|
|
||||||
except Exception as e:
|
# Graceful degradation if embedding unavailable
|
||||||
|
if query_embedding is None or not query_embedding:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to generate query embedding - falling back to lexical-only search: {e}. "
|
"Failed to generate query embedding - falling back to lexical-only search. "
|
||||||
"Check that openai_internal_api_key is configured and OpenAI API is accessible."
|
"Check that openai_internal_api_key is configured and OpenAI API is accessible."
|
||||||
)
|
)
|
||||||
query_embedding = [0.0] * EMBEDDING_DIM
|
query_embedding = [0.0] * EMBEDDING_DIM
|
||||||
@@ -463,12 +464,13 @@ async def hybrid_search(
|
|||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# Generate query embedding with graceful degradation
|
# Generate query embedding
|
||||||
try:
|
query_embedding = await embed_query(query)
|
||||||
query_embedding = await embed_query(query)
|
|
||||||
except Exception as e:
|
# Graceful degradation
|
||||||
|
if query_embedding is None or not query_embedding:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to generate query embedding - falling back to lexical-only search: {e}"
|
"Failed to generate query embedding - falling back to lexical-only search."
|
||||||
)
|
)
|
||||||
query_embedding = [0.0] * EMBEDDING_DIM
|
query_embedding = [0.0] * EMBEDDING_DIM
|
||||||
total_non_semantic = (
|
total_non_semantic = (
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ async def test_hybrid_search_without_embeddings():
|
|||||||
with patch(
|
with patch(
|
||||||
"backend.api.features.store.hybrid_search.query_raw_with_schema"
|
"backend.api.features.store.hybrid_search.query_raw_with_schema"
|
||||||
) as mock_query:
|
) as mock_query:
|
||||||
# Simulate embedding failure by raising exception
|
# Simulate embedding failure
|
||||||
mock_embed.side_effect = Exception("Embedding generation failed")
|
mock_embed.return_value = None
|
||||||
mock_query.return_value = mock_results
|
mock_query.return_value = mock_results
|
||||||
|
|
||||||
# Should NOT raise - graceful degradation
|
# Should NOT raise - graceful degradation
|
||||||
@@ -613,9 +613,7 @@ async def test_unified_hybrid_search_graceful_degradation():
|
|||||||
"backend.api.features.store.hybrid_search.embed_query"
|
"backend.api.features.store.hybrid_search.embed_query"
|
||||||
) as mock_embed:
|
) as mock_embed:
|
||||||
mock_query.return_value = mock_results
|
mock_query.return_value = mock_results
|
||||||
mock_embed.side_effect = Exception(
|
mock_embed.return_value = None # Embedding failure
|
||||||
"Embedding generation failed"
|
|
||||||
) # Embedding failure
|
|
||||||
|
|
||||||
# Should NOT raise - graceful degradation
|
# Should NOT raise - graceful degradation
|
||||||
results, total = await unified_hybrid_search(
|
results, total = await unified_hybrid_search(
|
||||||
|
|||||||
@@ -1,659 +0,0 @@
|
|||||||
import json
|
|
||||||
import shlex
|
|
||||||
import uuid
|
|
||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from e2b import AsyncSandbox as BaseAsyncSandbox
|
|
||||||
from pydantic import BaseModel, SecretStr
|
|
||||||
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.model import (
|
|
||||||
APIKeyCredentials,
|
|
||||||
CredentialsField,
|
|
||||||
CredentialsMetaInput,
|
|
||||||
SchemaField,
|
|
||||||
)
|
|
||||||
from backend.integrations.providers import ProviderName
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCodeExecutionError(Exception):
|
|
||||||
"""Exception raised when Claude Code execution fails.
|
|
||||||
|
|
||||||
Carries the sandbox_id so it can be returned to the user for cleanup
|
|
||||||
when dispose_sandbox=False.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, message: str, sandbox_id: str = ""):
|
|
||||||
super().__init__(message)
|
|
||||||
self.sandbox_id = sandbox_id
|
|
||||||
|
|
||||||
|
|
||||||
# Test credentials for E2B
|
|
||||||
TEST_E2B_CREDENTIALS = APIKeyCredentials(
|
|
||||||
id="01234567-89ab-cdef-0123-456789abcdef",
|
|
||||||
provider="e2b",
|
|
||||||
api_key=SecretStr("mock-e2b-api-key"),
|
|
||||||
title="Mock E2B API key",
|
|
||||||
expires_at=None,
|
|
||||||
)
|
|
||||||
TEST_E2B_CREDENTIALS_INPUT = {
|
|
||||||
"provider": TEST_E2B_CREDENTIALS.provider,
|
|
||||||
"id": TEST_E2B_CREDENTIALS.id,
|
|
||||||
"type": TEST_E2B_CREDENTIALS.type,
|
|
||||||
"title": TEST_E2B_CREDENTIALS.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test credentials for Anthropic
|
|
||||||
TEST_ANTHROPIC_CREDENTIALS = APIKeyCredentials(
|
|
||||||
id="2e568a2b-b2ea-475a-8564-9a676bf31c56",
|
|
||||||
provider="anthropic",
|
|
||||||
api_key=SecretStr("mock-anthropic-api-key"),
|
|
||||||
title="Mock Anthropic API key",
|
|
||||||
expires_at=None,
|
|
||||||
)
|
|
||||||
TEST_ANTHROPIC_CREDENTIALS_INPUT = {
|
|
||||||
"provider": TEST_ANTHROPIC_CREDENTIALS.provider,
|
|
||||||
"id": TEST_ANTHROPIC_CREDENTIALS.id,
|
|
||||||
"type": TEST_ANTHROPIC_CREDENTIALS.type,
|
|
||||||
"title": TEST_ANTHROPIC_CREDENTIALS.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCodeBlock(Block):
|
|
||||||
"""
|
|
||||||
Execute tasks using Claude Code (Anthropic's AI coding assistant) in an E2B sandbox.
|
|
||||||
|
|
||||||
Claude Code can create files, install tools, run commands, and perform complex
|
|
||||||
coding tasks autonomously within a secure sandbox environment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Use base template - we'll install Claude Code ourselves for latest version
|
|
||||||
DEFAULT_TEMPLATE = "base"
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
e2b_credentials: CredentialsMetaInput[
|
|
||||||
Literal[ProviderName.E2B], Literal["api_key"]
|
|
||||||
] = CredentialsField(
|
|
||||||
description=(
|
|
||||||
"API key for the E2B platform to create the sandbox. "
|
|
||||||
"Get one on the [e2b website](https://e2b.dev/docs)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
anthropic_credentials: CredentialsMetaInput[
|
|
||||||
Literal[ProviderName.ANTHROPIC], Literal["api_key"]
|
|
||||||
] = CredentialsField(
|
|
||||||
description=(
|
|
||||||
"API key for Anthropic to power Claude Code. "
|
|
||||||
"Get one at [Anthropic's website](https://console.anthropic.com)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt: str = SchemaField(
|
|
||||||
description=(
|
|
||||||
"The task or instruction for Claude Code to execute. "
|
|
||||||
"Claude Code can create files, install packages, run commands, "
|
|
||||||
"and perform complex coding tasks."
|
|
||||||
),
|
|
||||||
placeholder="Create a hello world index.html file",
|
|
||||||
default="",
|
|
||||||
advanced=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
timeout: int = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Sandbox timeout in seconds. Claude Code tasks can take "
|
|
||||||
"a while, so set this appropriately for your task complexity. "
|
|
||||||
"Note: This only applies when creating a new sandbox. "
|
|
||||||
"When reconnecting to an existing sandbox via sandbox_id, "
|
|
||||||
"the original timeout is retained."
|
|
||||||
),
|
|
||||||
default=300, # 5 minutes default
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
setup_commands: list[str] = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Optional shell commands to run before executing Claude Code. "
|
|
||||||
"Useful for installing dependencies or setting up the environment."
|
|
||||||
),
|
|
||||||
default_factory=list,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
working_directory: str = SchemaField(
|
|
||||||
description="Working directory for Claude Code to operate in.",
|
|
||||||
default="/home/user",
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Session/continuation support
|
|
||||||
session_id: str = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Session ID to resume a previous conversation. "
|
|
||||||
"Leave empty for a new conversation. "
|
|
||||||
"Use the session_id from a previous run to continue that conversation."
|
|
||||||
),
|
|
||||||
default="",
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sandbox_id: str = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Sandbox ID to reconnect to an existing sandbox. "
|
|
||||||
"Required when resuming a session (along with session_id). "
|
|
||||||
"Use the sandbox_id from a previous run where dispose_sandbox was False."
|
|
||||||
),
|
|
||||||
default="",
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
conversation_history: str = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Previous conversation history to continue from. "
|
|
||||||
"Use this to restore context on a fresh sandbox if the previous one timed out. "
|
|
||||||
"Pass the conversation_history output from a previous run."
|
|
||||||
),
|
|
||||||
default="",
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
dispose_sandbox: bool = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Whether to dispose of the sandbox immediately after execution. "
|
|
||||||
"Set to False if you want to continue the conversation later "
|
|
||||||
"(you'll need both sandbox_id and session_id from the output)."
|
|
||||||
),
|
|
||||||
default=True,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
class FileOutput(BaseModel):
|
|
||||||
"""A file extracted from the sandbox."""
|
|
||||||
|
|
||||||
path: str
|
|
||||||
relative_path: str # Path relative to working directory (for GitHub, etc.)
|
|
||||||
name: str
|
|
||||||
content: str
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
response: str = SchemaField(
|
|
||||||
description="The output/response from Claude Code execution"
|
|
||||||
)
|
|
||||||
files: list["ClaudeCodeBlock.FileOutput"] = SchemaField(
|
|
||||||
description=(
|
|
||||||
"List of text files created/modified by Claude Code during this execution. "
|
|
||||||
"Each file has 'path', 'relative_path', 'name', and 'content' fields."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conversation_history: str = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Full conversation history including this turn. "
|
|
||||||
"Pass this to conversation_history input to continue on a fresh sandbox "
|
|
||||||
"if the previous sandbox timed out."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session_id: str = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Session ID for this conversation. "
|
|
||||||
"Pass this back along with sandbox_id to continue the conversation."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sandbox_id: Optional[str] = SchemaField(
|
|
||||||
description=(
|
|
||||||
"ID of the sandbox instance. "
|
|
||||||
"Pass this back along with session_id to continue the conversation. "
|
|
||||||
"This is None if dispose_sandbox was True (sandbox was disposed)."
|
|
||||||
),
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
error: str = SchemaField(description="Error message if execution failed")
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="4e34f4a5-9b89-4326-ba77-2dd6750b7194",
|
|
||||||
description=(
|
|
||||||
"Execute tasks using Claude Code in an E2B sandbox. "
|
|
||||||
"Claude Code can create files, install tools, run commands, "
|
|
||||||
"and perform complex coding tasks autonomously."
|
|
||||||
),
|
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.AI},
|
|
||||||
input_schema=ClaudeCodeBlock.Input,
|
|
||||||
output_schema=ClaudeCodeBlock.Output,
|
|
||||||
test_credentials={
|
|
||||||
"e2b_credentials": TEST_E2B_CREDENTIALS,
|
|
||||||
"anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS,
|
|
||||||
},
|
|
||||||
test_input={
|
|
||||||
"e2b_credentials": TEST_E2B_CREDENTIALS_INPUT,
|
|
||||||
"anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS_INPUT,
|
|
||||||
"prompt": "Create a hello world HTML file",
|
|
||||||
"timeout": 300,
|
|
||||||
"setup_commands": [],
|
|
||||||
"working_directory": "/home/user",
|
|
||||||
"session_id": "",
|
|
||||||
"sandbox_id": "",
|
|
||||||
"conversation_history": "",
|
|
||||||
"dispose_sandbox": True,
|
|
||||||
},
|
|
||||||
test_output=[
|
|
||||||
("response", "Created index.html with hello world content"),
|
|
||||||
(
|
|
||||||
"files",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"path": "/home/user/index.html",
|
|
||||||
"relative_path": "index.html",
|
|
||||||
"name": "index.html",
|
|
||||||
"content": "<html>Hello World</html>",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"conversation_history",
|
|
||||||
"User: Create a hello world HTML file\n"
|
|
||||||
"Claude: Created index.html with hello world content",
|
|
||||||
),
|
|
||||||
("session_id", str),
|
|
||||||
("sandbox_id", None), # None because dispose_sandbox=True in test_input
|
|
||||||
],
|
|
||||||
test_mock={
|
|
||||||
"execute_claude_code": lambda *args, **kwargs: (
|
|
||||||
"Created index.html with hello world content", # response
|
|
||||||
[
|
|
||||||
ClaudeCodeBlock.FileOutput(
|
|
||||||
path="/home/user/index.html",
|
|
||||||
relative_path="index.html",
|
|
||||||
name="index.html",
|
|
||||||
content="<html>Hello World</html>",
|
|
||||||
)
|
|
||||||
], # files
|
|
||||||
"User: Create a hello world HTML file\n"
|
|
||||||
"Claude: Created index.html with hello world content", # conversation_history
|
|
||||||
"test-session-id", # session_id
|
|
||||||
"sandbox_id", # sandbox_id
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute_claude_code(
|
|
||||||
self,
|
|
||||||
e2b_api_key: str,
|
|
||||||
anthropic_api_key: str,
|
|
||||||
prompt: str,
|
|
||||||
timeout: int,
|
|
||||||
setup_commands: list[str],
|
|
||||||
working_directory: str,
|
|
||||||
session_id: str,
|
|
||||||
existing_sandbox_id: str,
|
|
||||||
conversation_history: str,
|
|
||||||
dispose_sandbox: bool,
|
|
||||||
) -> tuple[str, list["ClaudeCodeBlock.FileOutput"], str, str, str]:
|
|
||||||
"""
|
|
||||||
Execute Claude Code in an E2B sandbox.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (response, files, conversation_history, session_id, sandbox_id)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Validate that sandbox_id is provided when resuming a session
|
|
||||||
if session_id and not existing_sandbox_id:
|
|
||||||
raise ValueError(
|
|
||||||
"sandbox_id is required when resuming a session with session_id. "
|
|
||||||
"The session state is stored in the original sandbox. "
|
|
||||||
"If the sandbox has timed out, use conversation_history instead "
|
|
||||||
"to restore context on a fresh sandbox."
|
|
||||||
)
|
|
||||||
|
|
||||||
sandbox = None
|
|
||||||
sandbox_id = ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Either reconnect to existing sandbox or create a new one
|
|
||||||
if existing_sandbox_id:
|
|
||||||
# Reconnect to existing sandbox for conversation continuation
|
|
||||||
sandbox = await BaseAsyncSandbox.connect(
|
|
||||||
sandbox_id=existing_sandbox_id,
|
|
||||||
api_key=e2b_api_key,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Create new sandbox
|
|
||||||
sandbox = await BaseAsyncSandbox.create(
|
|
||||||
template=self.DEFAULT_TEMPLATE,
|
|
||||||
api_key=e2b_api_key,
|
|
||||||
timeout=timeout,
|
|
||||||
envs={"ANTHROPIC_API_KEY": anthropic_api_key},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Install Claude Code from npm (ensures we get the latest version)
|
|
||||||
install_result = await sandbox.commands.run(
|
|
||||||
"npm install -g @anthropic-ai/claude-code@latest",
|
|
||||||
timeout=120, # 2 min timeout for install
|
|
||||||
)
|
|
||||||
if install_result.exit_code != 0:
|
|
||||||
raise Exception(
|
|
||||||
f"Failed to install Claude Code: {install_result.stderr}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run any user-provided setup commands
|
|
||||||
for cmd in setup_commands:
|
|
||||||
setup_result = await sandbox.commands.run(cmd)
|
|
||||||
if setup_result.exit_code != 0:
|
|
||||||
raise Exception(
|
|
||||||
f"Setup command failed: {cmd}\n"
|
|
||||||
f"Exit code: {setup_result.exit_code}\n"
|
|
||||||
f"Stdout: {setup_result.stdout}\n"
|
|
||||||
f"Stderr: {setup_result.stderr}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Capture sandbox_id immediately after creation/connection
|
|
||||||
# so it's available for error recovery if dispose_sandbox=False
|
|
||||||
sandbox_id = sandbox.sandbox_id
|
|
||||||
|
|
||||||
# Generate or use provided session ID
|
|
||||||
current_session_id = session_id if session_id else str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Build base Claude flags
|
|
||||||
base_flags = "-p --dangerously-skip-permissions --output-format json"
|
|
||||||
|
|
||||||
# Add conversation history context if provided (for fresh sandbox continuation)
|
|
||||||
history_flag = ""
|
|
||||||
if conversation_history and not session_id:
|
|
||||||
# Inject previous conversation as context via system prompt
|
|
||||||
# Use consistent escaping via _escape_prompt helper
|
|
||||||
escaped_history = self._escape_prompt(
|
|
||||||
f"Previous conversation context: {conversation_history}"
|
|
||||||
)
|
|
||||||
history_flag = f" --append-system-prompt {escaped_history}"
|
|
||||||
|
|
||||||
# Build Claude command based on whether we're resuming or starting new
|
|
||||||
# Use shlex.quote for working_directory and session IDs to prevent injection
|
|
||||||
safe_working_dir = shlex.quote(working_directory)
|
|
||||||
if session_id:
|
|
||||||
# Resuming existing session (sandbox still alive)
|
|
||||||
safe_session_id = shlex.quote(session_id)
|
|
||||||
claude_command = (
|
|
||||||
f"cd {safe_working_dir} && "
|
|
||||||
f"echo {self._escape_prompt(prompt)} | "
|
|
||||||
f"claude --resume {safe_session_id} {base_flags}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# New session with specific ID
|
|
||||||
safe_current_session_id = shlex.quote(current_session_id)
|
|
||||||
claude_command = (
|
|
||||||
f"cd {safe_working_dir} && "
|
|
||||||
f"echo {self._escape_prompt(prompt)} | "
|
|
||||||
f"claude --session-id {safe_current_session_id} {base_flags}{history_flag}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Capture timestamp before running Claude Code to filter files later
|
|
||||||
# Capture timestamp 1 second in the past to avoid race condition with file creation
|
|
||||||
timestamp_result = await sandbox.commands.run(
|
|
||||||
"date -u -d '1 second ago' +%Y-%m-%dT%H:%M:%S"
|
|
||||||
)
|
|
||||||
if timestamp_result.exit_code != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to capture timestamp: {timestamp_result.stderr}"
|
|
||||||
)
|
|
||||||
start_timestamp = (
|
|
||||||
timestamp_result.stdout.strip() if timestamp_result.stdout else None
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await sandbox.commands.run(
|
|
||||||
claude_command,
|
|
||||||
timeout=0, # No command timeout - let sandbox timeout handle it
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for command failure
|
|
||||||
if result.exit_code != 0:
|
|
||||||
error_msg = result.stderr or result.stdout or "Unknown error"
|
|
||||||
raise Exception(
|
|
||||||
f"Claude Code command failed with exit code {result.exit_code}:\n"
|
|
||||||
f"{error_msg}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_output = result.stdout or ""
|
|
||||||
|
|
||||||
# Parse JSON output to extract response and build conversation history
|
|
||||||
response = ""
|
|
||||||
new_conversation_history = conversation_history or ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# The JSON output contains the result
|
|
||||||
output_data = json.loads(raw_output)
|
|
||||||
response = output_data.get("result", raw_output)
|
|
||||||
|
|
||||||
# Build conversation history entry
|
|
||||||
turn_entry = f"User: {prompt}\nClaude: {response}"
|
|
||||||
if new_conversation_history:
|
|
||||||
new_conversation_history = (
|
|
||||||
f"{new_conversation_history}\n\n{turn_entry}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
new_conversation_history = turn_entry
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# If not valid JSON, use raw output
|
|
||||||
response = raw_output
|
|
||||||
turn_entry = f"User: {prompt}\nClaude: {response}"
|
|
||||||
if new_conversation_history:
|
|
||||||
new_conversation_history = (
|
|
||||||
f"{new_conversation_history}\n\n{turn_entry}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
new_conversation_history = turn_entry
|
|
||||||
|
|
||||||
# Extract files created/modified during this run
|
|
||||||
files = await self._extract_files(
|
|
||||||
sandbox, working_directory, start_timestamp
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
response,
|
|
||||||
files,
|
|
||||||
new_conversation_history,
|
|
||||||
current_session_id,
|
|
||||||
sandbox_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Wrap exception with sandbox_id so caller can access/cleanup
|
|
||||||
# the preserved sandbox when dispose_sandbox=False
|
|
||||||
raise ClaudeCodeExecutionError(str(e), sandbox_id) from e
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if dispose_sandbox and sandbox:
|
|
||||||
await sandbox.kill()
|
|
||||||
|
|
||||||
async def _extract_files(
|
|
||||||
self,
|
|
||||||
sandbox: BaseAsyncSandbox,
|
|
||||||
working_directory: str,
|
|
||||||
since_timestamp: str | None = None,
|
|
||||||
) -> list["ClaudeCodeBlock.FileOutput"]:
|
|
||||||
"""
|
|
||||||
Extract text files created/modified during this Claude Code execution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sandbox: The E2B sandbox instance
|
|
||||||
working_directory: Directory to search for files
|
|
||||||
since_timestamp: ISO timestamp - only return files modified after this time
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of FileOutput objects with path, relative_path, name, and content
|
|
||||||
"""
|
|
||||||
files: list[ClaudeCodeBlock.FileOutput] = []
|
|
||||||
|
|
||||||
# Text file extensions we can safely read as text
|
|
||||||
text_extensions = {
|
|
||||||
".txt",
|
|
||||||
".md",
|
|
||||||
".html",
|
|
||||||
".htm",
|
|
||||||
".css",
|
|
||||||
".js",
|
|
||||||
".ts",
|
|
||||||
".jsx",
|
|
||||||
".tsx",
|
|
||||||
".json",
|
|
||||||
".xml",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".toml",
|
|
||||||
".ini",
|
|
||||||
".cfg",
|
|
||||||
".conf",
|
|
||||||
".py",
|
|
||||||
".rb",
|
|
||||||
".php",
|
|
||||||
".java",
|
|
||||||
".c",
|
|
||||||
".cpp",
|
|
||||||
".h",
|
|
||||||
".hpp",
|
|
||||||
".cs",
|
|
||||||
".go",
|
|
||||||
".rs",
|
|
||||||
".swift",
|
|
||||||
".kt",
|
|
||||||
".scala",
|
|
||||||
".sh",
|
|
||||||
".bash",
|
|
||||||
".zsh",
|
|
||||||
".sql",
|
|
||||||
".graphql",
|
|
||||||
".env",
|
|
||||||
".gitignore",
|
|
||||||
".dockerfile",
|
|
||||||
"Dockerfile",
|
|
||||||
".vue",
|
|
||||||
".svelte",
|
|
||||||
".astro",
|
|
||||||
".mdx",
|
|
||||||
".rst",
|
|
||||||
".tex",
|
|
||||||
".csv",
|
|
||||||
".log",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# List files recursively using find command
|
|
||||||
# Exclude node_modules and .git directories, but allow hidden files
|
|
||||||
# like .env and .gitignore (they're filtered by text_extensions later)
|
|
||||||
# Filter by timestamp to only get files created/modified during this run
|
|
||||||
safe_working_dir = shlex.quote(working_directory)
|
|
||||||
timestamp_filter = ""
|
|
||||||
if since_timestamp:
|
|
||||||
timestamp_filter = f"-newermt {shlex.quote(since_timestamp)} "
|
|
||||||
find_result = await sandbox.commands.run(
|
|
||||||
f"find {safe_working_dir} -type f "
|
|
||||||
f"{timestamp_filter}"
|
|
||||||
f"-not -path '*/node_modules/*' "
|
|
||||||
f"-not -path '*/.git/*' "
|
|
||||||
f"2>/dev/null"
|
|
||||||
)
|
|
||||||
|
|
||||||
if find_result.stdout:
|
|
||||||
for file_path in find_result.stdout.strip().split("\n"):
|
|
||||||
if not file_path:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if it's a text file we can read
|
|
||||||
is_text = any(
|
|
||||||
file_path.endswith(ext) for ext in text_extensions
|
|
||||||
) or file_path.endswith("Dockerfile")
|
|
||||||
|
|
||||||
if is_text:
|
|
||||||
try:
|
|
||||||
content = await sandbox.files.read(file_path)
|
|
||||||
# Handle bytes or string
|
|
||||||
if isinstance(content, bytes):
|
|
||||||
content = content.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
# Extract filename from path
|
|
||||||
file_name = file_path.split("/")[-1]
|
|
||||||
|
|
||||||
# Calculate relative path by stripping working directory
|
|
||||||
relative_path = file_path
|
|
||||||
if file_path.startswith(working_directory):
|
|
||||||
relative_path = file_path[len(working_directory) :]
|
|
||||||
# Remove leading slash if present
|
|
||||||
if relative_path.startswith("/"):
|
|
||||||
relative_path = relative_path[1:]
|
|
||||||
|
|
||||||
files.append(
|
|
||||||
ClaudeCodeBlock.FileOutput(
|
|
||||||
path=file_path,
|
|
||||||
relative_path=relative_path,
|
|
||||||
name=file_name,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Skip files that can't be read
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# If file extraction fails, return empty results
|
|
||||||
pass
|
|
||||||
|
|
||||||
return files
|
|
||||||
|
|
||||||
def _escape_prompt(self, prompt: str) -> str:
|
|
||||||
"""Escape the prompt for safe shell execution."""
|
|
||||||
# Use single quotes and escape any single quotes in the prompt
|
|
||||||
escaped = prompt.replace("'", "'\"'\"'")
|
|
||||||
return f"'{escaped}'"
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
e2b_credentials: APIKeyCredentials,
|
|
||||||
anthropic_credentials: APIKeyCredentials,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
try:
|
|
||||||
(
|
|
||||||
response,
|
|
||||||
files,
|
|
||||||
conversation_history,
|
|
||||||
session_id,
|
|
||||||
sandbox_id,
|
|
||||||
) = await self.execute_claude_code(
|
|
||||||
e2b_api_key=e2b_credentials.api_key.get_secret_value(),
|
|
||||||
anthropic_api_key=anthropic_credentials.api_key.get_secret_value(),
|
|
||||||
prompt=input_data.prompt,
|
|
||||||
timeout=input_data.timeout,
|
|
||||||
setup_commands=input_data.setup_commands,
|
|
||||||
working_directory=input_data.working_directory,
|
|
||||||
session_id=input_data.session_id,
|
|
||||||
existing_sandbox_id=input_data.sandbox_id,
|
|
||||||
conversation_history=input_data.conversation_history,
|
|
||||||
dispose_sandbox=input_data.dispose_sandbox,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "response", response
|
|
||||||
# Always yield files (empty list if none) to match Output schema
|
|
||||||
yield "files", [f.model_dump() for f in files]
|
|
||||||
# Always yield conversation_history so user can restore context on fresh sandbox
|
|
||||||
yield "conversation_history", conversation_history
|
|
||||||
# Always yield session_id so user can continue conversation
|
|
||||||
yield "session_id", session_id
|
|
||||||
# Always yield sandbox_id (None if disposed) to match Output schema
|
|
||||||
yield "sandbox_id", sandbox_id if not input_data.dispose_sandbox else None
|
|
||||||
|
|
||||||
except ClaudeCodeExecutionError as e:
|
|
||||||
yield "error", str(e)
|
|
||||||
# If sandbox was preserved (dispose_sandbox=False), yield sandbox_id
|
|
||||||
# so user can reconnect to or clean up the orphaned sandbox
|
|
||||||
if not input_data.dispose_sandbox and e.sandbox_id:
|
|
||||||
yield "sandbox_id", e.sandbox_id
|
|
||||||
except Exception as e:
|
|
||||||
yield "error", str(e)
|
|
||||||
@@ -1,37 +1,12 @@
|
|||||||
-- CreateExtension
|
-- CreateExtension
|
||||||
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
|
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
|
||||||
-- Ensures vector extension is in the current schema (from DATABASE_URL ?schema= param)
|
-- Creates extension in current schema (determined by search_path from DATABASE_URL ?schema= param)
|
||||||
-- If it exists in a different schema (e.g., public), we drop and recreate it in the current schema
|
|
||||||
-- This ensures vector type is in the same schema as tables, making ::vector work without explicit qualification
|
-- This ensures vector type is in the same schema as tables, making ::vector work without explicit qualification
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
|
||||||
current_schema_name text;
|
|
||||||
vector_schema text;
|
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get the current schema from search_path
|
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||||
SELECT current_schema() INTO current_schema_name;
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'vector extension not available or already exists, skipping';
|
||||||
-- Check if vector extension exists and which schema it's in
|
|
||||||
SELECT n.nspname INTO vector_schema
|
|
||||||
FROM pg_extension e
|
|
||||||
JOIN pg_namespace n ON e.extnamespace = n.oid
|
|
||||||
WHERE e.extname = 'vector';
|
|
||||||
|
|
||||||
-- Handle removal if in wrong schema
|
|
||||||
IF vector_schema IS NOT NULL AND vector_schema != current_schema_name THEN
|
|
||||||
BEGIN
|
|
||||||
-- Vector exists in a different schema, drop it first
|
|
||||||
RAISE WARNING 'pgvector found in schema "%" but need it in "%". Dropping and reinstalling...',
|
|
||||||
vector_schema, current_schema_name;
|
|
||||||
EXECUTE 'DROP EXTENSION IF EXISTS vector CASCADE';
|
|
||||||
EXCEPTION WHEN OTHERS THEN
|
|
||||||
RAISE EXCEPTION 'Failed to drop pgvector from schema "%": %. You may need to drop it manually.',
|
|
||||||
vector_schema, SQLERRM;
|
|
||||||
END;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Create extension in current schema (let it fail naturally if not available)
|
|
||||||
EXECUTE format('CREATE EXTENSION IF NOT EXISTS vector SCHEMA %I', current_schema_name);
|
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Acknowledge Supabase-managed extensions to prevent drift warnings
|
||||||
|
-- These extensions are pre-installed by Supabase in specific schemas
|
||||||
|
-- This migration ensures they exist where available (Supabase) or skips gracefully (CI)
|
||||||
|
|
||||||
|
-- Create schemas (safe in both CI and Supabase)
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "extensions";
|
||||||
|
|
||||||
|
-- Extensions that exist in both CI and Supabase
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'pgcrypto extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'uuid-ossp extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Supabase-specific extensions (skip gracefully in CI)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'pg_stat_statements extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'pg_net extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'pgjwt extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "graphql";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'pg_graphql extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "pgsodium";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgsodium" WITH SCHEMA "pgsodium";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'pgsodium extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "vault";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault";
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'supabase_vault extension not available, skipping';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
|
||||||
|
-- Return to platform
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "platform";
|
||||||
@@ -34,10 +34,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Default output directory relative to repo root
|
# Default output directory relative to repo root
|
||||||
DEFAULT_OUTPUT_DIR = (
|
DEFAULT_OUTPUT_DIR = (
|
||||||
Path(__file__).parent.parent.parent.parent
|
Path(__file__).parent.parent.parent.parent / "docs" / "integrations"
|
||||||
/ "docs"
|
|
||||||
/ "integrations"
|
|
||||||
/ "block-integrations"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -424,14 +421,6 @@ def generate_block_markdown(
|
|||||||
lines.append("<!-- END MANUAL -->")
|
lines.append("<!-- END MANUAL -->")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Optional per-block extras (only include if has content)
|
|
||||||
extras = manual_content.get("extras", "")
|
|
||||||
if extras:
|
|
||||||
lines.append("<!-- MANUAL: extras -->")
|
|
||||||
lines.append(extras)
|
|
||||||
lines.append("<!-- END MANUAL -->")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -467,52 +456,25 @@ def get_block_file_mapping(blocks: list[BlockDoc]) -> dict[str, list[BlockDoc]]:
|
|||||||
return dict(file_mapping)
|
return dict(file_mapping)
|
||||||
|
|
||||||
|
|
||||||
def generate_overview_table(blocks: list[BlockDoc], block_dir_prefix: str = "") -> str:
|
def generate_overview_table(blocks: list[BlockDoc]) -> str:
|
||||||
"""Generate the overview table markdown (blocks.md).
|
"""Generate the overview table markdown (blocks.md)."""
|
||||||
|
|
||||||
Args:
|
|
||||||
blocks: List of block documentation objects
|
|
||||||
block_dir_prefix: Prefix for block file links (e.g., "block-integrations/")
|
|
||||||
"""
|
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
# GitBook YAML frontmatter
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("layout:")
|
|
||||||
lines.append(" width: default")
|
|
||||||
lines.append(" title:")
|
|
||||||
lines.append(" visible: true")
|
|
||||||
lines.append(" description:")
|
|
||||||
lines.append(" visible: true")
|
|
||||||
lines.append(" tableOfContents:")
|
|
||||||
lines.append(" visible: false")
|
|
||||||
lines.append(" outline:")
|
|
||||||
lines.append(" visible: true")
|
|
||||||
lines.append(" pagination:")
|
|
||||||
lines.append(" visible: true")
|
|
||||||
lines.append(" metadata:")
|
|
||||||
lines.append(" visible: true")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("# AutoGPT Blocks Overview")
|
lines.append("# AutoGPT Blocks Overview")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(
|
||||||
'AutoGPT uses a modular approach with various "blocks" to handle different tasks. These blocks are the building blocks of AutoGPT workflows, allowing users to create complex automations by combining simple, specialized components.'
|
'AutoGPT uses a modular approach with various "blocks" to handle different tasks. These blocks are the building blocks of AutoGPT workflows, allowing users to create complex automations by combining simple, specialized components.'
|
||||||
)
|
)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append('{% hint style="info" %}')
|
lines.append('!!! info "Creating Your Own Blocks"')
|
||||||
lines.append("**Creating Your Own Blocks**")
|
lines.append(" Want to create your own custom blocks? Check out our guides:")
|
||||||
lines.append("")
|
lines.append(" ")
|
||||||
lines.append("Want to create your own custom blocks? Check out our guides:")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(
|
lines.append(
|
||||||
"* [Build your own Blocks](https://docs.agpt.co/platform/new_blocks/) - Step-by-step tutorial with examples"
|
" - [Build your own Blocks](https://docs.agpt.co/platform/new_blocks/) - Step-by-step tutorial with examples"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
"* [Block SDK Guide](https://docs.agpt.co/platform/block-sdk-guide/) - Advanced SDK patterns with OAuth, webhooks, and provider configuration"
|
" - [Block SDK Guide](https://docs.agpt.co/platform/block-sdk-guide/) - Advanced SDK patterns with OAuth, webhooks, and provider configuration"
|
||||||
)
|
)
|
||||||
lines.append("{% endhint %}")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(
|
||||||
"Below is a comprehensive list of all available blocks, categorized by their primary function. Click on any block name to view its detailed documentation."
|
"Below is a comprehensive list of all available blocks, categorized by their primary function. Click on any block name to view its detailed documentation."
|
||||||
@@ -575,8 +537,7 @@ def generate_overview_table(blocks: list[BlockDoc], block_dir_prefix: str = "")
|
|||||||
else "No description"
|
else "No description"
|
||||||
)
|
)
|
||||||
short_desc = short_desc.replace("\n", " ").replace("|", "\\|")
|
short_desc = short_desc.replace("\n", " ").replace("|", "\\|")
|
||||||
link_path = f"{block_dir_prefix}{file_path}"
|
lines.append(f"| [{block.name}]({file_path}#{anchor}) | {short_desc} |")
|
||||||
lines.append(f"| [{block.name}]({link_path}#{anchor}) | {short_desc} |")
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -602,55 +563,13 @@ def generate_overview_table(blocks: list[BlockDoc], block_dir_prefix: str = "")
|
|||||||
)
|
)
|
||||||
short_desc = short_desc.replace("\n", " ").replace("|", "\\|")
|
short_desc = short_desc.replace("\n", " ").replace("|", "\\|")
|
||||||
|
|
||||||
link_path = f"{block_dir_prefix}{file_path}"
|
lines.append(f"| [{block.name}]({file_path}#{anchor}) | {short_desc} |")
|
||||||
lines.append(f"| [{block.name}]({link_path}#{anchor}) | {short_desc} |")
|
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def generate_summary_md(
|
|
||||||
blocks: list[BlockDoc], root_dir: Path, block_dir_prefix: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""Generate SUMMARY.md for GitBook navigation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
blocks: List of block documentation objects
|
|
||||||
root_dir: The root docs directory (e.g., docs/integrations/)
|
|
||||||
block_dir_prefix: Prefix for block file links (e.g., "block-integrations/")
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
lines.append("# Table of contents")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("* [AutoGPT Blocks Overview](README.md)")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Check for guides/ directory at the root level (docs/integrations/guides/)
|
|
||||||
guides_dir = root_dir / "guides"
|
|
||||||
if guides_dir.exists():
|
|
||||||
lines.append("## Guides")
|
|
||||||
lines.append("")
|
|
||||||
for guide_file in sorted(guides_dir.glob("*.md")):
|
|
||||||
# Use just the file name for title (replace hyphens/underscores with spaces)
|
|
||||||
title = file_path_to_title(guide_file.stem.replace("-", "_") + ".md")
|
|
||||||
lines.append(f"* [{title}](guides/{guide_file.name})")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("## Block Integrations")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
file_mapping = get_block_file_mapping(blocks)
|
|
||||||
for file_path in sorted(file_mapping.keys()):
|
|
||||||
title = file_path_to_title(file_path)
|
|
||||||
link_path = f"{block_dir_prefix}{file_path}"
|
|
||||||
lines.append(f"* [{title}]({link_path})")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def load_all_blocks_for_docs() -> list[BlockDoc]:
|
def load_all_blocks_for_docs() -> list[BlockDoc]:
|
||||||
"""Load all blocks and extract documentation."""
|
"""Load all blocks and extract documentation."""
|
||||||
from backend.blocks import load_all_blocks
|
from backend.blocks import load_all_blocks
|
||||||
@@ -734,16 +653,6 @@ def write_block_docs(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add file-level additional_content section if present
|
|
||||||
file_additional = extract_manual_content(existing_content).get(
|
|
||||||
"additional_content", ""
|
|
||||||
)
|
|
||||||
if file_additional:
|
|
||||||
content_parts.append("<!-- MANUAL: additional_content -->")
|
|
||||||
content_parts.append(file_additional)
|
|
||||||
content_parts.append("<!-- END MANUAL -->")
|
|
||||||
content_parts.append("")
|
|
||||||
|
|
||||||
full_content = file_header + "\n" + "\n".join(content_parts)
|
full_content = file_header + "\n" + "\n".join(content_parts)
|
||||||
generated_files[str(file_path)] = full_content
|
generated_files[str(file_path)] = full_content
|
||||||
|
|
||||||
@@ -752,28 +661,14 @@ def write_block_docs(
|
|||||||
|
|
||||||
full_path.write_text(full_content)
|
full_path.write_text(full_content)
|
||||||
|
|
||||||
# Generate overview file at the parent directory (docs/integrations/)
|
# Generate overview file
|
||||||
# with links prefixed to point into block-integrations/
|
overview_content = generate_overview_table(blocks)
|
||||||
root_dir = output_dir.parent
|
overview_path = output_dir / "README.md"
|
||||||
block_dir_name = output_dir.name # "block-integrations"
|
|
||||||
block_dir_prefix = f"{block_dir_name}/"
|
|
||||||
|
|
||||||
overview_content = generate_overview_table(blocks, block_dir_prefix)
|
|
||||||
overview_path = root_dir / "README.md"
|
|
||||||
generated_files["README.md"] = overview_content
|
generated_files["README.md"] = overview_content
|
||||||
overview_path.write_text(overview_content)
|
overview_path.write_text(overview_content)
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
print(" Writing README.md (overview) to parent directory")
|
print(" Writing README.md (overview)")
|
||||||
|
|
||||||
# Generate SUMMARY.md for GitBook navigation at the parent directory
|
|
||||||
summary_content = generate_summary_md(blocks, root_dir, block_dir_prefix)
|
|
||||||
summary_path = root_dir / "SUMMARY.md"
|
|
||||||
generated_files["SUMMARY.md"] = summary_content
|
|
||||||
summary_path.write_text(summary_content)
|
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(" Writing SUMMARY.md (navigation) to parent directory")
|
|
||||||
|
|
||||||
return generated_files
|
return generated_files
|
||||||
|
|
||||||
@@ -853,16 +748,6 @@ def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool:
|
|||||||
elif block_match.group(1).strip() != expected_block_content.strip():
|
elif block_match.group(1).strip() != expected_block_content.strip():
|
||||||
mismatched_blocks.append(block.name)
|
mismatched_blocks.append(block.name)
|
||||||
|
|
||||||
# Add file-level additional_content to expected content (matches write_block_docs)
|
|
||||||
file_additional = extract_manual_content(existing_content).get(
|
|
||||||
"additional_content", ""
|
|
||||||
)
|
|
||||||
if file_additional:
|
|
||||||
content_parts.append("<!-- MANUAL: additional_content -->")
|
|
||||||
content_parts.append(file_additional)
|
|
||||||
content_parts.append("<!-- END MANUAL -->")
|
|
||||||
content_parts.append("")
|
|
||||||
|
|
||||||
expected_content = file_header + "\n" + "\n".join(content_parts)
|
expected_content = file_header + "\n" + "\n".join(content_parts)
|
||||||
|
|
||||||
if existing_content.strip() != expected_content.strip():
|
if existing_content.strip() != expected_content.strip():
|
||||||
@@ -872,15 +757,11 @@ def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool:
|
|||||||
out_of_sync_details.append((file_path, mismatched_blocks))
|
out_of_sync_details.append((file_path, mismatched_blocks))
|
||||||
all_match = False
|
all_match = False
|
||||||
|
|
||||||
# Check overview at the parent directory (docs/integrations/)
|
# Check overview
|
||||||
root_dir = output_dir.parent
|
overview_path = output_dir / "README.md"
|
||||||
block_dir_name = output_dir.name # "block-integrations"
|
|
||||||
block_dir_prefix = f"{block_dir_name}/"
|
|
||||||
|
|
||||||
overview_path = root_dir / "README.md"
|
|
||||||
if overview_path.exists():
|
if overview_path.exists():
|
||||||
existing_overview = overview_path.read_text()
|
existing_overview = overview_path.read_text()
|
||||||
expected_overview = generate_overview_table(blocks, block_dir_prefix)
|
expected_overview = generate_overview_table(blocks)
|
||||||
if existing_overview.strip() != expected_overview.strip():
|
if existing_overview.strip() != expected_overview.strip():
|
||||||
print("OUT OF SYNC: README.md (overview)")
|
print("OUT OF SYNC: README.md (overview)")
|
||||||
print(" The blocks overview table needs regeneration")
|
print(" The blocks overview table needs regeneration")
|
||||||
@@ -891,21 +772,6 @@ def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool:
|
|||||||
out_of_sync_details.append(("README.md", ["overview table"]))
|
out_of_sync_details.append(("README.md", ["overview table"]))
|
||||||
all_match = False
|
all_match = False
|
||||||
|
|
||||||
# Check SUMMARY.md at the parent directory
|
|
||||||
summary_path = root_dir / "SUMMARY.md"
|
|
||||||
if summary_path.exists():
|
|
||||||
existing_summary = summary_path.read_text()
|
|
||||||
expected_summary = generate_summary_md(blocks, root_dir, block_dir_prefix)
|
|
||||||
if existing_summary.strip() != expected_summary.strip():
|
|
||||||
print("OUT OF SYNC: SUMMARY.md (navigation)")
|
|
||||||
print(" The GitBook navigation needs regeneration")
|
|
||||||
out_of_sync_details.append(("SUMMARY.md", ["navigation"]))
|
|
||||||
all_match = False
|
|
||||||
else:
|
|
||||||
print("MISSING: SUMMARY.md (navigation)")
|
|
||||||
out_of_sync_details.append(("SUMMARY.md", ["navigation"]))
|
|
||||||
all_match = False
|
|
||||||
|
|
||||||
# Check for unfilled manual sections
|
# Check for unfilled manual sections
|
||||||
unfilled_patterns = [
|
unfilled_patterns = [
|
||||||
"_Add a description of this category of blocks._",
|
"_Add a description of this category of blocks._",
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
|
||||||
|
|
||||||
interface NewChatContextValue {
|
|
||||||
onNewChatClick: () => void;
|
|
||||||
setOnNewChatClick: (handler?: () => void) => void;
|
|
||||||
performNewChat?: () => void;
|
|
||||||
setPerformNewChat: (handler?: () => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewChatContext = createContext<NewChatContextValue | null>(null);
|
|
||||||
|
|
||||||
export function NewChatProvider({ children }: { children: ReactNode }) {
|
|
||||||
const onNewChatRef = useRef<(() => void) | undefined>();
|
|
||||||
const performNewChatRef = useRef<(() => void) | undefined>();
|
|
||||||
const contextValueRef = useRef<NewChatContextValue>({
|
|
||||||
onNewChatClick() {
|
|
||||||
onNewChatRef.current?.();
|
|
||||||
},
|
|
||||||
setOnNewChatClick(handler?: () => void) {
|
|
||||||
onNewChatRef.current = handler;
|
|
||||||
},
|
|
||||||
performNewChat() {
|
|
||||||
performNewChatRef.current?.();
|
|
||||||
},
|
|
||||||
setPerformNewChat(handler?: () => void) {
|
|
||||||
performNewChatRef.current = handler;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NewChatContext.Provider value={contextValueRef.current}>
|
|
||||||
{children}
|
|
||||||
</NewChatContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNewChat() {
|
|
||||||
return useContext(NewChatContext);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useNewChat } from "../../NewChatContext";
|
|
||||||
import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
|
import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
|
||||||
import { LoadingState } from "./components/LoadingState/LoadingState";
|
import { LoadingState } from "./components/LoadingState/LoadingState";
|
||||||
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||||
@@ -35,25 +33,10 @@ export function CopilotShell({ children }: Props) {
|
|||||||
isReadyToShowContent,
|
isReadyToShowContent,
|
||||||
} = useCopilotShell();
|
} = useCopilotShell();
|
||||||
|
|
||||||
const newChatContext = useNewChat();
|
|
||||||
const handleNewChatClickWrapper =
|
|
||||||
newChatContext?.onNewChatClick || handleNewChat;
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function registerNewChatHandler() {
|
|
||||||
if (!newChatContext) return;
|
|
||||||
newChatContext.setPerformNewChat(handleNewChat);
|
|
||||||
return function cleanup() {
|
|
||||||
newChatContext.setPerformNewChat(undefined);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[newChatContext, handleNewChat],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<ChatLoader />
|
<LoadingSpinner size="large" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,7 +55,7 @@ export function CopilotShell({ children }: Props) {
|
|||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
onFetchNextPage={fetchNextPage}
|
onFetchNextPage={fetchNextPage}
|
||||||
onNewChat={handleNewChatClickWrapper}
|
onNewChat={handleNewChat}
|
||||||
hasActiveSession={Boolean(hasActiveSession)}
|
hasActiveSession={Boolean(hasActiveSession)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -94,7 +77,7 @@ export function CopilotShell({ children }: Props) {
|
|||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
onFetchNextPage={fetchNextPage}
|
onFetchNextPage={fetchNextPage}
|
||||||
onNewChat={handleNewChatClickWrapper}
|
onNewChat={handleNewChat}
|
||||||
onClose={handleCloseDrawer}
|
onClose={handleCloseDrawer}
|
||||||
onOpenChange={handleDrawerOpenChange}
|
onOpenChange={handleDrawerOpenChange}
|
||||||
hasActiveSession={Boolean(hasActiveSession)}
|
hasActiveSession={Boolean(hasActiveSession)}
|
||||||
|
|||||||
@@ -148,15 +148,13 @@ export function useCopilotShell() {
|
|||||||
setHasAutoSelectedSession(false);
|
setHasAutoSelectedSession(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoading = isSessionsLoading && accumulatedSessions.length === 0;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isMobile,
|
isMobile,
|
||||||
isDrawerOpen,
|
isDrawerOpen,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
hasActiveSession:
|
hasActiveSession:
|
||||||
Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)),
|
Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)),
|
||||||
isLoading,
|
isLoading: isSessionsLoading || !areAllSessionsLoaded,
|
||||||
sessions: visibleSessions,
|
sessions: visibleSessions,
|
||||||
currentSessionId: sidebarSelectedSessionId,
|
currentSessionId: sidebarSelectedSessionId,
|
||||||
handleSelectSession,
|
handleSelectSession,
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
import type { User } from "@supabase/supabase-js";
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
|
||||||
export type PageState =
|
|
||||||
| { type: "welcome" }
|
|
||||||
| { type: "newChat" }
|
|
||||||
| { type: "creating"; prompt: string }
|
|
||||||
| { type: "chat"; sessionId: string; initialPrompt?: string };
|
|
||||||
|
|
||||||
export function getInitialPromptFromState(
|
|
||||||
pageState: PageState,
|
|
||||||
storedInitialPrompt: string | undefined,
|
|
||||||
) {
|
|
||||||
if (storedInitialPrompt) return storedInitialPrompt;
|
|
||||||
if (pageState.type === "creating") return pageState.prompt;
|
|
||||||
if (pageState.type === "chat") return pageState.initialPrompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldResetToWelcome(pageState: PageState) {
|
|
||||||
return (
|
|
||||||
pageState.type !== "newChat" &&
|
|
||||||
pageState.type !== "creating" &&
|
|
||||||
pageState.type !== "welcome"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGreetingName(user?: User | null): string {
|
export function getGreetingName(user?: User | null): string {
|
||||||
if (!user) return "there";
|
if (!user) return "there";
|
||||||
const metadata = user.user_metadata as Record<string, unknown> | undefined;
|
const metadata = user.user_metadata as Record<string, unknown> | undefined;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { NewChatProvider } from "./NewChatContext";
|
|
||||||
import { CopilotShell } from "./components/CopilotShell/CopilotShell";
|
import { CopilotShell } from "./components/CopilotShell/CopilotShell";
|
||||||
|
|
||||||
export default function CopilotLayout({ children }: { children: ReactNode }) {
|
export default function CopilotLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return <CopilotShell>{children}</CopilotShell>;
|
||||||
<NewChatProvider>
|
|
||||||
<CopilotShell>{children}</CopilotShell>
|
|
||||||
</NewChatProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,142 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { Chat } from "@/components/contextual/Chat/Chat";
|
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
import { getHomepageRoute } from "@/lib/constants";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { useCopilotPage } from "./useCopilotPage";
|
import {
|
||||||
|
Flag,
|
||||||
|
type FlagValues,
|
||||||
|
useGetFlag,
|
||||||
|
} from "@/services/feature-flags/use-get-flag";
|
||||||
|
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { getGreetingName, getQuickActions } from "./helpers";
|
||||||
|
|
||||||
|
type PageState =
|
||||||
|
| { type: "welcome" }
|
||||||
|
| { type: "creating"; prompt: string }
|
||||||
|
| { type: "chat"; sessionId: string; initialPrompt?: string };
|
||||||
|
|
||||||
export default function CopilotPage() {
|
export default function CopilotPage() {
|
||||||
const { state, handlers } = useCopilotPage();
|
const router = useRouter();
|
||||||
const {
|
const searchParams = useSearchParams();
|
||||||
greetingName,
|
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
||||||
quickActions,
|
|
||||||
isLoading,
|
|
||||||
pageState,
|
|
||||||
isNewChatModalOpen,
|
|
||||||
isReady,
|
|
||||||
} = state;
|
|
||||||
const {
|
|
||||||
handleQuickAction,
|
|
||||||
startChatWithPrompt,
|
|
||||||
handleSessionNotFound,
|
|
||||||
handleStreamingChange,
|
|
||||||
handleCancelNewChat,
|
|
||||||
proceedWithNewChat,
|
|
||||||
handleNewChatModalOpen,
|
|
||||||
} = handlers;
|
|
||||||
|
|
||||||
if (!isReady) {
|
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||||
|
const flags = useFlags<FlagValues>();
|
||||||
|
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||||
|
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||||
|
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||||
|
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||||
|
const isFlagReady =
|
||||||
|
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||||
|
|
||||||
|
const [pageState, setPageState] = useState<PageState>({ type: "welcome" });
|
||||||
|
const initialPromptRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
const urlSessionId = searchParams.get("sessionId");
|
||||||
|
|
||||||
|
// Sync with URL sessionId (preserve initialPrompt from ref)
|
||||||
|
useEffect(
|
||||||
|
function syncSessionFromUrl() {
|
||||||
|
if (urlSessionId) {
|
||||||
|
// If we're already in chat state with this sessionId, don't overwrite
|
||||||
|
if (pageState.type === "chat" && pageState.sessionId === urlSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get initialPrompt from ref or current state
|
||||||
|
const storedInitialPrompt = initialPromptRef.current.get(urlSessionId);
|
||||||
|
const currentInitialPrompt =
|
||||||
|
storedInitialPrompt ||
|
||||||
|
(pageState.type === "creating"
|
||||||
|
? pageState.prompt
|
||||||
|
: pageState.type === "chat"
|
||||||
|
? pageState.initialPrompt
|
||||||
|
: undefined);
|
||||||
|
if (currentInitialPrompt) {
|
||||||
|
initialPromptRef.current.set(urlSessionId, currentInitialPrompt);
|
||||||
|
}
|
||||||
|
setPageState({
|
||||||
|
type: "chat",
|
||||||
|
sessionId: urlSessionId,
|
||||||
|
initialPrompt: currentInitialPrompt,
|
||||||
|
});
|
||||||
|
} else if (pageState.type === "chat") {
|
||||||
|
setPageState({ type: "welcome" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[urlSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function ensureAccess() {
|
||||||
|
if (!isFlagReady) return;
|
||||||
|
if (isChatEnabled === false) {
|
||||||
|
router.replace(homepageRoute);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[homepageRoute, isChatEnabled, isFlagReady, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const greetingName = useMemo(
|
||||||
|
function getName() {
|
||||||
|
return getGreetingName(user);
|
||||||
|
},
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const quickActions = useMemo(function getActions() {
|
||||||
|
return getQuickActions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function startChatWithPrompt(prompt: string) {
|
||||||
|
if (!prompt?.trim()) return;
|
||||||
|
if (pageState.type === "creating") return;
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim();
|
||||||
|
setPageState({ type: "creating", prompt: trimmedPrompt });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create session
|
||||||
|
const sessionResponse = await postV2CreateSession({
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
|
||||||
|
throw new Error("Failed to create session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = sessionResponse.data.id;
|
||||||
|
|
||||||
|
// Store initialPrompt in ref so it persists across re-renders
|
||||||
|
initialPromptRef.current.set(sessionId, trimmedPrompt);
|
||||||
|
|
||||||
|
// Update URL and show Chat with initial prompt
|
||||||
|
// Chat will handle sending the message and streaming
|
||||||
|
window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`);
|
||||||
|
setPageState({ type: "chat", sessionId, initialPrompt: trimmedPrompt });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CopilotPage] Failed to start chat:", error);
|
||||||
|
setPageState({ type: "welcome" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuickAction(action: string) {
|
||||||
|
startChatWithPrompt(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSessionNotFound() {
|
||||||
|
router.replace("/copilot");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFlagReady || isChatEnabled === false || !isLoggedIn) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,55 +150,7 @@ export default function CopilotPage() {
|
|||||||
urlSessionId={pageState.sessionId}
|
urlSessionId={pageState.sessionId}
|
||||||
initialPrompt={pageState.initialPrompt}
|
initialPrompt={pageState.initialPrompt}
|
||||||
onSessionNotFound={handleSessionNotFound}
|
onSessionNotFound={handleSessionNotFound}
|
||||||
onStreamingChange={handleStreamingChange}
|
|
||||||
/>
|
/>
|
||||||
<Dialog
|
|
||||||
title="Interrupt current chat?"
|
|
||||||
styling={{ maxWidth: 300, width: "100%" }}
|
|
||||||
controlled={{
|
|
||||||
isOpen: isNewChatModalOpen,
|
|
||||||
set: handleNewChatModalOpen,
|
|
||||||
}}
|
|
||||||
onClose={handleCancelNewChat}
|
|
||||||
>
|
|
||||||
<Dialog.Content>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Text variant="body">
|
|
||||||
The current chat response will be interrupted. Are you sure you
|
|
||||||
want to start a new chat?
|
|
||||||
</Text>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancelNewChat}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
onClick={proceedWithNewChat}
|
|
||||||
>
|
|
||||||
Start new chat
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageState.type === "newChat") {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9]">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<ChatLoader />
|
|
||||||
<Text variant="body" className="text-zinc-500">
|
|
||||||
Loading your chats...
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,18 +158,18 @@ export default function CopilotPage() {
|
|||||||
// Show loading state while creating session and sending first message
|
// Show loading state while creating session and sending first message
|
||||||
if (pageState.type === "creating") {
|
if (pageState.type === "creating") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9]">
|
<div className="flex h-full flex-1 flex-col items-center justify-center bg-[#f8f8f9] px-6 py-10">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<LoadingSpinner size="large" />
|
||||||
<ChatLoader />
|
<Text variant="body" className="mt-4 text-zinc-500">
|
||||||
<Text variant="body" className="text-zinc-500">
|
Starting your chat...
|
||||||
Loading your chats...
|
</Text>
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show Welcome screen
|
// Show Welcome screen
|
||||||
|
const isLoading = isUserLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
||||||
<div className="w-full text-center">
|
<div className="w-full text-center">
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import { getHomepageRoute } from "@/lib/constants";
|
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
|
||||||
import {
|
|
||||||
Flag,
|
|
||||||
type FlagValues,
|
|
||||||
useGetFlag,
|
|
||||||
} from "@/services/feature-flags/use-get-flag";
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useReducer } from "react";
|
|
||||||
import { useNewChat } from "./NewChatContext";
|
|
||||||
import { getGreetingName, getQuickActions, type PageState } from "./helpers";
|
|
||||||
import { useCopilotURLState } from "./useCopilotURLState";
|
|
||||||
|
|
||||||
type CopilotState = {
|
|
||||||
pageState: PageState;
|
|
||||||
isStreaming: boolean;
|
|
||||||
isNewChatModalOpen: boolean;
|
|
||||||
initialPrompts: Record<string, string>;
|
|
||||||
previousSessionId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CopilotAction =
|
|
||||||
| { type: "setPageState"; pageState: PageState }
|
|
||||||
| { type: "setStreaming"; isStreaming: boolean }
|
|
||||||
| { type: "setNewChatModalOpen"; isOpen: boolean }
|
|
||||||
| { type: "setInitialPrompt"; sessionId: string; prompt: string }
|
|
||||||
| { type: "setPreviousSessionId"; sessionId: string | null };
|
|
||||||
|
|
||||||
function isSamePageState(next: PageState, current: PageState) {
|
|
||||||
if (next.type !== current.type) return false;
|
|
||||||
if (next.type === "creating" && current.type === "creating") {
|
|
||||||
return next.prompt === current.prompt;
|
|
||||||
}
|
|
||||||
if (next.type === "chat" && current.type === "chat") {
|
|
||||||
return (
|
|
||||||
next.sessionId === current.sessionId &&
|
|
||||||
next.initialPrompt === current.initialPrompt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function copilotReducer(
|
|
||||||
state: CopilotState,
|
|
||||||
action: CopilotAction,
|
|
||||||
): CopilotState {
|
|
||||||
if (action.type === "setPageState") {
|
|
||||||
if (isSamePageState(action.pageState, state.pageState)) return state;
|
|
||||||
return { ...state, pageState: action.pageState };
|
|
||||||
}
|
|
||||||
if (action.type === "setStreaming") {
|
|
||||||
if (action.isStreaming === state.isStreaming) return state;
|
|
||||||
return { ...state, isStreaming: action.isStreaming };
|
|
||||||
}
|
|
||||||
if (action.type === "setNewChatModalOpen") {
|
|
||||||
if (action.isOpen === state.isNewChatModalOpen) return state;
|
|
||||||
return { ...state, isNewChatModalOpen: action.isOpen };
|
|
||||||
}
|
|
||||||
if (action.type === "setInitialPrompt") {
|
|
||||||
if (state.initialPrompts[action.sessionId] === action.prompt) return state;
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
initialPrompts: {
|
|
||||||
...state.initialPrompts,
|
|
||||||
[action.sessionId]: action.prompt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action.type === "setPreviousSessionId") {
|
|
||||||
if (state.previousSessionId === action.sessionId) return state;
|
|
||||||
return { ...state, previousSessionId: action.sessionId };
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCopilotPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
|
||||||
const flags = useFlags<FlagValues>();
|
|
||||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
|
||||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
|
||||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
|
||||||
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
|
||||||
const isFlagReady =
|
|
||||||
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(copilotReducer, {
|
|
||||||
pageState: { type: "welcome" },
|
|
||||||
isStreaming: false,
|
|
||||||
isNewChatModalOpen: false,
|
|
||||||
initialPrompts: {},
|
|
||||||
previousSessionId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newChatContext = useNewChat();
|
|
||||||
const greetingName = getGreetingName(user);
|
|
||||||
const quickActions = getQuickActions();
|
|
||||||
|
|
||||||
function setPageState(pageState: PageState) {
|
|
||||||
dispatch({ type: "setPageState", pageState });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setInitialPrompt(sessionId: string, prompt: string) {
|
|
||||||
dispatch({ type: "setInitialPrompt", sessionId, prompt });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPreviousSessionId(sessionId: string | null) {
|
|
||||||
dispatch({ type: "setPreviousSessionId", sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { setUrlSessionId } = useCopilotURLState({
|
|
||||||
pageState: state.pageState,
|
|
||||||
initialPrompts: state.initialPrompts,
|
|
||||||
previousSessionId: state.previousSessionId,
|
|
||||||
setPageState,
|
|
||||||
setInitialPrompt,
|
|
||||||
setPreviousSessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function registerNewChatHandler() {
|
|
||||||
if (!newChatContext) return;
|
|
||||||
newChatContext.setOnNewChatClick(handleNewChatClick);
|
|
||||||
return function cleanup() {
|
|
||||||
newChatContext.setOnNewChatClick(undefined);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[newChatContext, handleNewChatClick],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function transitionNewChatToWelcome() {
|
|
||||||
if (state.pageState.type === "newChat") {
|
|
||||||
function setWelcomeState() {
|
|
||||||
dispatch({ type: "setPageState", pageState: { type: "welcome" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(setWelcomeState, 300);
|
|
||||||
|
|
||||||
return function cleanup() {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.pageState.type],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function ensureAccess() {
|
|
||||||
if (!isFlagReady) return;
|
|
||||||
if (isChatEnabled === false) {
|
|
||||||
router.replace(homepageRoute);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[homepageRoute, isChatEnabled, isFlagReady, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function startChatWithPrompt(prompt: string) {
|
|
||||||
if (!prompt?.trim()) return;
|
|
||||||
if (state.pageState.type === "creating") return;
|
|
||||||
|
|
||||||
const trimmedPrompt = prompt.trim();
|
|
||||||
dispatch({
|
|
||||||
type: "setPageState",
|
|
||||||
pageState: { type: "creating", prompt: trimmedPrompt },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionResponse = await postV2CreateSession({
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
|
|
||||||
throw new Error("Failed to create session");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = sessionResponse.data.id;
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "setInitialPrompt",
|
|
||||||
sessionId,
|
|
||||||
prompt: trimmedPrompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await setUrlSessionId(sessionId, { shallow: false });
|
|
||||||
dispatch({
|
|
||||||
type: "setPageState",
|
|
||||||
pageState: { type: "chat", sessionId, initialPrompt: trimmedPrompt },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CopilotPage] Failed to start chat:", error);
|
|
||||||
toast({ title: "Failed to start chat", variant: "destructive" });
|
|
||||||
Sentry.captureException(error);
|
|
||||||
dispatch({ type: "setPageState", pageState: { type: "welcome" } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleQuickAction(action: string) {
|
|
||||||
startChatWithPrompt(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSessionNotFound() {
|
|
||||||
router.replace("/copilot");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStreamingChange(isStreamingValue: boolean) {
|
|
||||||
dispatch({ type: "setStreaming", isStreaming: isStreamingValue });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function proceedWithNewChat() {
|
|
||||||
dispatch({ type: "setNewChatModalOpen", isOpen: false });
|
|
||||||
if (newChatContext?.performNewChat) {
|
|
||||||
newChatContext.performNewChat();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await setUrlSessionId(null, { shallow: false });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CopilotPage] Failed to clear session:", error);
|
|
||||||
}
|
|
||||||
router.replace("/copilot");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancelNewChat() {
|
|
||||||
dispatch({ type: "setNewChatModalOpen", isOpen: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNewChatModalOpen(isOpen: boolean) {
|
|
||||||
dispatch({ type: "setNewChatModalOpen", isOpen });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNewChatClick() {
|
|
||||||
if (state.isStreaming) {
|
|
||||||
dispatch({ type: "setNewChatModalOpen", isOpen: true });
|
|
||||||
} else {
|
|
||||||
proceedWithNewChat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
greetingName,
|
|
||||||
quickActions,
|
|
||||||
isLoading: isUserLoading,
|
|
||||||
pageState: state.pageState,
|
|
||||||
isNewChatModalOpen: state.isNewChatModalOpen,
|
|
||||||
isReady: isFlagReady && isChatEnabled !== false && isLoggedIn,
|
|
||||||
},
|
|
||||||
handlers: {
|
|
||||||
handleQuickAction,
|
|
||||||
startChatWithPrompt,
|
|
||||||
handleSessionNotFound,
|
|
||||||
handleStreamingChange,
|
|
||||||
handleCancelNewChat,
|
|
||||||
proceedWithNewChat,
|
|
||||||
handleNewChatModalOpen,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { parseAsString, useQueryState } from "nuqs";
|
|
||||||
import { useLayoutEffect } from "react";
|
|
||||||
import {
|
|
||||||
getInitialPromptFromState,
|
|
||||||
type PageState,
|
|
||||||
shouldResetToWelcome,
|
|
||||||
} from "./helpers";
|
|
||||||
|
|
||||||
interface UseCopilotUrlStateArgs {
|
|
||||||
pageState: PageState;
|
|
||||||
initialPrompts: Record<string, string>;
|
|
||||||
previousSessionId: string | null;
|
|
||||||
setPageState: (pageState: PageState) => void;
|
|
||||||
setInitialPrompt: (sessionId: string, prompt: string) => void;
|
|
||||||
setPreviousSessionId: (sessionId: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCopilotURLState({
|
|
||||||
pageState,
|
|
||||||
initialPrompts,
|
|
||||||
previousSessionId,
|
|
||||||
setPageState,
|
|
||||||
setInitialPrompt,
|
|
||||||
setPreviousSessionId,
|
|
||||||
}: UseCopilotUrlStateArgs) {
|
|
||||||
const [urlSessionId, setUrlSessionId] = useQueryState(
|
|
||||||
"sessionId",
|
|
||||||
parseAsString,
|
|
||||||
);
|
|
||||||
|
|
||||||
function syncSessionFromUrl() {
|
|
||||||
if (urlSessionId) {
|
|
||||||
if (pageState.type === "chat" && pageState.sessionId === urlSessionId) {
|
|
||||||
setPreviousSessionId(urlSessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedInitialPrompt = initialPrompts[urlSessionId];
|
|
||||||
const currentInitialPrompt = getInitialPromptFromState(
|
|
||||||
pageState,
|
|
||||||
storedInitialPrompt,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentInitialPrompt) {
|
|
||||||
setInitialPrompt(urlSessionId, currentInitialPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPageState({
|
|
||||||
type: "chat",
|
|
||||||
sessionId: urlSessionId,
|
|
||||||
initialPrompt: currentInitialPrompt,
|
|
||||||
});
|
|
||||||
setPreviousSessionId(urlSessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasInChat = previousSessionId !== null && pageState.type === "chat";
|
|
||||||
setPreviousSessionId(null);
|
|
||||||
if (wasInChat) {
|
|
||||||
setPageState({ type: "newChat" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldResetToWelcome(pageState)) {
|
|
||||||
setPageState({ type: "welcome" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useLayoutEffect(syncSessionFromUrl, [
|
|
||||||
urlSessionId,
|
|
||||||
pageState.type,
|
|
||||||
previousSessionId,
|
|
||||||
initialPrompts,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
urlSessionId,
|
|
||||||
setUrlSessionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { expect, test } from "vitest";
|
|
||||||
import { render, screen } from "@/tests/integrations/test-utils";
|
|
||||||
import { MainMarkeplacePage } from "../MainMarketplacePage";
|
|
||||||
import { server } from "@/mocks/mock-server";
|
|
||||||
import { getDeleteV2DeleteStoreSubmissionMockHandler422 } from "@/app/api/__generated__/endpoints/store/store.msw";
|
|
||||||
|
|
||||||
// Only for CI testing purpose, will remove it in future PR
|
|
||||||
test("MainMarketplacePage", async () => {
|
|
||||||
server.use(getDeleteV2DeleteStoreSubmissionMockHandler422());
|
|
||||||
|
|
||||||
render(<MainMarkeplacePage />);
|
|
||||||
expect(
|
|
||||||
await screen.findByText("Featured agents", { exact: false }),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ export interface ChatProps {
|
|||||||
urlSessionId?: string | null;
|
urlSessionId?: string | null;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
onSessionNotFound?: () => void;
|
onSessionNotFound?: () => void;
|
||||||
onStreamingChange?: (isStreaming: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chat({
|
export function Chat({
|
||||||
@@ -21,7 +20,6 @@ export function Chat({
|
|||||||
urlSessionId,
|
urlSessionId,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
onSessionNotFound,
|
onSessionNotFound,
|
||||||
onStreamingChange,
|
|
||||||
}: ChatProps) {
|
}: ChatProps) {
|
||||||
const hasHandledNotFoundRef = useRef(false);
|
const hasHandledNotFoundRef = useRef(false);
|
||||||
const {
|
const {
|
||||||
@@ -75,7 +73,6 @@ export function Chat({
|
|||||||
initialMessages={messages}
|
initialMessages={messages}
|
||||||
initialPrompt={initialPrompt}
|
initialPrompt={initialPrompt}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onStreamingChange={onStreamingChange}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { ChatInput } from "../ChatInput/ChatInput";
|
import { ChatInput } from "../ChatInput/ChatInput";
|
||||||
import { MessageList } from "../MessageList/MessageList";
|
import { MessageList } from "../MessageList/MessageList";
|
||||||
import { useChatContainer } from "./useChatContainer";
|
import { useChatContainer } from "./useChatContainer";
|
||||||
@@ -14,7 +13,6 @@ export interface ChatContainerProps {
|
|||||||
initialMessages: SessionDetailResponse["messages"];
|
initialMessages: SessionDetailResponse["messages"];
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onStreamingChange?: (isStreaming: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatContainer({
|
export function ChatContainer({
|
||||||
@@ -22,7 +20,6 @@ export function ChatContainer({
|
|||||||
initialMessages,
|
initialMessages,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
className,
|
className,
|
||||||
onStreamingChange,
|
|
||||||
}: ChatContainerProps) {
|
}: ChatContainerProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -39,10 +36,6 @@ export function ChatContainer({
|
|||||||
initialPrompt,
|
initialPrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onStreamingChange?.(isStreaming);
|
|
||||||
}, [isStreaming, onStreamingChange]);
|
|
||||||
|
|
||||||
const breakpoint = useBreakpoint();
|
const breakpoint = useBreakpoint();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
|
||||||
export function ChatLoader() {
|
export function ChatLoader() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Text
|
||||||
<div className="h-5 w-5 animate-loader rounded-full bg-black" />
|
variant="small"
|
||||||
</div>
|
className="bg-gradient-to-r from-neutral-600 via-neutral-500 to-neutral-600 bg-[length:200%_100%] bg-clip-text text-xs text-transparent [animation:shimmer_2s_ease-in-out_infinite]"
|
||||||
|
>
|
||||||
|
Taking a bit more time...
|
||||||
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ArrowsClockwiseIcon,
|
ArrowsClockwiseIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
@@ -339,26 +340,11 @@ export function ChatMessage({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
aria-label="Copy message"
|
aria-label="Copy message"
|
||||||
className="p-1"
|
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<CheckIcon className="size-4 text-green-600" />
|
<CheckIcon className="size-4 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<CopyIcon className="size-4 text-zinc-600" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="size-3 text-zinc-600"
|
|
||||||
>
|
|
||||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
||||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
|
import { ChatLoader } from "../ChatLoader/ChatLoader";
|
||||||
|
|
||||||
export interface ThinkingMessageProps {
|
export interface ThinkingMessageProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -8,9 +9,7 @@ export interface ThinkingMessageProps {
|
|||||||
|
|
||||||
export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||||
const [showSlowLoader, setShowSlowLoader] = useState(false);
|
const [showSlowLoader, setShowSlowLoader] = useState(false);
|
||||||
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current === null) {
|
if (timerRef.current === null) {
|
||||||
@@ -19,21 +18,11 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
|||||||
}, 8000);
|
}, 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coffeeTimerRef.current === null) {
|
|
||||||
coffeeTimerRef.current = setTimeout(() => {
|
|
||||||
setShowCoffeeMessage(true);
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
if (coffeeTimerRef.current) {
|
|
||||||
clearTimeout(coffeeTimerRef.current);
|
|
||||||
coffeeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -48,16 +37,16 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
|||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<AIChatBubble>
|
<AIChatBubble>
|
||||||
<div className="transition-all duration-500 ease-in-out">
|
<div className="transition-all duration-500 ease-in-out">
|
||||||
{showCoffeeMessage ? (
|
{showSlowLoader ? (
|
||||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
<ChatLoader />
|
||||||
This could take a few minutes, grab a coffee ☕️
|
|
||||||
</span>
|
|
||||||
) : showSlowLoader ? (
|
|
||||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
|
||||||
Taking a bit more time...
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
<span
|
||||||
|
className="inline-block bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-clip-text text-transparent"
|
||||||
|
style={{
|
||||||
|
backgroundSize: "200% 100%",
|
||||||
|
animation: "shimmer 2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Thinking...
|
Thinking...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -157,21 +157,12 @@ const config = {
|
|||||||
backgroundPosition: "-200% 0",
|
backgroundPosition: "-200% 0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
loader: {
|
|
||||||
"0%": {
|
|
||||||
boxShadow: "0 0 0 0 rgba(0, 0, 0, 0.25)",
|
|
||||||
},
|
|
||||||
"100%": {
|
|
||||||
boxShadow: "0 0 0 30px rgba(0, 0, 0, 0)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
"fade-in": "fade-in 0.2s ease-out",
|
"fade-in": "fade-in 0.2s ease-out",
|
||||||
shimmer: "shimmer 2s ease-in-out infinite",
|
shimmer: "shimmer 2s ease-in-out infinite",
|
||||||
loader: "loader 1s infinite",
|
|
||||||
},
|
},
|
||||||
transitionDuration: {
|
transitionDuration: {
|
||||||
"2000": "2000ms",
|
"2000": "2000ms",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,133 +0,0 @@
|
|||||||
# Table of contents
|
|
||||||
|
|
||||||
* [AutoGPT Blocks Overview](README.md)
|
|
||||||
|
|
||||||
## Guides
|
|
||||||
|
|
||||||
* [LLM Providers](guides/llm-providers.md)
|
|
||||||
* [Voice Providers](guides/voice-providers.md)
|
|
||||||
|
|
||||||
## Block Integrations
|
|
||||||
|
|
||||||
* [Airtable Bases](block-integrations/airtable/bases.md)
|
|
||||||
* [Airtable Records](block-integrations/airtable/records.md)
|
|
||||||
* [Airtable Schema](block-integrations/airtable/schema.md)
|
|
||||||
* [Airtable Triggers](block-integrations/airtable/triggers.md)
|
|
||||||
* [Apollo Organization](block-integrations/apollo/organization.md)
|
|
||||||
* [Apollo People](block-integrations/apollo/people.md)
|
|
||||||
* [Apollo Person](block-integrations/apollo/person.md)
|
|
||||||
* [Ayrshare Post To Bluesky](block-integrations/ayrshare/post_to_bluesky.md)
|
|
||||||
* [Ayrshare Post To Facebook](block-integrations/ayrshare/post_to_facebook.md)
|
|
||||||
* [Ayrshare Post To GMB](block-integrations/ayrshare/post_to_gmb.md)
|
|
||||||
* [Ayrshare Post To Instagram](block-integrations/ayrshare/post_to_instagram.md)
|
|
||||||
* [Ayrshare Post To LinkedIn](block-integrations/ayrshare/post_to_linkedin.md)
|
|
||||||
* [Ayrshare Post To Pinterest](block-integrations/ayrshare/post_to_pinterest.md)
|
|
||||||
* [Ayrshare Post To Reddit](block-integrations/ayrshare/post_to_reddit.md)
|
|
||||||
* [Ayrshare Post To Snapchat](block-integrations/ayrshare/post_to_snapchat.md)
|
|
||||||
* [Ayrshare Post To Telegram](block-integrations/ayrshare/post_to_telegram.md)
|
|
||||||
* [Ayrshare Post To Threads](block-integrations/ayrshare/post_to_threads.md)
|
|
||||||
* [Ayrshare Post To TikTok](block-integrations/ayrshare/post_to_tiktok.md)
|
|
||||||
* [Ayrshare Post To X](block-integrations/ayrshare/post_to_x.md)
|
|
||||||
* [Ayrshare Post To YouTube](block-integrations/ayrshare/post_to_youtube.md)
|
|
||||||
* [Baas Bots](block-integrations/baas/bots.md)
|
|
||||||
* [Bannerbear Text Overlay](block-integrations/bannerbear/text_overlay.md)
|
|
||||||
* [Basic](block-integrations/basic.md)
|
|
||||||
* [Compass Triggers](block-integrations/compass/triggers.md)
|
|
||||||
* [Data](block-integrations/data.md)
|
|
||||||
* [Dataforseo Keyword Suggestions](block-integrations/dataforseo/keyword_suggestions.md)
|
|
||||||
* [Dataforseo Related Keywords](block-integrations/dataforseo/related_keywords.md)
|
|
||||||
* [Discord Bot Blocks](block-integrations/discord/bot_blocks.md)
|
|
||||||
* [Discord OAuth Blocks](block-integrations/discord/oauth_blocks.md)
|
|
||||||
* [Enrichlayer LinkedIn](block-integrations/enrichlayer/linkedin.md)
|
|
||||||
* [Exa Answers](block-integrations/exa/answers.md)
|
|
||||||
* [Exa Code Context](block-integrations/exa/code_context.md)
|
|
||||||
* [Exa Contents](block-integrations/exa/contents.md)
|
|
||||||
* [Exa Research](block-integrations/exa/research.md)
|
|
||||||
* [Exa Search](block-integrations/exa/search.md)
|
|
||||||
* [Exa Similar](block-integrations/exa/similar.md)
|
|
||||||
* [Exa Webhook Blocks](block-integrations/exa/webhook_blocks.md)
|
|
||||||
* [Exa Websets](block-integrations/exa/websets.md)
|
|
||||||
* [Exa Websets Enrichment](block-integrations/exa/websets_enrichment.md)
|
|
||||||
* [Exa Websets Import Export](block-integrations/exa/websets_import_export.md)
|
|
||||||
* [Exa Websets Items](block-integrations/exa/websets_items.md)
|
|
||||||
* [Exa Websets Monitor](block-integrations/exa/websets_monitor.md)
|
|
||||||
* [Exa Websets Polling](block-integrations/exa/websets_polling.md)
|
|
||||||
* [Exa Websets Search](block-integrations/exa/websets_search.md)
|
|
||||||
* [Fal AI Video Generator](block-integrations/fal/ai_video_generator.md)
|
|
||||||
* [Firecrawl Crawl](block-integrations/firecrawl/crawl.md)
|
|
||||||
* [Firecrawl Extract](block-integrations/firecrawl/extract.md)
|
|
||||||
* [Firecrawl Map](block-integrations/firecrawl/map.md)
|
|
||||||
* [Firecrawl Scrape](block-integrations/firecrawl/scrape.md)
|
|
||||||
* [Firecrawl Search](block-integrations/firecrawl/search.md)
|
|
||||||
* [Generic Webhook Triggers](block-integrations/generic_webhook/triggers.md)
|
|
||||||
* [GitHub Checks](block-integrations/github/checks.md)
|
|
||||||
* [GitHub CI](block-integrations/github/ci.md)
|
|
||||||
* [GitHub Issues](block-integrations/github/issues.md)
|
|
||||||
* [GitHub Pull Requests](block-integrations/github/pull_requests.md)
|
|
||||||
* [GitHub Repo](block-integrations/github/repo.md)
|
|
||||||
* [GitHub Reviews](block-integrations/github/reviews.md)
|
|
||||||
* [GitHub Statuses](block-integrations/github/statuses.md)
|
|
||||||
* [GitHub Triggers](block-integrations/github/triggers.md)
|
|
||||||
* [Google Calendar](block-integrations/google/calendar.md)
|
|
||||||
* [Google Docs](block-integrations/google/docs.md)
|
|
||||||
* [Google Gmail](block-integrations/google/gmail.md)
|
|
||||||
* [Google Sheets](block-integrations/google/sheets.md)
|
|
||||||
* [HubSpot Company](block-integrations/hubspot/company.md)
|
|
||||||
* [HubSpot Contact](block-integrations/hubspot/contact.md)
|
|
||||||
* [HubSpot Engagement](block-integrations/hubspot/engagement.md)
|
|
||||||
* [Jina Chunking](block-integrations/jina/chunking.md)
|
|
||||||
* [Jina Embeddings](block-integrations/jina/embeddings.md)
|
|
||||||
* [Jina Fact Checker](block-integrations/jina/fact_checker.md)
|
|
||||||
* [Jina Search](block-integrations/jina/search.md)
|
|
||||||
* [Linear Comment](block-integrations/linear/comment.md)
|
|
||||||
* [Linear Issues](block-integrations/linear/issues.md)
|
|
||||||
* [Linear Projects](block-integrations/linear/projects.md)
|
|
||||||
* [LLM](block-integrations/llm.md)
|
|
||||||
* [Logic](block-integrations/logic.md)
|
|
||||||
* [Misc](block-integrations/misc.md)
|
|
||||||
* [Multimedia](block-integrations/multimedia.md)
|
|
||||||
* [Notion Create Page](block-integrations/notion/create_page.md)
|
|
||||||
* [Notion Read Database](block-integrations/notion/read_database.md)
|
|
||||||
* [Notion Read Page](block-integrations/notion/read_page.md)
|
|
||||||
* [Notion Read Page Markdown](block-integrations/notion/read_page_markdown.md)
|
|
||||||
* [Notion Search](block-integrations/notion/search.md)
|
|
||||||
* [Nvidia Deepfake](block-integrations/nvidia/deepfake.md)
|
|
||||||
* [Replicate Flux Advanced](block-integrations/replicate/flux_advanced.md)
|
|
||||||
* [Replicate Replicate Block](block-integrations/replicate/replicate_block.md)
|
|
||||||
* [Search](block-integrations/search.md)
|
|
||||||
* [Slant3D Filament](block-integrations/slant3d/filament.md)
|
|
||||||
* [Slant3D Order](block-integrations/slant3d/order.md)
|
|
||||||
* [Slant3D Slicing](block-integrations/slant3d/slicing.md)
|
|
||||||
* [Slant3D Webhook](block-integrations/slant3d/webhook.md)
|
|
||||||
* [Smartlead Campaign](block-integrations/smartlead/campaign.md)
|
|
||||||
* [Stagehand Blocks](block-integrations/stagehand/blocks.md)
|
|
||||||
* [System Library Operations](block-integrations/system/library_operations.md)
|
|
||||||
* [System Store Operations](block-integrations/system/store_operations.md)
|
|
||||||
* [Text](block-integrations/text.md)
|
|
||||||
* [Todoist Comments](block-integrations/todoist/comments.md)
|
|
||||||
* [Todoist Labels](block-integrations/todoist/labels.md)
|
|
||||||
* [Todoist Projects](block-integrations/todoist/projects.md)
|
|
||||||
* [Todoist Sections](block-integrations/todoist/sections.md)
|
|
||||||
* [Todoist Tasks](block-integrations/todoist/tasks.md)
|
|
||||||
* [Twitter Blocks](block-integrations/twitter/blocks.md)
|
|
||||||
* [Twitter Bookmark](block-integrations/twitter/bookmark.md)
|
|
||||||
* [Twitter Follows](block-integrations/twitter/follows.md)
|
|
||||||
* [Twitter Hide](block-integrations/twitter/hide.md)
|
|
||||||
* [Twitter Like](block-integrations/twitter/like.md)
|
|
||||||
* [Twitter List Follows](block-integrations/twitter/list_follows.md)
|
|
||||||
* [Twitter List Lookup](block-integrations/twitter/list_lookup.md)
|
|
||||||
* [Twitter List Members](block-integrations/twitter/list_members.md)
|
|
||||||
* [Twitter List Tweets Lookup](block-integrations/twitter/list_tweets_lookup.md)
|
|
||||||
* [Twitter Manage](block-integrations/twitter/manage.md)
|
|
||||||
* [Twitter Manage Lists](block-integrations/twitter/manage_lists.md)
|
|
||||||
* [Twitter Mutes](block-integrations/twitter/mutes.md)
|
|
||||||
* [Twitter Pinned Lists](block-integrations/twitter/pinned_lists.md)
|
|
||||||
* [Twitter Quote](block-integrations/twitter/quote.md)
|
|
||||||
* [Twitter Retweet](block-integrations/twitter/retweet.md)
|
|
||||||
* [Twitter Search Spaces](block-integrations/twitter/search_spaces.md)
|
|
||||||
* [Twitter Spaces Lookup](block-integrations/twitter/spaces_lookup.md)
|
|
||||||
* [Twitter Timeline](block-integrations/twitter/timeline.md)
|
|
||||||
* [Twitter Tweet Lookup](block-integrations/twitter/tweet_lookup.md)
|
|
||||||
* [Twitter User Lookup](block-integrations/twitter/user_lookup.md)
|
|
||||||
* [Wolfram LLM API](block-integrations/wolfram/llm_api.md)
|
|
||||||
* [Zerobounce Validate Emails](block-integrations/zerobounce/validate_emails.md)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Claude Code Execution
|
|
||||||
|
|
||||||
## What it is
|
|
||||||
The Claude Code block executes complex coding tasks using Anthropic's Claude Code AI assistant in a secure E2B sandbox environment.
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
This block allows you to delegate coding tasks to Claude Code, which can autonomously create files, install packages, run commands, and build complete applications within a sandboxed environment. Claude Code can handle multi-step development tasks and maintain conversation context across multiple turns.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
When activated, the block:
|
|
||||||
1. Creates or connects to an E2B sandbox (a secure, isolated Linux environment)
|
|
||||||
2. Installs the latest version of Claude Code in the sandbox
|
|
||||||
3. Optionally runs setup commands to prepare the environment
|
|
||||||
4. Executes your prompt using Claude Code, which can:
|
|
||||||
- Create and edit files
|
|
||||||
- Install dependencies (npm, pip, etc.)
|
|
||||||
- Run terminal commands
|
|
||||||
- Build and test applications
|
|
||||||
5. Extracts all text files created/modified during execution
|
|
||||||
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
|
|
||||||
|
|
||||||
The block supports conversation continuation through three mechanisms:
|
|
||||||
- **Same sandbox continuation** (via `session_id` + `sandbox_id`): Resume on the same live sandbox
|
|
||||||
- **Fresh sandbox continuation** (via `conversation_history`): Restore context on a new sandbox if the previous one timed out
|
|
||||||
- **Dispose control** (`dispose_sandbox` flag): Keep sandbox alive for multi-turn conversations
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
| Input | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| E2B Credentials | API key for the E2B platform to create the sandbox. Get one at [e2b.dev](https://e2b.dev/docs) |
|
|
||||||
| Anthropic Credentials | API key for Anthropic to power Claude Code. Get one at [Anthropic's website](https://console.anthropic.com) |
|
|
||||||
| Prompt | The task or instruction for Claude Code to execute. Claude Code can create files, install packages, run commands, and perform complex coding tasks |
|
|
||||||
| Timeout | Sandbox timeout in seconds (default: 300). Set higher for complex tasks. Note: Only applies when creating a new sandbox |
|
|
||||||
| Setup Commands | Optional shell commands to run before executing Claude Code (e.g., installing dependencies) |
|
|
||||||
| Working Directory | Working directory for Claude Code to operate in (default: /home/user) |
|
|
||||||
| Session ID | Session ID to resume a previous conversation. Leave empty for new conversations |
|
|
||||||
| Sandbox ID | Sandbox ID to reconnect to an existing sandbox. Required when resuming a session |
|
|
||||||
| Conversation History | Previous conversation history to restore context on a fresh sandbox if the previous one timed out |
|
|
||||||
| Dispose Sandbox | Whether to dispose of the sandbox after execution (default: true). Set to false to continue conversations later |
|
|
||||||
|
|
||||||
## Outputs
|
|
||||||
| Output | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| Response | The output/response from Claude Code execution |
|
|
||||||
| Files | List of text files created/modified during execution. Each file includes path, relative_path, name, and content fields |
|
|
||||||
| Conversation History | Full conversation history including this turn. Use to restore context on a fresh sandbox |
|
|
||||||
| Session ID | Session ID for this conversation. Pass back with sandbox_id to continue the conversation |
|
|
||||||
| Sandbox ID | ID of the sandbox instance (null if disposed). Pass back with session_id to continue the conversation |
|
|
||||||
| Error | Error message if execution failed |
|
|
||||||
|
|
||||||
## Possible use case
|
|
||||||
**API Documentation to Full Application:**
|
|
||||||
A product team wants to quickly prototype applications based on API documentation. They create an agent that:
|
|
||||||
1. Uses Firecrawl to fetch API documentation from a URL
|
|
||||||
2. Passes the docs to Claude Code with a prompt like "Create a web app that demonstrates all the key features of this API"
|
|
||||||
3. Claude Code builds a complete application with HTML/CSS/JS frontend, proper error handling, and example API calls
|
|
||||||
4. The Files output is used with GitHub blocks to push the generated code to a new repository
|
|
||||||
|
|
||||||
The team can then iterate on the application by passing the sandbox_id and session_id back to Claude Code with refinement requests like "Add authentication" or "Improve the UI", and Claude Code will modify the existing files in the same sandbox.
|
|
||||||
|
|
||||||
**Multi-turn Development:**
|
|
||||||
A developer uses Claude Code to scaffold a new project:
|
|
||||||
- Turn 1: "Create a Python FastAPI project with user authentication" (dispose_sandbox=false)
|
|
||||||
- Turn 2: Uses the returned session_id + sandbox_id to ask "Add rate limiting middleware"
|
|
||||||
- Turn 3: Continues with "Add comprehensive tests"
|
|
||||||
|
|
||||||
Each turn builds on the previous work in the same sandbox environment.
|
|
||||||