mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-19 20:18:22 -05:00
Compare commits
27 Commits
make-old-w
...
fix/librar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfdd23d8f8 | ||
|
|
fc25e008b3 | ||
|
|
b0855e8cf2 | ||
|
|
5e2146dd76 | ||
|
|
103a62c9da | ||
|
|
fc8434fb30 | ||
|
|
3ae08cd48e | ||
|
|
4db13837b9 | ||
|
|
df87867625 | ||
|
|
e503126170 | ||
|
|
7ee28197a3 | ||
|
|
818de26d24 | ||
|
|
cb08def96c | ||
|
|
ac2daee5f8 | ||
|
|
266e0d79d4 | ||
|
|
01f443190e | ||
|
|
bdba0033de | ||
|
|
b87c64ce38 | ||
|
|
003affca43 | ||
|
|
290d0d9a9b | ||
|
|
fba61c72ed | ||
|
|
79d45a15d0 | ||
|
|
66f0d97ca2 | ||
|
|
5894a8fcdf | ||
|
|
dff8efa35d | ||
|
|
e26822998f | ||
|
|
4a7bc006a8 |
@@ -119,12 +119,58 @@ async def list_library_agents(
|
||||
f"Retrieved {len(library_agents)} library agents for user #{user_id}"
|
||||
)
|
||||
|
||||
# Batch fetch StoreListings for all agents' graphs
|
||||
graph_ids = {agent.agentGraphId for agent in library_agents if agent.AgentGraph}
|
||||
store_listings_map: dict[str, prisma.models.StoreListing] = {}
|
||||
profiles_map: dict[str, prisma.models.Profile] = {}
|
||||
|
||||
if graph_ids:
|
||||
store_listings = await prisma.models.StoreListing.prisma().find_many(
|
||||
where={
|
||||
"agentGraphId": {"in": list(graph_ids)},
|
||||
"isDeleted": False,
|
||||
"hasApprovedVersion": True,
|
||||
},
|
||||
include={"ActiveVersion": True},
|
||||
)
|
||||
|
||||
# Build map of graph_id -> StoreListing
|
||||
for listing in store_listings:
|
||||
if listing.agentGraphId:
|
||||
store_listings_map[listing.agentGraphId] = listing
|
||||
|
||||
# Fetch profiles for store listing owners
|
||||
owning_user_ids = {
|
||||
listing.owningUserId
|
||||
for listing in store_listings_map.values()
|
||||
if listing.owningUserId
|
||||
}
|
||||
if owning_user_ids:
|
||||
profiles = await prisma.models.Profile.prisma().find_many(
|
||||
where={"userId": {"in": list(owning_user_ids)}}
|
||||
)
|
||||
profiles_map = {profile.userId: profile for profile in profiles}
|
||||
|
||||
# Only pass valid agents to the response
|
||||
valid_library_agents: list[library_model.LibraryAgent] = []
|
||||
|
||||
for agent in library_agents:
|
||||
try:
|
||||
library_agent = library_model.LibraryAgent.from_db(agent)
|
||||
# Get store listing and profile for this agent's graph
|
||||
store_listing = None
|
||||
profile = None
|
||||
if agent.AgentGraph and agent.agentGraphId in store_listings_map:
|
||||
store_listing = store_listings_map[agent.agentGraphId]
|
||||
if (
|
||||
store_listing
|
||||
and store_listing.owningUserId
|
||||
and store_listing.owningUserId in profiles_map
|
||||
):
|
||||
profile = profiles_map[store_listing.owningUserId]
|
||||
|
||||
library_agent = library_model.LibraryAgent.from_db(
|
||||
agent, store_listing=store_listing, profile=profile
|
||||
)
|
||||
valid_library_agents.append(library_agent)
|
||||
except Exception as e:
|
||||
# Skip this agent if there was an error
|
||||
@@ -205,12 +251,58 @@ async def list_favorite_library_agents(
|
||||
f"Retrieved {len(library_agents)} favorite library agents for user #{user_id}"
|
||||
)
|
||||
|
||||
# Batch fetch StoreListings for all agents' graphs
|
||||
graph_ids = {agent.agentGraphId for agent in library_agents if agent.AgentGraph}
|
||||
store_listings_map: dict[str, prisma.models.StoreListing] = {}
|
||||
profiles_map: dict[str, prisma.models.Profile] = {}
|
||||
|
||||
if graph_ids:
|
||||
store_listings = await prisma.models.StoreListing.prisma().find_many(
|
||||
where={
|
||||
"agentGraphId": {"in": list(graph_ids)},
|
||||
"isDeleted": False,
|
||||
"hasApprovedVersion": True,
|
||||
},
|
||||
include={"ActiveVersion": True},
|
||||
)
|
||||
|
||||
# Build map of graph_id -> StoreListing
|
||||
for listing in store_listings:
|
||||
if listing.agentGraphId:
|
||||
store_listings_map[listing.agentGraphId] = listing
|
||||
|
||||
# Fetch profiles for store listing owners
|
||||
owning_user_ids = {
|
||||
listing.owningUserId
|
||||
for listing in store_listings_map.values()
|
||||
if listing.owningUserId
|
||||
}
|
||||
if owning_user_ids:
|
||||
profiles = await prisma.models.Profile.prisma().find_many(
|
||||
where={"userId": {"in": list(owning_user_ids)}}
|
||||
)
|
||||
profiles_map = {profile.userId: profile for profile in profiles}
|
||||
|
||||
# Only pass valid agents to the response
|
||||
valid_library_agents: list[library_model.LibraryAgent] = []
|
||||
|
||||
for agent in library_agents:
|
||||
try:
|
||||
library_agent = library_model.LibraryAgent.from_db(agent)
|
||||
# Get store listing and profile for this agent's graph
|
||||
store_listing = None
|
||||
profile = None
|
||||
if agent.AgentGraph and agent.agentGraphId in store_listings_map:
|
||||
store_listing = store_listings_map[agent.agentGraphId]
|
||||
if (
|
||||
store_listing
|
||||
and store_listing.owningUserId
|
||||
and store_listing.owningUserId in profiles_map
|
||||
):
|
||||
profile = profiles_map[store_listing.owningUserId]
|
||||
|
||||
library_agent = library_model.LibraryAgent.from_db(
|
||||
agent, store_listing=store_listing, profile=profile
|
||||
)
|
||||
valid_library_agents.append(library_agent)
|
||||
except Exception as e:
|
||||
# Skip this agent if there was an error
|
||||
|
||||
@@ -39,7 +39,7 @@ import backend.data.user
|
||||
import backend.integrations.webhooks.utils
|
||||
import backend.util.service
|
||||
import backend.util.settings
|
||||
from backend.blocks.llm import LlmModel
|
||||
from backend.blocks.llm import DEFAULT_LLM_MODEL
|
||||
from backend.data.model import Credentials
|
||||
from backend.integrations.providers import ProviderName
|
||||
from backend.monitoring.instrumentation import instrument_fastapi
|
||||
@@ -113,7 +113,7 @@ async def lifespan_context(app: fastapi.FastAPI):
|
||||
|
||||
await backend.data.user.migrate_and_encrypt_user_integrations()
|
||||
await backend.data.graph.fix_llm_provider_credentials()
|
||||
await backend.data.graph.migrate_llm_models(LlmModel.GPT4O)
|
||||
await backend.data.graph.migrate_llm_models(DEFAULT_LLM_MODEL)
|
||||
await backend.integrations.webhooks.utils.migrate_legacy_triggered_graphs()
|
||||
|
||||
with launch_darkly_context():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from backend.blocks.llm import (
|
||||
DEFAULT_LLM_MODEL,
|
||||
TEST_CREDENTIALS,
|
||||
TEST_CREDENTIALS_INPUT,
|
||||
AIBlockBase,
|
||||
@@ -49,7 +50,7 @@ class AIConditionBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=LlmModel.GPT4O,
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for evaluating the condition.",
|
||||
advanced=False,
|
||||
)
|
||||
@@ -81,7 +82,7 @@ class AIConditionBlock(AIBlockBase):
|
||||
"condition": "the input is an email address",
|
||||
"yes_value": "Valid email",
|
||||
"no_value": "Not an email",
|
||||
"model": LlmModel.GPT4O,
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
},
|
||||
test_credentials=TEST_CREDENTIALS,
|
||||
|
||||
@@ -182,13 +182,10 @@ class DataForSeoRelatedKeywordsBlock(Block):
|
||||
if results and len(results) > 0:
|
||||
# results is a list, get the first element
|
||||
first_result = results[0] if isinstance(results, list) else results
|
||||
items = (
|
||||
first_result.get("items", [])
|
||||
if isinstance(first_result, dict)
|
||||
else []
|
||||
)
|
||||
# Ensure items is never None
|
||||
if items is None:
|
||||
# Handle missing key, null value, or valid list value
|
||||
if isinstance(first_result, dict):
|
||||
items = first_result.get("items") or []
|
||||
else:
|
||||
items = []
|
||||
for item in items:
|
||||
# Extract keyword_data from the item
|
||||
|
||||
2896
autogpt_platform/backend/backend/blocks/google/docs.py
Normal file
2896
autogpt_platform/backend/backend/blocks/google/docs.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,8 +92,9 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
O1 = "o1"
|
||||
O1_MINI = "o1-mini"
|
||||
# GPT-5 models
|
||||
GPT5 = "gpt-5-2025-08-07"
|
||||
GPT5_2 = "gpt-5.2-2025-12-11"
|
||||
GPT5_1 = "gpt-5.1-2025-11-13"
|
||||
GPT5 = "gpt-5-2025-08-07"
|
||||
GPT5_MINI = "gpt-5-mini-2025-08-07"
|
||||
GPT5_NANO = "gpt-5-nano-2025-08-07"
|
||||
GPT5_CHAT = "gpt-5-chat-latest"
|
||||
@@ -194,8 +195,9 @@ MODEL_METADATA = {
|
||||
LlmModel.O1: ModelMetadata("openai", 200000, 100000), # o1-2024-12-17
|
||||
LlmModel.O1_MINI: ModelMetadata("openai", 128000, 65536), # o1-mini-2024-09-12
|
||||
# GPT-5 models
|
||||
LlmModel.GPT5: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_2: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_1: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_MINI: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_NANO: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_CHAT: ModelMetadata("openai", 400000, 16384),
|
||||
@@ -303,6 +305,8 @@ MODEL_METADATA = {
|
||||
LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000),
|
||||
}
|
||||
|
||||
DEFAULT_LLM_MODEL = LlmModel.GPT5_2
|
||||
|
||||
for model in LlmModel:
|
||||
if model not in MODEL_METADATA:
|
||||
raise ValueError(f"Missing MODEL_METADATA metadata for model: {model}")
|
||||
@@ -790,7 +794,7 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=LlmModel.GPT4O,
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for answering the prompt.",
|
||||
advanced=False,
|
||||
)
|
||||
@@ -855,7 +859,7 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
|
||||
input_schema=AIStructuredResponseGeneratorBlock.Input,
|
||||
output_schema=AIStructuredResponseGeneratorBlock.Output,
|
||||
test_input={
|
||||
"model": LlmModel.GPT4O,
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
"expected_format": {
|
||||
"key1": "value1",
|
||||
@@ -1221,7 +1225,7 @@ class AITextGeneratorBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=LlmModel.GPT4O,
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for answering the prompt.",
|
||||
advanced=False,
|
||||
)
|
||||
@@ -1317,7 +1321,7 @@ class AITextSummarizerBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=LlmModel.GPT4O,
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for summarizing the text.",
|
||||
)
|
||||
focus: str = SchemaField(
|
||||
@@ -1534,7 +1538,7 @@ class AIConversationBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=LlmModel.GPT4O,
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for the conversation.",
|
||||
)
|
||||
credentials: AICredentials = AICredentialsField()
|
||||
@@ -1572,7 +1576,7 @@ class AIConversationBlock(AIBlockBase):
|
||||
},
|
||||
{"role": "user", "content": "Where was it played?"},
|
||||
],
|
||||
"model": LlmModel.GPT4O,
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
},
|
||||
test_credentials=TEST_CREDENTIALS,
|
||||
@@ -1635,7 +1639,7 @@ class AIListGeneratorBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=LlmModel.GPT4O,
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for generating the list.",
|
||||
advanced=True,
|
||||
)
|
||||
@@ -1692,7 +1696,7 @@ class AIListGeneratorBlock(AIBlockBase):
|
||||
"drawing explorers to uncover its mysteries. Each planet showcases the limitless possibilities of "
|
||||
"fictional worlds."
|
||||
),
|
||||
"model": LlmModel.GPT4O,
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
"max_retries": 3,
|
||||
"force_json_output": False,
|
||||
|
||||
@@ -226,7 +226,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
)
|
||||
model: llm.LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=llm.LlmModel.GPT4O,
|
||||
default=llm.DEFAULT_LLM_MODEL,
|
||||
description="The language model to use for answering the prompt.",
|
||||
advanced=False,
|
||||
)
|
||||
|
||||
@@ -196,6 +196,15 @@ class TestXMLParserBlockSecurity:
|
||||
async for _ in block.run(XMLParserBlock.Input(input_xml=large_xml)):
|
||||
pass
|
||||
|
||||
async def test_rejects_text_outside_root(self):
|
||||
"""Ensure parser surfaces readable errors for invalid root text."""
|
||||
block = XMLParserBlock()
|
||||
invalid_xml = "<root><child>value</child></root> trailing"
|
||||
|
||||
with pytest.raises(ValueError, match="text outside the root element"):
|
||||
async for _ in block.run(XMLParserBlock.Input(input_xml=invalid_xml)):
|
||||
pass
|
||||
|
||||
|
||||
class TestStoreMediaFileSecurity:
|
||||
"""Test file storage security limits."""
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestLLMStatsTracking:
|
||||
|
||||
response = await llm.llm_call(
|
||||
credentials=llm.TEST_CREDENTIALS,
|
||||
llm_model=llm.LlmModel.GPT4O,
|
||||
llm_model=llm.DEFAULT_LLM_MODEL,
|
||||
prompt=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
@@ -65,7 +65,7 @@ class TestLLMStatsTracking:
|
||||
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
|
||||
prompt="Test prompt",
|
||||
expected_format={"key1": "desc1", "key2": "desc2"},
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore # type: ignore
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestLLMStatsTracking:
|
||||
# Run the block
|
||||
input_data = llm.AITextGeneratorBlock.Input(
|
||||
prompt="Generate text",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ class TestLLMStatsTracking:
|
||||
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
|
||||
prompt="Test prompt",
|
||||
expected_format={"key1": "desc1", "key2": "desc2"},
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
retry=2,
|
||||
)
|
||||
@@ -228,7 +228,7 @@ class TestLLMStatsTracking:
|
||||
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text=long_text,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_tokens=100, # Small chunks
|
||||
chunk_overlap=10,
|
||||
@@ -299,7 +299,7 @@ class TestLLMStatsTracking:
|
||||
# Test with very short text (should only need 1 chunk + 1 final summary)
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="This is a short text.",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_tokens=1000, # Large enough to avoid chunking
|
||||
)
|
||||
@@ -346,7 +346,7 @@ class TestLLMStatsTracking:
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
],
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -387,7 +387,7 @@ class TestLLMStatsTracking:
|
||||
# Run the block
|
||||
input_data = llm.AIListGeneratorBlock.Input(
|
||||
focus="test items",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_retries=3,
|
||||
)
|
||||
@@ -469,7 +469,7 @@ class TestLLMStatsTracking:
|
||||
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
|
||||
prompt="Test",
|
||||
expected_format={"result": "desc"},
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -513,7 +513,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
style=llm.SummaryStyle.BULLET_POINTS,
|
||||
)
|
||||
@@ -558,7 +558,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
style=llm.SummaryStyle.BULLET_POINTS,
|
||||
max_tokens=1000,
|
||||
@@ -593,7 +593,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -623,7 +623,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_tokens=1000,
|
||||
)
|
||||
@@ -654,7 +654,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ async def test_smart_decision_maker_tracks_llm_stats():
|
||||
# Create test input
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Should I continue with this task?",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -335,7 +335,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
retry=2, # Set retry to 2 for testing
|
||||
agent_mode_max_iterations=0,
|
||||
@@ -402,7 +402,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -462,7 +462,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -526,7 +526,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -648,7 +648,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Test prompt",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
retry=2,
|
||||
agent_mode_max_iterations=0,
|
||||
@@ -722,7 +722,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
):
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Simple prompt",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -778,7 +778,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
):
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Another test",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -931,7 +931,7 @@ async def test_smart_decision_maker_agent_mode():
|
||||
# Test agent mode with max_iterations = 3
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Complete this task using tools",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=3, # Enable agent mode with 3 max iterations
|
||||
)
|
||||
@@ -1020,7 +1020,7 @@ async def test_smart_decision_maker_traditional_mode_default():
|
||||
# Test default behavior (traditional mode)
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Test prompt",
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0, # Traditional mode
|
||||
)
|
||||
|
||||
@@ -373,7 +373,7 @@ async def test_output_yielding_with_dynamic_fields():
|
||||
input_data = block.input_schema(
|
||||
prompt="Create a user dictionary",
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
agent_mode_max_iterations=0, # Use traditional mode to test output yielding
|
||||
)
|
||||
|
||||
@@ -594,7 +594,7 @@ async def test_validation_errors_dont_pollute_conversation():
|
||||
input_data = block.input_schema(
|
||||
prompt="Test prompt",
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
retry=3, # Allow retries
|
||||
agent_mode_max_iterations=1,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from gravitasml.parser import Parser
|
||||
from gravitasml.token import tokenize
|
||||
from gravitasml.token import Token, tokenize
|
||||
|
||||
from backend.data.block import Block, BlockOutput, BlockSchemaInput, BlockSchemaOutput
|
||||
from backend.data.model import SchemaField
|
||||
@@ -25,6 +25,38 @@ class XMLParserBlock(Block):
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_tokens(tokens: list[Token]) -> None:
|
||||
"""Ensure the XML has a single root element and no stray text."""
|
||||
if not tokens:
|
||||
raise ValueError("XML input is empty.")
|
||||
|
||||
depth = 0
|
||||
root_seen = False
|
||||
|
||||
for token in tokens:
|
||||
if token.type == "TAG_OPEN":
|
||||
if depth == 0 and root_seen:
|
||||
raise ValueError("XML must have a single root element.")
|
||||
depth += 1
|
||||
if depth == 1:
|
||||
root_seen = True
|
||||
elif token.type == "TAG_CLOSE":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise SyntaxError("Unexpected closing tag in XML input.")
|
||||
elif token.type in {"TEXT", "ESCAPE"}:
|
||||
if depth == 0 and token.value:
|
||||
raise ValueError(
|
||||
"XML contains text outside the root element; "
|
||||
"wrap content in a single root tag."
|
||||
)
|
||||
|
||||
if depth != 0:
|
||||
raise SyntaxError("Unclosed tag detected in XML input.")
|
||||
if not root_seen:
|
||||
raise ValueError("XML must include a root element.")
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
# Security fix: Add size limits to prevent XML bomb attacks
|
||||
MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB limit for XML input
|
||||
@@ -35,7 +67,9 @@ class XMLParserBlock(Block):
|
||||
)
|
||||
|
||||
try:
|
||||
tokens = tokenize(input_data.input_xml)
|
||||
tokens = list(tokenize(input_data.input_xml))
|
||||
self._validate_tokens(tokens)
|
||||
|
||||
parser = Parser(tokens)
|
||||
parsed_result = parser.parse()
|
||||
yield "parsed_xml", parsed_result
|
||||
|
||||
@@ -111,6 +111,8 @@ class TranscribeYoutubeVideoBlock(Block):
|
||||
return parsed_url.path.split("/")[2]
|
||||
if parsed_url.path[:3] == "/v/":
|
||||
return parsed_url.path.split("/")[2]
|
||||
if parsed_url.path.startswith("/shorts/"):
|
||||
return parsed_url.path.split("/")[2]
|
||||
raise ValueError(f"Invalid YouTube URL: {url}")
|
||||
|
||||
def get_transcript(
|
||||
|
||||
@@ -59,12 +59,13 @@ from backend.integrations.credentials_store import (
|
||||
|
||||
MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.O3: 4,
|
||||
LlmModel.O3_MINI: 2, # $1.10 / $4.40
|
||||
LlmModel.O1: 16, # $15 / $60
|
||||
LlmModel.O3_MINI: 2,
|
||||
LlmModel.O1: 16,
|
||||
LlmModel.O1_MINI: 4,
|
||||
# GPT-5 models
|
||||
LlmModel.GPT5: 2,
|
||||
LlmModel.GPT5_2: 6,
|
||||
LlmModel.GPT5_1: 5,
|
||||
LlmModel.GPT5: 2,
|
||||
LlmModel.GPT5_MINI: 1,
|
||||
LlmModel.GPT5_NANO: 1,
|
||||
LlmModel.GPT5_CHAT: 5,
|
||||
@@ -87,7 +88,7 @@ MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.AIML_API_LLAMA3_3_70B: 1,
|
||||
LlmModel.AIML_API_META_LLAMA_3_1_70B: 1,
|
||||
LlmModel.AIML_API_LLAMA_3_2_3B: 1,
|
||||
LlmModel.LLAMA3_3_70B: 1, # $0.59 / $0.79
|
||||
LlmModel.LLAMA3_3_70B: 1,
|
||||
LlmModel.LLAMA3_1_8B: 1,
|
||||
LlmModel.OLLAMA_LLAMA3_3: 1,
|
||||
LlmModel.OLLAMA_LLAMA3_2: 1,
|
||||
|
||||
@@ -341,6 +341,19 @@ class UserCreditBase(ABC):
|
||||
|
||||
if result:
|
||||
# UserBalance is already updated by the CTE
|
||||
|
||||
# Clear insufficient funds notification flags when credits are added
|
||||
# so user can receive alerts again if they run out in the future.
|
||||
if transaction.amount > 0 and transaction.type in [
|
||||
CreditTransactionType.GRANT,
|
||||
CreditTransactionType.TOP_UP,
|
||||
]:
|
||||
from backend.executor.manager import (
|
||||
clear_insufficient_funds_notifications,
|
||||
)
|
||||
|
||||
await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
return result[0]["balance"]
|
||||
|
||||
async def _add_transaction(
|
||||
@@ -530,6 +543,22 @@ class UserCreditBase(ABC):
|
||||
if result:
|
||||
new_balance, tx_key = result[0]["balance"], result[0]["transactionKey"]
|
||||
# UserBalance is already updated by the CTE
|
||||
|
||||
# Clear insufficient funds notification flags when credits are added
|
||||
# so user can receive alerts again if they run out in the future.
|
||||
if (
|
||||
amount > 0
|
||||
and is_active
|
||||
and transaction_type
|
||||
in [CreditTransactionType.GRANT, CreditTransactionType.TOP_UP]
|
||||
):
|
||||
# Lazy import to avoid circular dependency with executor.manager
|
||||
from backend.executor.manager import (
|
||||
clear_insufficient_funds_notifications,
|
||||
)
|
||||
|
||||
await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
return new_balance, tx_key
|
||||
|
||||
# If no result, either user doesn't exist or insufficient balance
|
||||
|
||||
@@ -114,6 +114,40 @@ utilization_gauge = Gauge(
|
||||
"Ratio of active graph runs to max graph workers",
|
||||
)
|
||||
|
||||
# Redis key prefix for tracking insufficient funds Discord notifications.
|
||||
# We only send one notification per user per agent until they top up credits.
|
||||
INSUFFICIENT_FUNDS_NOTIFIED_PREFIX = "insufficient_funds_discord_notified"
|
||||
# TTL for the notification flag (30 days) - acts as a fallback cleanup
|
||||
INSUFFICIENT_FUNDS_NOTIFIED_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
async def clear_insufficient_funds_notifications(user_id: str) -> int:
|
||||
"""
|
||||
Clear all insufficient funds notification flags for a user.
|
||||
|
||||
This should be called when a user tops up their credits, allowing
|
||||
Discord notifications to be sent again if they run out of funds.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to clear notifications for.
|
||||
|
||||
Returns:
|
||||
The number of keys that were deleted.
|
||||
"""
|
||||
try:
|
||||
redis_client = await redis.get_redis_async()
|
||||
pattern = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
keys = [key async for key in redis_client.scan_iter(match=pattern)]
|
||||
if keys:
|
||||
return await redis_client.delete(*keys)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to clear insufficient funds notification flags for user "
|
||||
f"{user_id}: {e}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# Thread-local storage for ExecutionProcessor instances
|
||||
_tls = threading.local()
|
||||
@@ -1261,12 +1295,40 @@ class ExecutionProcessor:
|
||||
graph_id: str,
|
||||
e: InsufficientBalanceError,
|
||||
):
|
||||
# Check if we've already sent a notification for this user+agent combo.
|
||||
# We only send one notification per user per agent until they top up credits.
|
||||
redis_key = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id}"
|
||||
try:
|
||||
redis_client = redis.get_redis()
|
||||
# SET NX returns True only if the key was newly set (didn't exist)
|
||||
is_new_notification = redis_client.set(
|
||||
redis_key,
|
||||
"1",
|
||||
nx=True,
|
||||
ex=INSUFFICIENT_FUNDS_NOTIFIED_TTL_SECONDS,
|
||||
)
|
||||
if not is_new_notification:
|
||||
# Already notified for this user+agent, skip all notifications
|
||||
logger.debug(
|
||||
f"Skipping duplicate insufficient funds notification for "
|
||||
f"user={user_id}, graph={graph_id}"
|
||||
)
|
||||
return
|
||||
except Exception as redis_error:
|
||||
# If Redis fails, log and continue to send the notification
|
||||
# (better to occasionally duplicate than to never notify)
|
||||
logger.warning(
|
||||
f"Failed to check/set insufficient funds notification flag in Redis: "
|
||||
f"{redis_error}"
|
||||
)
|
||||
|
||||
shortfall = abs(e.amount) - e.balance
|
||||
metadata = db_client.get_graph_metadata(graph_id)
|
||||
base_url = (
|
||||
settings.config.frontend_base_url or settings.config.platform_base_url
|
||||
)
|
||||
|
||||
# Queue user email notification
|
||||
queue_notification(
|
||||
NotificationEventModel(
|
||||
user_id=user_id,
|
||||
@@ -1280,6 +1342,7 @@ class ExecutionProcessor:
|
||||
)
|
||||
)
|
||||
|
||||
# Send Discord system alert
|
||||
try:
|
||||
user_email = db_client.get_user_email_by_id(user_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from prisma.enums import NotificationType
|
||||
|
||||
from backend.data.notifications import ZeroBalanceData
|
||||
from backend.executor.manager import (
|
||||
INSUFFICIENT_FUNDS_NOTIFIED_PREFIX,
|
||||
ExecutionProcessor,
|
||||
clear_insufficient_funds_notifications,
|
||||
)
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
from backend.util.test import SpinTestServer
|
||||
|
||||
|
||||
async def async_iter(items):
|
||||
"""Helper to create an async iterator from a list."""
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_sends_discord_alert_first_time(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that the first insufficient funds notification sends a Discord alert."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id = "test-graph-456"
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72, # $0.72
|
||||
amount=-714, # Attempting to spend $7.14
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.executor.manager.queue_notification"
|
||||
) as mock_queue_notif, patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Setup mocks
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
# Mock Redis to simulate first-time notification (set returns True)
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
mock_redis_client.set.return_value = True # Key was newly set
|
||||
|
||||
# Create mock database client
|
||||
mock_db_client = MagicMock()
|
||||
mock_graph_metadata = MagicMock()
|
||||
mock_graph_metadata.name = "Test Agent"
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_metadata
|
||||
mock_db_client.get_user_email_by_id.return_value = "test@example.com"
|
||||
|
||||
# Test the insufficient funds handler
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify notification was queued
|
||||
mock_queue_notif.assert_called_once()
|
||||
notification_call = mock_queue_notif.call_args[0][0]
|
||||
assert notification_call.type == NotificationType.ZERO_BALANCE
|
||||
assert notification_call.user_id == user_id
|
||||
assert isinstance(notification_call.data, ZeroBalanceData)
|
||||
assert notification_call.data.current_balance == 72
|
||||
|
||||
# Verify Redis was checked with correct key pattern
|
||||
expected_key = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id}"
|
||||
mock_redis_client.set.assert_called_once()
|
||||
call_args = mock_redis_client.set.call_args
|
||||
assert call_args[0][0] == expected_key
|
||||
assert call_args[1]["nx"] is True
|
||||
|
||||
# Verify Discord alert was sent
|
||||
mock_client.discord_system_alert.assert_called_once()
|
||||
discord_message = mock_client.discord_system_alert.call_args[0][0]
|
||||
assert "Insufficient Funds Alert" in discord_message
|
||||
assert "test@example.com" in discord_message
|
||||
assert "Test Agent" in discord_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_skips_duplicate_notifications(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that duplicate insufficient funds notifications skip both email and Discord."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id = "test-graph-456"
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72,
|
||||
amount=-714,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.executor.manager.queue_notification"
|
||||
) as mock_queue_notif, patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Setup mocks
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
# Mock Redis to simulate duplicate notification (set returns False/None)
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
mock_redis_client.set.return_value = None # Key already existed
|
||||
|
||||
# Create mock database client
|
||||
mock_db_client = MagicMock()
|
||||
mock_db_client.get_graph_metadata.return_value = MagicMock(name="Test Agent")
|
||||
|
||||
# Test the insufficient funds handler
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify email notification was NOT queued (deduplication worked)
|
||||
mock_queue_notif.assert_not_called()
|
||||
|
||||
# Verify Discord alert was NOT sent (deduplication worked)
|
||||
mock_client.discord_system_alert.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_different_agents_get_separate_alerts(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that different agents for the same user get separate Discord alerts."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id_1 = "test-graph-111"
|
||||
graph_id_2 = "test-graph-222"
|
||||
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72,
|
||||
amount=-714,
|
||||
)
|
||||
|
||||
with patch("backend.executor.manager.queue_notification"), patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
# Both calls return True (first time for each agent)
|
||||
mock_redis_client.set.return_value = True
|
||||
|
||||
mock_db_client = MagicMock()
|
||||
mock_graph_metadata = MagicMock()
|
||||
mock_graph_metadata.name = "Test Agent"
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_metadata
|
||||
mock_db_client.get_user_email_by_id.return_value = "test@example.com"
|
||||
|
||||
# First agent notification
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id_1,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Second agent notification
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id_2,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify Discord alerts were sent for both agents
|
||||
assert mock_client.discord_system_alert.call_count == 2
|
||||
|
||||
# Verify Redis was called with different keys
|
||||
assert mock_redis_client.set.call_count == 2
|
||||
calls = mock_redis_client.set.call_args_list
|
||||
assert (
|
||||
calls[0][0][0]
|
||||
== f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id_1}"
|
||||
)
|
||||
assert (
|
||||
calls[1][0][0]
|
||||
== f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id_2}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_clear_insufficient_funds_notifications(server: SpinTestServer):
|
||||
"""Test that clearing notifications removes all keys for a user."""
|
||||
|
||||
user_id = "test-user-123"
|
||||
|
||||
with patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
mock_redis_client = MagicMock()
|
||||
# get_redis_async is an async function, so we need AsyncMock for it
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
# Mock scan_iter to return some keys as an async iterator
|
||||
mock_keys = [
|
||||
f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-1",
|
||||
f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-2",
|
||||
f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-3",
|
||||
]
|
||||
mock_redis_client.scan_iter.return_value = async_iter(mock_keys)
|
||||
# delete is awaited, so use AsyncMock
|
||||
mock_redis_client.delete = AsyncMock(return_value=3)
|
||||
|
||||
# Clear notifications
|
||||
result = await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
# Verify correct pattern was used
|
||||
expected_pattern = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
mock_redis_client.scan_iter.assert_called_once_with(match=expected_pattern)
|
||||
|
||||
# Verify delete was called with all keys
|
||||
mock_redis_client.delete.assert_called_once_with(*mock_keys)
|
||||
|
||||
# Verify return value
|
||||
assert result == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_clear_insufficient_funds_notifications_no_keys(server: SpinTestServer):
|
||||
"""Test clearing notifications when there are no keys to clear."""
|
||||
|
||||
user_id = "test-user-no-notifications"
|
||||
|
||||
with patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
mock_redis_client = MagicMock()
|
||||
# get_redis_async is an async function, so we need AsyncMock for it
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
# Mock scan_iter to return no keys as an async iterator
|
||||
mock_redis_client.scan_iter.return_value = async_iter([])
|
||||
|
||||
# Clear notifications
|
||||
result = await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
# Verify delete was not called
|
||||
mock_redis_client.delete.assert_not_called()
|
||||
|
||||
# Verify return value
|
||||
assert result == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_clear_insufficient_funds_notifications_handles_redis_error(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that clearing notifications handles Redis errors gracefully."""
|
||||
|
||||
user_id = "test-user-redis-error"
|
||||
|
||||
with patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
# Mock get_redis_async to raise an error
|
||||
mock_redis_module.get_redis_async = AsyncMock(
|
||||
side_effect=Exception("Redis connection failed")
|
||||
)
|
||||
|
||||
# Clear notifications should not raise, just return 0
|
||||
result = await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
# Verify it returned 0 (graceful failure)
|
||||
assert result == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_continues_on_redis_error(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that both email and Discord notifications are still sent when Redis fails."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id = "test-graph-456"
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72,
|
||||
amount=-714,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.executor.manager.queue_notification"
|
||||
) as mock_queue_notif, patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
# Mock Redis to raise an error
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
mock_redis_client.set.side_effect = Exception("Redis connection error")
|
||||
|
||||
mock_db_client = MagicMock()
|
||||
mock_graph_metadata = MagicMock()
|
||||
mock_graph_metadata.name = "Test Agent"
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_metadata
|
||||
mock_db_client.get_user_email_by_id.return_value = "test@example.com"
|
||||
|
||||
# Test the insufficient funds handler
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify email notification was still queued despite Redis error
|
||||
mock_queue_notif.assert_called_once()
|
||||
|
||||
# Verify Discord alert was still sent despite Redis error
|
||||
mock_client.discord_system_alert.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_clears_notifications_on_grant(server: SpinTestServer):
|
||||
"""Test that _add_transaction clears notification flags when adding GRANT credits."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-grant-clear"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 1000, "transactionKey": "test-tx-key"}]
|
||||
|
||||
# Mock async Redis for notification clearing
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
mock_redis_client.scan_iter.return_value = async_iter(
|
||||
[f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-1"]
|
||||
)
|
||||
mock_redis_client.delete = AsyncMock(return_value=1)
|
||||
|
||||
# Create a concrete instance
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with GRANT type (should clear notifications)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=500, # Positive amount
|
||||
transaction_type=CreditTransactionType.GRANT,
|
||||
is_active=True, # Active transaction
|
||||
)
|
||||
|
||||
# Verify notification clearing was called
|
||||
mock_redis_module.get_redis_async.assert_called_once()
|
||||
mock_redis_client.scan_iter.assert_called_once_with(
|
||||
match=f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_clears_notifications_on_top_up(server: SpinTestServer):
|
||||
"""Test that _add_transaction clears notification flags when adding TOP_UP credits."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-topup-clear"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 2000, "transactionKey": "test-tx-key-2"}]
|
||||
|
||||
# Mock async Redis for notification clearing
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
mock_redis_client.scan_iter.return_value = async_iter([])
|
||||
mock_redis_client.delete = AsyncMock(return_value=0)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with TOP_UP type (should clear notifications)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=1000, # Positive amount
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Verify notification clearing was attempted
|
||||
mock_redis_module.get_redis_async.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_skips_clearing_for_inactive_transaction(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that _add_transaction does NOT clear notifications for inactive transactions."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-inactive"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 500, "transactionKey": "test-tx-key-3"}]
|
||||
|
||||
# Mock async Redis
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with is_active=False (should NOT clear notifications)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=500,
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
is_active=False, # Inactive - pending Stripe payment
|
||||
)
|
||||
|
||||
# Verify notification clearing was NOT called
|
||||
mock_redis_module.get_redis_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_skips_clearing_for_usage_transaction(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that _add_transaction does NOT clear notifications for USAGE transactions."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-usage"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 400, "transactionKey": "test-tx-key-4"}]
|
||||
|
||||
# Mock async Redis
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with USAGE type (spending, should NOT clear)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=-100, # Negative - spending credits
|
||||
transaction_type=CreditTransactionType.USAGE,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Verify notification clearing was NOT called
|
||||
mock_redis_module.get_redis_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enable_transaction_clears_notifications(server: SpinTestServer):
|
||||
"""Test that _enable_transaction clears notification flags when enabling a TOP_UP."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-enable"
|
||||
|
||||
with patch("backend.data.credit.CreditTransaction") as mock_credit_tx, patch(
|
||||
"backend.data.credit.query_raw_with_schema"
|
||||
) as mock_query, patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
# Mock finding the pending transaction
|
||||
mock_transaction = MagicMock()
|
||||
mock_transaction.amount = 1000
|
||||
mock_transaction.type = CreditTransactionType.TOP_UP
|
||||
mock_credit_tx.prisma.return_value.find_first = AsyncMock(
|
||||
return_value=mock_transaction
|
||||
)
|
||||
|
||||
# Mock the query to return updated balance
|
||||
mock_query.return_value = [{"balance": 1500}]
|
||||
|
||||
# Mock async Redis for notification clearing
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
mock_redis_client.scan_iter.return_value = async_iter(
|
||||
[f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-1"]
|
||||
)
|
||||
mock_redis_client.delete = AsyncMock(return_value=1)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _enable_transaction (simulates Stripe checkout completion)
|
||||
from backend.util.json import SafeJson
|
||||
|
||||
await credit_model._enable_transaction(
|
||||
transaction_key="cs_test_123",
|
||||
user_id=user_id,
|
||||
metadata=SafeJson({"payment": "completed"}),
|
||||
)
|
||||
|
||||
# Verify notification clearing was called
|
||||
mock_redis_module.get_redis_async.assert_called_once()
|
||||
mock_redis_client.scan_iter.assert_called_once_with(
|
||||
match=f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
)
|
||||
24
autogpt_platform/backend/poetry.lock
generated
24
autogpt_platform/backend/poetry.lock
generated
@@ -1906,16 +1906,32 @@ httpx = {version = ">=0.26,<0.29", extras = ["http2"]}
|
||||
pydantic = ">=1.10,<3"
|
||||
pyjwt = ">=2.10.1,<3.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "gravitas-md2gdocs"
|
||||
version = "0.1.0"
|
||||
description = "Convert Markdown to Google Docs API requests"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gravitas_md2gdocs-0.1.0-py3-none-any.whl", hash = "sha256:0cb0627779fdd65c1604818af4142eea1b25d055060183363de1bae4d9e46508"},
|
||||
{file = "gravitas_md2gdocs-0.1.0.tar.gz", hash = "sha256:bb3122fe9fa35c528f3f00b785d3f1398d350082d5d03f60f56c895bdcc68033"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["google-auth-oauthlib (>=1.0.0)", "pytest (>=7.0.0)", "pytest-cov (>=4.0.0)", "python-dotenv (>=1.0.0)", "ruff (>=0.1.0)"]
|
||||
google = ["google-api-python-client (>=2.0.0)", "google-auth (>=2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "gravitasml"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gravitasml-0.1.3-py3-none-any.whl", hash = "sha256:51ff98b4564b7a61f7796f18d5f2558b919d30b3722579296089645b7bc18b85"},
|
||||
{file = "gravitasml-0.1.3.tar.gz", hash = "sha256:04d240b9fa35878252d57a36032130b6516487468847fcdced1022c032a20f57"},
|
||||
{file = "gravitasml-0.1.4-py3-none-any.whl", hash = "sha256:671a18b11d3d8a0e270c6a80c72cd058458b18d5ef7560d00010e962ab1bca74"},
|
||||
{file = "gravitasml-0.1.4.tar.gz", hash = "sha256:35d0d9fec7431817482d53d9c976e375557c3e041d1eb6928e809324a8c866e3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7279,4 +7295,4 @@ cffi = ["cffi (>=1.11)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.14"
|
||||
content-hash = "13b191b2a1989d3321ff713c66ff6f5f4f3b82d15df4d407e0e5dbf87d7522c4"
|
||||
content-hash = "a93ba0cea3b465cb6ec3e3f258b383b09f84ea352ccfdbfa112902cde5653fc6"
|
||||
|
||||
@@ -27,7 +27,7 @@ google-api-python-client = "^2.177.0"
|
||||
google-auth-oauthlib = "^1.2.2"
|
||||
google-cloud-storage = "^3.2.0"
|
||||
googlemaps = "^4.10.0"
|
||||
gravitasml = "^0.1.3"
|
||||
gravitasml = "^0.1.4"
|
||||
groq = "^0.30.0"
|
||||
html2text = "^2024.2.26"
|
||||
jinja2 = "^3.1.6"
|
||||
@@ -82,6 +82,7 @@ firecrawl-py = "^4.3.6"
|
||||
exa-py = "^1.14.20"
|
||||
croniter = "^6.0.0"
|
||||
stagehand = "^0.5.1"
|
||||
gravitas-md2gdocs = "^0.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
aiohappyeyeballs = "^2.6.1"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from backend.blocks.google.docs import GoogleDocsFormatTextBlock
|
||||
|
||||
|
||||
def _make_mock_docs_service() -> Mock:
|
||||
service = Mock()
|
||||
# Ensure chained call exists: service.documents().batchUpdate(...).execute()
|
||||
service.documents.return_value.batchUpdate.return_value.execute.return_value = {}
|
||||
return service
|
||||
|
||||
|
||||
def test_format_text_parses_shorthand_hex_color():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#FFF",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify request body contains correct rgbColor for white.
|
||||
_, kwargs = service.documents.return_value.batchUpdate.call_args
|
||||
requests = kwargs["body"]["requests"]
|
||||
rgb = requests[0]["updateTextStyle"]["textStyle"]["foregroundColor"]["color"][
|
||||
"rgbColor"
|
||||
]
|
||||
assert rgb == {"red": 1.0, "green": 1.0, "blue": 1.0}
|
||||
|
||||
|
||||
def test_format_text_parses_full_hex_color():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#FF0000",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
_, kwargs = service.documents.return_value.batchUpdate.call_args
|
||||
requests = kwargs["body"]["requests"]
|
||||
rgb = requests[0]["updateTextStyle"]["textStyle"]["foregroundColor"]["color"][
|
||||
"rgbColor"
|
||||
]
|
||||
assert rgb == {"red": 1.0, "green": 0.0, "blue": 0.0}
|
||||
|
||||
|
||||
def test_format_text_ignores_invalid_color_when_other_fields_present():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=True,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#GGG",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "warning" in result
|
||||
|
||||
# Should still apply bold, but should NOT include foregroundColor in textStyle.
|
||||
_, kwargs = service.documents.return_value.batchUpdate.call_args
|
||||
requests = kwargs["body"]["requests"]
|
||||
text_style = requests[0]["updateTextStyle"]["textStyle"]
|
||||
fields = requests[0]["updateTextStyle"]["fields"]
|
||||
|
||||
assert text_style == {"bold": True}
|
||||
assert fields == "bold"
|
||||
|
||||
|
||||
def test_format_text_invalid_color_only_does_not_call_api():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#F",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid foreground_color" in result["message"]
|
||||
service.documents.return_value.batchUpdate.assert_not_called()
|
||||
@@ -37,6 +37,18 @@ class TestTranscribeYoutubeVideoBlock:
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dQw4w9WgXcQ"
|
||||
|
||||
def test_extract_video_id_shorts_url(self):
|
||||
"""Test extracting video ID from YouTube Shorts URL."""
|
||||
url = "https://www.youtube.com/shorts/dtUqwMu3e-g"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dtUqwMu3e-g"
|
||||
|
||||
def test_extract_video_id_shorts_url_with_params(self):
|
||||
"""Test extracting video ID from YouTube Shorts URL with query parameters."""
|
||||
url = "https://www.youtube.com/shorts/dtUqwMu3e-g?feature=share"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dtUqwMu3e-g"
|
||||
|
||||
@patch("backend.blocks.youtube.YouTubeTranscriptApi")
|
||||
def test_get_transcript_english_available(self, mock_api_class):
|
||||
"""Test getting transcript when English is available."""
|
||||
|
||||
146
autogpt_platform/cloudflare_worker.js
Normal file
146
autogpt_platform/cloudflare_worker.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Cloudflare Workers Script for docs.agpt.co → agpt.co/docs migration
|
||||
*
|
||||
* Deploy this script to handle all redirects with a single JavaScript file.
|
||||
* No rule limits, easy to maintain, handles all edge cases.
|
||||
*/
|
||||
|
||||
// URL mapping for special cases that don't follow patterns
|
||||
const SPECIAL_MAPPINGS = {
|
||||
// Root page
|
||||
'/': '/docs/platform',
|
||||
|
||||
// Special cases that don't follow standard patterns
|
||||
'/platform/d_id/': '/docs/integrations/block-integrations/d-id',
|
||||
'/platform/blocks/blocks/': '/docs/integrations',
|
||||
'/platform/blocks/decoder_block/': '/docs/integrations/block-integrations/text-decoder',
|
||||
'/platform/blocks/http': '/docs/integrations/block-integrations/send-web-request',
|
||||
'/platform/blocks/llm/': '/docs/integrations/block-integrations/ai-and-llm',
|
||||
'/platform/blocks/time_blocks': '/docs/integrations/block-integrations/time-and-date',
|
||||
'/platform/blocks/text_to_speech_block': '/docs/integrations/block-integrations/text-to-speech',
|
||||
'/platform/blocks/ai_shortform_video_block': '/docs/integrations/block-integrations/ai-shortform-video',
|
||||
'/platform/blocks/replicate_flux_advanced': '/docs/integrations/block-integrations/replicate-flux-advanced',
|
||||
'/platform/blocks/flux_kontext': '/docs/integrations/block-integrations/flux-kontext',
|
||||
'/platform/blocks/ai_condition/': '/docs/integrations/block-integrations/ai-condition',
|
||||
'/platform/blocks/email_block': '/docs/integrations/block-integrations/email',
|
||||
'/platform/blocks/google_maps': '/docs/integrations/block-integrations/google-maps',
|
||||
'/platform/blocks/google/gmail': '/docs/integrations/block-integrations/gmail',
|
||||
'/platform/blocks/github/issues/': '/docs/integrations/block-integrations/github-issues',
|
||||
'/platform/blocks/github/repo/': '/docs/integrations/block-integrations/github-repo',
|
||||
'/platform/blocks/github/pull_requests': '/docs/integrations/block-integrations/github-pull-requests',
|
||||
'/platform/blocks/twitter/twitter': '/docs/integrations/block-integrations/twitter',
|
||||
'/classic/setup/': '/docs/classic/setup/setting-up-autogpt-classic',
|
||||
'/code-of-conduct/': '/docs/classic/help-us-improve-autogpt/code-of-conduct',
|
||||
'/contributing/': '/docs/classic/contributing',
|
||||
'/contribute/': '/docs/contribute',
|
||||
'/forge/components/introduction/': '/docs/classic/forge/introduction'
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform path by replacing underscores with hyphens and removing trailing slashes
|
||||
*/
|
||||
function transformPath(path) {
|
||||
return path.replace(/_/g, '-').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle docs.agpt.co redirects
|
||||
*/
|
||||
function handleDocsRedirect(url) {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Check special mappings first
|
||||
if (SPECIAL_MAPPINGS[pathname]) {
|
||||
return `https://agpt.co${SPECIAL_MAPPINGS[pathname]}`;
|
||||
}
|
||||
|
||||
// Pattern-based redirects
|
||||
|
||||
// Platform blocks: /platform/blocks/* → /docs/integrations/block-integrations/*
|
||||
if (pathname.startsWith('/platform/blocks/')) {
|
||||
const blockName = pathname.substring('/platform/blocks/'.length);
|
||||
const transformedName = transformPath(blockName);
|
||||
return `https://agpt.co/docs/integrations/block-integrations/${transformedName}`;
|
||||
}
|
||||
|
||||
// Platform contributing: /platform/contributing/* → /docs/platform/contributing/*
|
||||
if (pathname.startsWith('/platform/contributing/')) {
|
||||
const subPath = pathname.substring('/platform/contributing/'.length);
|
||||
return `https://agpt.co/docs/platform/contributing/${subPath}`;
|
||||
}
|
||||
|
||||
// Platform general: /platform/* → /docs/platform/* (with underscore→hyphen)
|
||||
if (pathname.startsWith('/platform/')) {
|
||||
const subPath = pathname.substring('/platform/'.length);
|
||||
const transformedPath = transformPath(subPath);
|
||||
return `https://agpt.co/docs/platform/${transformedPath}`;
|
||||
}
|
||||
|
||||
// Forge components: /forge/components/* → /docs/classic/forge/introduction/*
|
||||
if (pathname.startsWith('/forge/components/')) {
|
||||
const subPath = pathname.substring('/forge/components/'.length);
|
||||
return `https://agpt.co/docs/classic/forge/introduction/${subPath}`;
|
||||
}
|
||||
|
||||
// Forge general: /forge/* → /docs/classic/forge/*
|
||||
if (pathname.startsWith('/forge/')) {
|
||||
const subPath = pathname.substring('/forge/'.length);
|
||||
return `https://agpt.co/docs/classic/forge/${subPath}`;
|
||||
}
|
||||
|
||||
// Classic: /classic/* → /docs/classic/*
|
||||
if (pathname.startsWith('/classic/')) {
|
||||
const subPath = pathname.substring('/classic/'.length);
|
||||
return `https://agpt.co/docs/classic/${subPath}`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'https://agpt.co/docs/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Worker function
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle docs.agpt.co requests
|
||||
if (url.hostname === 'docs.agpt.co') {
|
||||
const redirectUrl = handleDocsRedirect(url);
|
||||
|
||||
return new Response(null, {
|
||||
status: 301,
|
||||
headers: {
|
||||
'Location': redirectUrl,
|
||||
'Cache-Control': 'max-age=300' // Cache redirects for 5 minutes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For non-docs requests, pass through or return 404
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
// Test function for local development
|
||||
export function testRedirects() {
|
||||
const testCases = [
|
||||
'https://docs.agpt.co/',
|
||||
'https://docs.agpt.co/platform/getting-started/',
|
||||
'https://docs.agpt.co/platform/advanced_setup/',
|
||||
'https://docs.agpt.co/platform/blocks/basic/',
|
||||
'https://docs.agpt.co/platform/blocks/ai_condition/',
|
||||
'https://docs.agpt.co/classic/setup/',
|
||||
'https://docs.agpt.co/forge/components/agents/',
|
||||
'https://docs.agpt.co/contributing/',
|
||||
'https://docs.agpt.co/unknown-page'
|
||||
];
|
||||
|
||||
console.log('Testing redirects:');
|
||||
testCases.forEach(testUrl => {
|
||||
const url = new URL(testUrl);
|
||||
const result = handleDocsRedirect(url);
|
||||
console.log(`${testUrl} → ${result}`);
|
||||
});
|
||||
}
|
||||
@@ -46,14 +46,15 @@
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@rjsf/core": "5.24.13",
|
||||
"@rjsf/utils": "5.24.13",
|
||||
"@rjsf/validator-ajv8": "5.24.13",
|
||||
"@rjsf/core": "6.1.2",
|
||||
"@rjsf/utils": "6.1.2",
|
||||
"@rjsf/validator-ajv8": "6.1.2",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.78.0",
|
||||
@@ -69,6 +70,7 @@
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
"dexie": "4.2.1",
|
||||
"dotenv": "17.2.3",
|
||||
"elliptic": "6.6.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
@@ -90,7 +92,6 @@
|
||||
"react-currency-input-field": "4.0.3",
|
||||
"react-day-picker": "9.11.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-drag-drop-files": "2.4.0",
|
||||
"react-hook-form": "7.66.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "9.0.3",
|
||||
|
||||
3878
autogpt_platform/frontend/pnpm-lock.yaml
generated
3878
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
|
||||
import { OAuthPopupResultMessage } from "./types";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// This route is intended to be used as the callback for integration OAuth flows,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
|
||||
| {
|
||||
success: true;
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
);
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/__legacy__/ui/sheet";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
|
||||
export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
|
||||
const hasOutputs = useGraphStore(useShallow((state) => state.hasOutputs));
|
||||
@@ -76,9 +76,13 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SheetTrigger asChild>
|
||||
<BuilderActionButton disabled={!flowID || !hasOutputs()}>
|
||||
<BookOpenIcon className="size-6" />
|
||||
</BuilderActionButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!flowID || !hasOutputs()}
|
||||
>
|
||||
<BookOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ButtonProps } from "@/components/atoms/Button/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleNotchIcon } from "@phosphor-icons/react";
|
||||
|
||||
export const BuilderActionButton = ({
|
||||
children,
|
||||
className,
|
||||
isLoading,
|
||||
...props
|
||||
}: ButtonProps & { isLoading?: boolean }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="icon"
|
||||
size={"small"}
|
||||
className={cn(
|
||||
"relative h-12 w-12 min-w-0 text-lg",
|
||||
"bg-gradient-to-br from-zinc-50 to-zinc-200",
|
||||
"border border-zinc-200",
|
||||
"shadow-[inset_0_3px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_2px_4px_0_rgba(0,0,0,0.4)]",
|
||||
"hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]",
|
||||
"active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
"transition-all duration-150",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!isLoading ? (
|
||||
children
|
||||
) : (
|
||||
<CircleNotchIcon className="size-6 animate-spin" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ShareIcon } from "@phosphor-icons/react";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { usePublishToMarketplace } from "./usePublishToMarketplace";
|
||||
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
|
||||
import { ShareIcon } from "@phosphor-icons/react";
|
||||
import { usePublishToMarketplace } from "./usePublishToMarketplace";
|
||||
|
||||
export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
const { handlePublishToMarketplace, publishState, handleStateChange } =
|
||||
@@ -16,12 +16,14 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BuilderActionButton
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handlePublishToMarketplace}
|
||||
disabled={!flowID}
|
||||
>
|
||||
<ShareIcon className="size-6 drop-shadow-sm" />
|
||||
</BuilderActionButton>
|
||||
<ShareIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Publish to Marketplace</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -30,6 +32,7 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
targetState={publishState}
|
||||
onStateChange={handleStateChange}
|
||||
preSelectedAgentId={flowID || undefined}
|
||||
showTrigger={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
|
||||
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
const {
|
||||
@@ -29,21 +28,19 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BuilderActionButton
|
||||
className={cn(
|
||||
isGraphRunning &&
|
||||
"border-red-500 bg-gradient-to-br from-red-400 to-red-500 shadow-[inset_0_2px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={isGraphRunning ? "destructive" : "primary"}
|
||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
|
||||
isLoading={isExecutingGraph || isTerminatingGraph || isSaving}
|
||||
loading={isExecutingGraph || isTerminatingGraph || isSaving}
|
||||
>
|
||||
{!isGraphRunning ? (
|
||||
<PlayIcon className="size-6 drop-shadow-sm" />
|
||||
<PlayIcon className="size-4" />
|
||||
) : (
|
||||
<StopIcon className="size-6 drop-shadow-sm" />
|
||||
<StopIcon className="size-4" />
|
||||
)}
|
||||
</BuilderActionButton>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isGraphRunning ? "Stop agent" : "Run agent"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ClockIcon, PlayIcon } from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
import { useRunInputDialog } from "./useRunInputDialog";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useMemo, useState } from "react";
|
||||
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
|
||||
import { isCredentialFieldSchema } from "@/components/renderers/input-renderer/fields/CredentialField/helpers";
|
||||
import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
|
||||
|
||||
export const useRunInputDialog = ({
|
||||
setIsOpen,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useScheduleGraph } from "./useScheduleGraph";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useScheduleGraph } from "./useScheduleGraph";
|
||||
|
||||
export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
const {
|
||||
@@ -23,12 +23,14 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BuilderActionButton
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleScheduleGraph}
|
||||
disabled={!flowID}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</BuilderActionButton>
|
||||
<ClockIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Schedule Graph</p>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ClockCounterClockwiseIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeAgo } from "@/lib/utils/time";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { DraftDiff } from "@/lib/dexie/draft-utils";
|
||||
|
||||
interface DraftRecoveryPopupProps {
|
||||
isInitialLoadComplete: boolean;
|
||||
}
|
||||
|
||||
function formatDiffSummary(diff: DraftDiff | null): string {
|
||||
if (!diff) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Node changes
|
||||
const nodeChanges: string[] = [];
|
||||
if (diff.nodes.added > 0) nodeChanges.push(`+${diff.nodes.added}`);
|
||||
if (diff.nodes.removed > 0) nodeChanges.push(`-${diff.nodes.removed}`);
|
||||
if (diff.nodes.modified > 0) nodeChanges.push(`~${diff.nodes.modified}`);
|
||||
|
||||
if (nodeChanges.length > 0) {
|
||||
parts.push(
|
||||
`${nodeChanges.join("/")} block${diff.nodes.added + diff.nodes.removed + diff.nodes.modified !== 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Edge changes
|
||||
const edgeChanges: string[] = [];
|
||||
if (diff.edges.added > 0) edgeChanges.push(`+${diff.edges.added}`);
|
||||
if (diff.edges.removed > 0) edgeChanges.push(`-${diff.edges.removed}`);
|
||||
if (diff.edges.modified > 0) edgeChanges.push(`~${diff.edges.modified}`);
|
||||
|
||||
if (edgeChanges.length > 0) {
|
||||
parts.push(
|
||||
`${edgeChanges.join("/")} connection${diff.edges.added + diff.edges.removed + diff.edges.modified !== 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function DraftRecoveryPopup({
|
||||
isInitialLoadComplete,
|
||||
}: DraftRecoveryPopupProps) {
|
||||
const {
|
||||
isOpen,
|
||||
popupRef,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
diff,
|
||||
savedAt,
|
||||
onLoad,
|
||||
onDiscard,
|
||||
} = useDraftRecoveryPopup(isInitialLoadComplete);
|
||||
|
||||
const diffSummary = formatDiffSummary(diff);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={popupRef}
|
||||
className={cn("absolute left-1/2 top-4 z-50")}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: "-50%",
|
||||
y: "-150%",
|
||||
scale: 0.5,
|
||||
filter: "blur(20px)",
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: "-50%",
|
||||
y: "0%",
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: "-150%",
|
||||
scale: 0.5,
|
||||
filter: "blur(20px)",
|
||||
transition: { duration: 0.4, type: "spring", bounce: 0.2 },
|
||||
}}
|
||||
transition={{ duration: 0.2, type: "spring", bounce: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xlarge border border-amber-200 bg-amber-50 px-4 py-3 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<ClockCounterClockwiseIcon className="h-5 w-5" weight="fill" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
Unsaved changes found
|
||||
</Text>
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{diffSummary ||
|
||||
`${nodeCount} block${nodeCount !== 1 ? "s" : ""}, ${edgeCount} connection${edgeCount !== 1 ? "s" : ""}`}{" "}
|
||||
• {formatTimeAgo(new Date(savedAt).toISOString())}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onLoad}
|
||||
className="aspect-square min-w-0 p-1.5"
|
||||
>
|
||||
<ClockCounterClockwiseIcon size={20} weight="fill" />
|
||||
<span className="sr-only">Restore changes</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore changes</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={onDiscard}
|
||||
aria-label="Discard changes"
|
||||
className="aspect-square min-w-0 p-1.5"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
<span className="sr-only">Discard changes</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Discard changes</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDraftManager } from "../FlowEditor/Flow/useDraftManager";
|
||||
|
||||
export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => {
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
isRecoveryOpen: isOpen,
|
||||
savedAt,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
diff,
|
||||
loadDraft: onLoad,
|
||||
discardDraft: onDiscard,
|
||||
} = useDraftManager(isInitialLoadComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onDiscard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onDiscard]);
|
||||
return {
|
||||
popupRef,
|
||||
isOpen,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
diff,
|
||||
savedAt,
|
||||
onLoad,
|
||||
onDiscard,
|
||||
};
|
||||
};
|
||||
@@ -1,26 +1,27 @@
|
||||
import { ReactFlow, Background } from "@xyflow/react";
|
||||
import NewControlPanel from "../../NewControlPanel/NewControlPanel";
|
||||
import CustomEdge from "../edges/CustomEdge";
|
||||
import { useFlow } from "./useFlow";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo, useEffect, useCallback } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
import { GraphLoadingBox } from "./components/GraphLoadingBox";
|
||||
import { BuilderActions } from "../../BuilderActions/BuilderActions";
|
||||
import { RunningBackground } from "./components/RunningBackground";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useCopyPaste } from "./useCopyPaste";
|
||||
import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { CustomControls } from "./components/CustomControl";
|
||||
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BuilderActions } from "../../BuilderActions/BuilderActions";
|
||||
import { DraftRecoveryPopup } from "../../DraftRecoveryDialog/DraftRecoveryPopup";
|
||||
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
||||
import NewControlPanel from "../../NewControlPanel/NewControlPanel";
|
||||
import CustomEdge from "../edges/CustomEdge";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { CustomControls } from "./components/CustomControl";
|
||||
import { GraphLoadingBox } from "./components/GraphLoadingBox";
|
||||
import { RunningBackground } from "./components/RunningBackground";
|
||||
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
|
||||
import { resolveCollisions } from "./helpers/resolve-collision";
|
||||
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
||||
import { useCopyPaste } from "./useCopyPaste";
|
||||
import { useFlow } from "./useFlow";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
|
||||
export const Flow = () => {
|
||||
const [{ flowID, flowExecutionID }] = useQueryStates({
|
||||
@@ -41,14 +42,18 @@ export const Flow = () => {
|
||||
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
const setNodes = useNodeStore(useShallow((state) => state.setNodes));
|
||||
|
||||
const onNodesChange = useNodeStore(
|
||||
useShallow((state) => state.onNodesChange),
|
||||
);
|
||||
|
||||
const hasWebhookNodes = useNodeStore(
|
||||
useShallow((state) => state.hasWebhookNodes()),
|
||||
);
|
||||
|
||||
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
|
||||
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
|
||||
|
||||
const onNodeDragStop = useCallback(() => {
|
||||
setNodes(
|
||||
resolveCollisions(nodes, {
|
||||
@@ -60,29 +65,26 @@ export const Flow = () => {
|
||||
}, [setNodes, nodes]);
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
|
||||
useFlow();
|
||||
// for loading purpose
|
||||
const {
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isFlowContentLoading,
|
||||
isInitialLoadComplete,
|
||||
isLocked,
|
||||
setIsLocked,
|
||||
} = useFlow();
|
||||
|
||||
// This hook is used for websocket realtime updates.
|
||||
useFlowRealtime();
|
||||
|
||||
// Copy/paste functionality
|
||||
const handleCopyPaste = useCopyPaste();
|
||||
useCopyPaste();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handleCopyPaste(event);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCopyPaste]);
|
||||
const isGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full dark:bg-slate-900">
|
||||
<div className="relative flex-1">
|
||||
@@ -95,6 +97,9 @@ export const Flow = () => {
|
||||
onConnect={onConnect}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
maxZoom={2}
|
||||
minZoom={0.1}
|
||||
onDragOver={onDragOver}
|
||||
@@ -102,6 +107,7 @@ export const Flow = () => {
|
||||
nodesDraggable={!isLocked}
|
||||
nodesConnectable={!isLocked}
|
||||
elementsSelectable={!isLocked}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
>
|
||||
<Background />
|
||||
<CustomControls setIsLocked={setIsLocked} isLocked={isLocked} />
|
||||
@@ -115,6 +121,7 @@ export const Flow = () => {
|
||||
className="right-2 top-32 p-2"
|
||||
/>
|
||||
)}
|
||||
<DraftRecoveryPopup isInitialLoadComplete={isInitialLoadComplete} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{/* TODO: Need to update it in future - also do not send executionId as prop - rather use useQueryState inside the component */}
|
||||
|
||||
@@ -48,8 +48,6 @@ export const resolveCollisions: CollisionAlgorithm = (
|
||||
const width = (node.width ?? node.measured?.width ?? 0) + margin * 2;
|
||||
const height = (node.height ?? node.measured?.height ?? 0) + margin * 2;
|
||||
|
||||
console.log("width", width);
|
||||
console.log("height", height);
|
||||
const x = node.position.x - margin;
|
||||
const y = node.position.y - margin;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
@@ -151,5 +151,16 @@ export function useCopyPaste() {
|
||||
[getViewport, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handleCopyPaste(event);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCopyPaste]);
|
||||
|
||||
return handleCopyPaste;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
|
||||
import {
|
||||
draftService,
|
||||
getTempFlowId,
|
||||
getOrCreateTempFlowId,
|
||||
DraftData,
|
||||
} from "@/services/builder-draft/draft-service";
|
||||
import { BuilderDraft } from "@/lib/dexie/db";
|
||||
import {
|
||||
cleanNodes,
|
||||
cleanEdges,
|
||||
calculateDraftDiff,
|
||||
DraftDiff,
|
||||
} from "@/lib/dexie/draft-utils";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useHistoryStore } from "../../../stores/historyStore";
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
||||
const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds
|
||||
|
||||
interface DraftRecoveryState {
|
||||
isOpen: boolean;
|
||||
draft: BuilderDraft | null;
|
||||
diff: DraftDiff | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated hook for draft persistence and recovery
|
||||
* - Auto-saves builder state every 15 seconds
|
||||
* - Saves on beforeunload event
|
||||
* - Checks for and manages unsaved drafts on load
|
||||
*/
|
||||
export function useDraftManager(isInitialLoadComplete: boolean) {
|
||||
const [state, setState] = useState<DraftRecoveryState>({
|
||||
isOpen: false,
|
||||
draft: null,
|
||||
diff: null,
|
||||
});
|
||||
|
||||
const [{ flowID, flowVersion }] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
});
|
||||
|
||||
const lastSavedStateRef = useRef<DraftData | null>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDirtyRef = useRef(false);
|
||||
const hasCheckedForDraft = useRef(false);
|
||||
|
||||
const getEffectiveFlowId = useCallback((): string => {
|
||||
return flowID || getOrCreateTempFlowId();
|
||||
}, [flowID]);
|
||||
|
||||
const getCurrentState = useCallback((): DraftData => {
|
||||
const nodes = useNodeStore.getState().nodes;
|
||||
const edges = useEdgeStore.getState().edges;
|
||||
const nodeCounter = useNodeStore.getState().nodeCounter;
|
||||
const graphStore = useGraphStore.getState();
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
graphSchemas: {
|
||||
input: graphStore.inputSchema,
|
||||
credentials: graphStore.credentialsInputSchema,
|
||||
output: graphStore.outputSchema,
|
||||
},
|
||||
nodeCounter,
|
||||
flowVersion: flowVersion ?? undefined,
|
||||
};
|
||||
}, [flowVersion]);
|
||||
|
||||
const cleanStateForComparison = useCallback((stateData: DraftData) => {
|
||||
return {
|
||||
nodes: cleanNodes(stateData.nodes),
|
||||
edges: cleanEdges(stateData.edges),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasChanges = useCallback((): boolean => {
|
||||
const currentState = getCurrentState();
|
||||
|
||||
if (!lastSavedStateRef.current) {
|
||||
return currentState.nodes.length > 0;
|
||||
}
|
||||
|
||||
const currentClean = cleanStateForComparison(currentState);
|
||||
const lastClean = cleanStateForComparison(lastSavedStateRef.current);
|
||||
|
||||
return !isEqual(currentClean, lastClean);
|
||||
}, [getCurrentState, cleanStateForComparison]);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
const effectiveFlowId = getEffectiveFlowId();
|
||||
const currentState = getCurrentState();
|
||||
|
||||
if (currentState.nodes.length === 0 && currentState.edges.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasChanges()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await draftService.saveDraft(effectiveFlowId, currentState);
|
||||
lastSavedStateRef.current = currentState;
|
||||
isDirtyRef.current = false;
|
||||
} catch (error) {
|
||||
console.error("[DraftPersistence] Failed to save draft:", error);
|
||||
}
|
||||
}, [getEffectiveFlowId, getCurrentState, hasChanges]);
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
isDirtyRef.current = true;
|
||||
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveDraft();
|
||||
}, AUTO_SAVE_INTERVAL_MS);
|
||||
}, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeNodes = useNodeStore.subscribe((storeState, prevState) => {
|
||||
if (storeState.nodes !== prevState.nodes) {
|
||||
scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeEdges = useEdgeStore.subscribe((storeState, prevState) => {
|
||||
if (storeState.edges !== prevState.edges) {
|
||||
scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeNodes();
|
||||
unsubscribeEdges();
|
||||
};
|
||||
}, [scheduleSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (isDirtyRef.current) {
|
||||
const effectiveFlowId = getEffectiveFlowId();
|
||||
const currentState = getCurrentState();
|
||||
|
||||
if (
|
||||
currentState.nodes.length === 0 &&
|
||||
currentState.edges.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
draftService.saveDraft(effectiveFlowId, currentState).catch(() => {
|
||||
// Ignore errors on unload
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [getEffectiveFlowId, getCurrentState]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
if (isDirtyRef.current) {
|
||||
saveDraft();
|
||||
}
|
||||
};
|
||||
}, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
draftService.cleanupExpired().catch((error) => {
|
||||
console.error(
|
||||
"[DraftPersistence] Failed to cleanup expired drafts:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const checkForDraft = useCallback(async () => {
|
||||
const effectiveFlowId = flowID || getTempFlowId();
|
||||
|
||||
if (!effectiveFlowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const draft = await draftService.loadDraft(effectiveFlowId);
|
||||
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNodes = useNodeStore.getState().nodes;
|
||||
const currentEdges = useEdgeStore.getState().edges;
|
||||
|
||||
const isDifferent = draftService.isDraftDifferent(
|
||||
draft,
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
);
|
||||
|
||||
if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) {
|
||||
const diff = calculateDraftDiff(
|
||||
draft.nodes,
|
||||
draft.edges,
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
);
|
||||
setState({
|
||||
isOpen: true,
|
||||
draft,
|
||||
diff,
|
||||
});
|
||||
} else {
|
||||
await draftService.deleteDraft(effectiveFlowId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DraftRecovery] Failed to check for draft:", error);
|
||||
}
|
||||
}, [flowID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoadComplete && !hasCheckedForDraft.current) {
|
||||
hasCheckedForDraft.current = true;
|
||||
checkForDraft();
|
||||
}
|
||||
}, [isInitialLoadComplete, checkForDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
hasCheckedForDraft.current = false;
|
||||
setState({
|
||||
isOpen: false,
|
||||
draft: null,
|
||||
diff: null,
|
||||
});
|
||||
}, [flowID]);
|
||||
|
||||
const loadDraft = useCallback(async () => {
|
||||
if (!state.draft) return;
|
||||
|
||||
const { draft } = state;
|
||||
|
||||
try {
|
||||
useNodeStore.getState().setNodes(draft.nodes);
|
||||
useEdgeStore.getState().setEdges(draft.edges);
|
||||
draft.nodes.forEach((node) => {
|
||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||
});
|
||||
|
||||
if (draft.nodeCounter !== undefined) {
|
||||
useNodeStore.setState({ nodeCounter: draft.nodeCounter });
|
||||
}
|
||||
|
||||
if (draft.graphSchemas) {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphSchemas(
|
||||
draft.graphSchemas.input as Record<string, unknown> | null,
|
||||
draft.graphSchemas.credentials as Record<string, unknown> | null,
|
||||
draft.graphSchemas.output as Record<string, unknown> | null,
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
}, 100);
|
||||
|
||||
await draftService.deleteDraft(draft.id);
|
||||
|
||||
setState({
|
||||
isOpen: false,
|
||||
draft: null,
|
||||
diff: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DraftRecovery] Failed to load draft:", error);
|
||||
}
|
||||
}, [state.draft]);
|
||||
|
||||
const discardDraft = useCallback(async () => {
|
||||
if (!state.draft) {
|
||||
setState({ isOpen: false, draft: null, diff: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await draftService.deleteDraft(state.draft.id);
|
||||
} catch (error) {
|
||||
console.error("[DraftRecovery] Failed to discard draft:", error);
|
||||
}
|
||||
|
||||
setState({ isOpen: false, draft: null, diff: null });
|
||||
}, [state.draft]);
|
||||
|
||||
return {
|
||||
// Recovery popup props
|
||||
isRecoveryOpen: state.isOpen,
|
||||
savedAt: state.draft?.savedAt ?? 0,
|
||||
nodeCount: state.draft?.nodes.length ?? 0,
|
||||
edgeCount: state.draft?.edges.length ?? 0,
|
||||
diff: state.diff,
|
||||
loadDraft,
|
||||
discardDraft,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
|
||||
export const useFlow = () => {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [hasAutoFramed, setHasAutoFramed] = useState(false);
|
||||
const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false);
|
||||
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
||||
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
||||
const updateNodeStatus = useNodeStore(
|
||||
@@ -120,6 +121,14 @@ export const useFlow = () => {
|
||||
if (customNodes.length > 0) {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
addNodes(customNodes);
|
||||
|
||||
// Sync hardcoded values with handle IDs.
|
||||
// If a key–value field has a key without a value, the backend omits it from hardcoded values.
|
||||
// But if a handleId exists for that key, it causes inconsistency.
|
||||
// This ensures hardcoded values stay in sync with handle IDs.
|
||||
customNodes.forEach((node) => {
|
||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||
});
|
||||
}
|
||||
}, [customNodes, addNodes]);
|
||||
|
||||
@@ -174,11 +183,23 @@ export const useFlow = () => {
|
||||
if (customNodes.length > 0 && graph?.links) {
|
||||
const timer = setTimeout(() => {
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
// Mark initial load as complete after history is initialized
|
||||
setIsInitialLoadComplete(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [customNodes, graph?.links]);
|
||||
|
||||
// Also mark as complete for new flows (no flowID) after a short delay
|
||||
useEffect(() => {
|
||||
if (!flowID && !isGraphLoading && !isBlocksLoading) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsInitialLoadComplete(true);
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [flowID, isGraphLoading, isBlocksLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
@@ -217,6 +238,7 @@ export const useFlow = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setHasAutoFramed(false);
|
||||
setIsInitialLoadComplete(false);
|
||||
}, [flowID, flowVersion]);
|
||||
|
||||
// Drag and drop block from block menu
|
||||
@@ -253,6 +275,7 @@ export const useFlow = () => {
|
||||
|
||||
return {
|
||||
isFlowContentLoading: isGraphLoading || isBlocksLoading,
|
||||
isInitialLoadComplete,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isLocked,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Connection as RFConnection, EdgeChange } from "@xyflow/react";
|
||||
import {
|
||||
Connection as RFConnection,
|
||||
EdgeChange,
|
||||
applyEdgeChanges,
|
||||
} from "@xyflow/react";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { useCallback } from "react";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { CustomEdge } from "./CustomEdge";
|
||||
|
||||
export const useCustomEdge = () => {
|
||||
const edges = useEdgeStore((s) => s.edges);
|
||||
const addEdge = useEdgeStore((s) => s.addEdge);
|
||||
const removeEdge = useEdgeStore((s) => s.removeEdge);
|
||||
const setEdges = useEdgeStore((s) => s.setEdges);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: RFConnection) => {
|
||||
@@ -45,14 +50,10 @@ export const useCustomEdge = () => {
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "remove") {
|
||||
removeEdge(change.id);
|
||||
}
|
||||
});
|
||||
(changes: EdgeChange<CustomEdge>[]) => {
|
||||
setEdges(applyEdgeChanges(changes, edges));
|
||||
},
|
||||
[removeEdge],
|
||||
[edges, setEdges],
|
||||
);
|
||||
|
||||
return { edges, onConnect, onEdgesChange };
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import { CircleIcon } from "@phosphor-icons/react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NodeHandle = ({
|
||||
const InputNodeHandle = ({
|
||||
handleId,
|
||||
isConnected,
|
||||
side,
|
||||
nodeId,
|
||||
}: {
|
||||
handleId: string;
|
||||
isConnected: boolean;
|
||||
side: "left" | "right";
|
||||
nodeId: string;
|
||||
}) => {
|
||||
const cleanedHandleId = cleanUpHandleId(handleId);
|
||||
const isInputConnected = useEdgeStore((state) =>
|
||||
state.isInputConnected(nodeId ?? "", cleanedHandleId),
|
||||
);
|
||||
|
||||
return (
|
||||
<Handle
|
||||
type={side === "left" ? "target" : "source"}
|
||||
position={side === "left" ? Position.Left : Position.Right}
|
||||
id={handleId}
|
||||
className={side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2"}
|
||||
type={"target"}
|
||||
position={Position.Left}
|
||||
id={cleanedHandleId}
|
||||
className={"-ml-6 mr-2"}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
size={16}
|
||||
weight={isConnected ? "fill" : "duotone"}
|
||||
weight={isInputConnected ? "fill" : "duotone"}
|
||||
className={"text-gray-400 opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
@@ -28,4 +34,35 @@ const NodeHandle = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeHandle;
|
||||
const OutputNodeHandle = ({
|
||||
field_name,
|
||||
nodeId,
|
||||
hexColor,
|
||||
}: {
|
||||
field_name: string;
|
||||
nodeId: string;
|
||||
hexColor: string;
|
||||
}) => {
|
||||
const isOutputConnected = useEdgeStore((state) =>
|
||||
state.isOutputConnected(nodeId, field_name),
|
||||
);
|
||||
return (
|
||||
<Handle
|
||||
type={"source"}
|
||||
position={Position.Right}
|
||||
id={field_name}
|
||||
className={"-mr-2 ml-2"}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
size={16}
|
||||
weight={"duotone"}
|
||||
color={isOutputConnected ? hexColor : "gray"}
|
||||
className={cn("text-gray-400 opacity-100")}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
);
|
||||
};
|
||||
|
||||
export { InputNodeHandle, OutputNodeHandle };
|
||||
|
||||
@@ -1,31 +1,4 @@
|
||||
/**
|
||||
* Handle ID Types for different input structures
|
||||
*
|
||||
* Examples:
|
||||
* SIMPLE: "message"
|
||||
* NESTED: "config.api_key"
|
||||
* ARRAY: "items_$_0", "items_$_1"
|
||||
* KEY_VALUE: "headers_#_Authorization", "params_#_limit"
|
||||
*
|
||||
* Note: All handle IDs are sanitized to remove spaces and special characters.
|
||||
* Spaces become underscores, and special characters are removed.
|
||||
* Example: "user name" becomes "user_name", "email@domain.com" becomes "emaildomaincom"
|
||||
*/
|
||||
export enum HandleIdType {
|
||||
SIMPLE = "SIMPLE",
|
||||
NESTED = "NESTED",
|
||||
ARRAY = "ARRAY",
|
||||
KEY_VALUE = "KEY_VALUE",
|
||||
}
|
||||
|
||||
const fromRjsfId = (id: string): string => {
|
||||
if (!id) return "";
|
||||
const parts = id.split("_");
|
||||
const filtered = parts.filter(
|
||||
(p) => p !== "root" && p !== "properties" && p.length > 0,
|
||||
);
|
||||
return filtered.join("_") || "";
|
||||
};
|
||||
// Here we are handling single level of nesting, if need more in future then i will update it
|
||||
|
||||
const sanitizeForHandleId = (str: string): string => {
|
||||
if (!str) return "";
|
||||
@@ -38,51 +11,53 @@ const sanitizeForHandleId = (str: string): string => {
|
||||
.replace(/^_|_$/g, ""); // Remove leading/trailing underscores
|
||||
};
|
||||
|
||||
export const generateHandleId = (
|
||||
const cleanTitleId = (id: string): string => {
|
||||
if (!id) return "";
|
||||
|
||||
if (id.endsWith("_title")) {
|
||||
id = id.slice(0, -6);
|
||||
}
|
||||
const parts = id.split("_");
|
||||
const filtered = parts.filter(
|
||||
(p) => p !== "root" && p !== "properties" && p.length > 0,
|
||||
);
|
||||
const filtered_id = filtered.join("_") || "";
|
||||
return filtered_id;
|
||||
};
|
||||
|
||||
export const generateHandleIdFromTitleId = (
|
||||
fieldKey: string,
|
||||
nestedValues: string[] = [],
|
||||
type: HandleIdType = HandleIdType.SIMPLE,
|
||||
{
|
||||
isObjectProperty,
|
||||
isAdditionalProperty,
|
||||
isArrayItem,
|
||||
}: {
|
||||
isArrayItem?: boolean;
|
||||
isObjectProperty?: boolean;
|
||||
isAdditionalProperty?: boolean;
|
||||
} = {
|
||||
isArrayItem: false,
|
||||
isObjectProperty: false,
|
||||
isAdditionalProperty: false,
|
||||
},
|
||||
): string => {
|
||||
if (!fieldKey) return "";
|
||||
|
||||
fieldKey = fromRjsfId(fieldKey);
|
||||
fieldKey = sanitizeForHandleId(fieldKey);
|
||||
const filteredKey = cleanTitleId(fieldKey);
|
||||
if (isAdditionalProperty || isArrayItem) {
|
||||
return filteredKey;
|
||||
}
|
||||
const cleanedKey = sanitizeForHandleId(filteredKey);
|
||||
|
||||
if (type === HandleIdType.SIMPLE || nestedValues.length === 0) {
|
||||
return fieldKey;
|
||||
if (isObjectProperty) {
|
||||
// "config_api_key" -> "config.api_key"
|
||||
const parts = cleanedKey.split("_");
|
||||
if (parts.length >= 2) {
|
||||
const baseName = parts[0];
|
||||
const propertyName = parts.slice(1).join("_");
|
||||
return `${baseName}.${propertyName}`;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedNestedValues = nestedValues.map((value) =>
|
||||
sanitizeForHandleId(value),
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case HandleIdType.NESTED:
|
||||
return [fieldKey, ...sanitizedNestedValues].join(".");
|
||||
|
||||
case HandleIdType.ARRAY:
|
||||
return [fieldKey, ...sanitizedNestedValues].join("_$_");
|
||||
|
||||
case HandleIdType.KEY_VALUE:
|
||||
return [fieldKey, ...sanitizedNestedValues].join("_#_");
|
||||
|
||||
default:
|
||||
return fieldKey;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseKeyValueHandleId = (
|
||||
handleId: string,
|
||||
type: HandleIdType,
|
||||
): string => {
|
||||
if (type === HandleIdType.KEY_VALUE) {
|
||||
return handleId.split("_#_")[1];
|
||||
} else if (type === HandleIdType.ARRAY) {
|
||||
return handleId.split("_$_")[1];
|
||||
} else if (type === HandleIdType.NESTED) {
|
||||
return handleId.split(".")[1];
|
||||
} else if (type === HandleIdType.SIMPLE) {
|
||||
return handleId.split("_")[1];
|
||||
}
|
||||
return "";
|
||||
return cleanedKey;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import React from "react";
|
||||
import { Node as XYNode, NodeProps } from "@xyflow/react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { BlockUIType } from "../../../types";
|
||||
import { StickyNoteBlock } from "./components/StickyNoteBlock";
|
||||
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import { NodeContainer } from "./components/NodeContainer";
|
||||
import { NodeHeader } from "./components/NodeHeader";
|
||||
import { FormCreator } from "../FormCreator";
|
||||
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
|
||||
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
|
||||
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
|
||||
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
|
||||
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
|
||||
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { NodeProps, Node as XYNode } from "@xyflow/react";
|
||||
import React from "react";
|
||||
import { BlockUIType } from "../../../types";
|
||||
import { FormCreator } from "../FormCreator";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
|
||||
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
|
||||
import { NodeContainer } from "./components/NodeContainer";
|
||||
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
|
||||
import { NodeHeader } from "./components/NodeHeader";
|
||||
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
|
||||
import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
|
||||
import { StickyNoteBlock } from "./components/StickyNoteBlock";
|
||||
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -88,7 +89,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
|
||||
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
|
||||
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
|
||||
return (
|
||||
const node = (
|
||||
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
||||
<div className="rounded-xlarge bg-white">
|
||||
<NodeHeader data={data} nodeId={nodeId} />
|
||||
@@ -99,7 +100,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
className={cn(
|
||||
"bg-white pr-6",
|
||||
"bg-white px-4",
|
||||
isWebhook && "pointer-events-none opacity-50",
|
||||
)}
|
||||
showHandles={showHandles}
|
||||
@@ -117,6 +118,15 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
<NodeExecutionBadge nodeId={nodeId} />
|
||||
</NodeContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeRightClickMenu
|
||||
nodeId={nodeId}
|
||||
subGraphID={data.hardcodedValues?.graph_id}
|
||||
>
|
||||
{node}
|
||||
</NodeRightClickMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => {
|
||||
);
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white px-5 py-3.5">
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const NodeContainer = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-12 max-w-[370px] rounded-xlarge ring-1 ring-slate-200/60",
|
||||
"z-12 w-[350px] rounded-xlarge ring-1 ring-slate-200/60",
|
||||
selected && "shadow-lg ring-2 ring-slate-200",
|
||||
status && nodeStyleBasedOnStatus[status],
|
||||
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import { DotsThreeOutlineVerticalIcon } from "@phosphor-icons/react";
|
||||
import { Copy, Trash2, ExternalLink } from "lucide-react";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import {
|
||||
SecondaryDropdownMenuContent,
|
||||
SecondaryDropdownMenuItem,
|
||||
SecondaryDropdownMenuSeparator,
|
||||
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
CopyIcon,
|
||||
DotsThreeOutlineVerticalIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
|
||||
export const NodeContextMenu = ({
|
||||
nodeId,
|
||||
subGraphID,
|
||||
}: {
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
subGraphID?: string;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
|
||||
const { deleteElements } = useReactFlow();
|
||||
|
||||
const handleCopy = () => {
|
||||
function handleCopy() {
|
||||
useNodeStore.setState((state) => ({
|
||||
nodes: state.nodes.map((node) => ({
|
||||
...node,
|
||||
@@ -30,47 +35,47 @@ export const NodeContextMenu = ({
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
function handleDelete() {
|
||||
deleteElements({ nodes: [{ id: nodeId }] });
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="py-2">
|
||||
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="rounded-xlarge"
|
||||
>
|
||||
<DropdownMenuItem onClick={handleCopy} className="hover:rounded-xlarge">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Node
|
||||
</DropdownMenuItem>
|
||||
<SecondaryDropdownMenuContent side="right" align="start">
|
||||
<SecondaryDropdownMenuItem onClick={handleCopy}>
|
||||
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
|
||||
<span className="dark:text-gray-100">Copy</span>
|
||||
</SecondaryDropdownMenuItem>
|
||||
<SecondaryDropdownMenuSeparator />
|
||||
|
||||
{subGraphID && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
className="hover:rounded-xlarge"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Agent
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<SecondaryDropdownMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={20}
|
||||
className="mr-2 dark:text-gray-100"
|
||||
/>
|
||||
<span className="dark:text-gray-100">Open agent</span>
|
||||
</SecondaryDropdownMenuItem>
|
||||
<SecondaryDropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 hover:rounded-xlarge"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<SecondaryDropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<TrashIcon
|
||||
size={20}
|
||||
className="mr-2 text-red-500 dark:text-red-400"
|
||||
/>
|
||||
<span className="dark:text-red-400">Delete</span>
|
||||
</SecondaryDropdownMenuItem>
|
||||
</SecondaryDropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import { NodeCost } from "./NodeCost";
|
||||
import { NodeBadges } from "./NodeBadges";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useState } from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
import { NodeBadges } from "./NodeBadges";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { NodeCost } from "./NodeCost";
|
||||
|
||||
export const NodeHeader = ({
|
||||
data,
|
||||
nodeId,
|
||||
}: {
|
||||
type Props = {
|
||||
data: CustomNodeData;
|
||||
nodeId: string;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const NodeHeader = ({ data, nodeId }: Props) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
const title = (data.metadata?.customized_name as string) || data.title;
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(title);
|
||||
const [editedTitle, setEditedTitle] = useState(
|
||||
beautifyString(title).replace("Block", "").trim(),
|
||||
);
|
||||
|
||||
const handleTitleEdit = () => {
|
||||
updateNodeData(nodeId, {
|
||||
@@ -41,7 +42,7 @@ export const NodeHeader = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
|
||||
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-zinc-200 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
|
||||
{/* Title row with context menu */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
@@ -68,12 +69,12 @@ export const NodeHeader = ({
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Text variant="large-semibold" className="line-clamp-1">
|
||||
{beautifyString(title)}
|
||||
{beautifyString(title).replace("Block", "").trim()}
|
||||
</Text>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{beautifyString(title)}</p>
|
||||
<p>{beautifyString(title).replace("Block", "").trim()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-slate-200/50 px-4 py-4">
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
||||
Node Output
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import {
|
||||
SecondaryMenuContent,
|
||||
SecondaryMenuItem,
|
||||
SecondaryMenuSeparator,
|
||||
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
|
||||
import { ArrowSquareOutIcon, CopyIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import * as ContextMenu from "@radix-ui/react-context-menu";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { CustomNode } from "../CustomNode";
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
subGraphID?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const DOUBLE_CLICK_TIMEOUT = 300;
|
||||
|
||||
export function NodeRightClickMenu({ nodeId, subGraphID, children }: Props) {
|
||||
const { deleteElements } = useReactFlow<CustomNode>();
|
||||
const lastRightClickTime = useRef<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function copyNode() {
|
||||
useNodeStore.setState((state) => ({
|
||||
nodes: state.nodes.map((node) => ({
|
||||
...node,
|
||||
selected: node.id === nodeId,
|
||||
})),
|
||||
}));
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
}
|
||||
|
||||
function deleteNode() {
|
||||
deleteElements({ nodes: [{ id: nodeId }] });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastClick = now - lastRightClickTime.current;
|
||||
|
||||
if (timeSinceLastClick < DOUBLE_CLICK_TIMEOUT) {
|
||||
e.stopImmediatePropagation();
|
||||
lastRightClickTime.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
lastRightClickTime.current = now;
|
||||
}
|
||||
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<div ref={containerRef}>{children}</div>
|
||||
</ContextMenu.Trigger>
|
||||
<SecondaryMenuContent>
|
||||
<SecondaryMenuItem onSelect={copyNode}>
|
||||
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
|
||||
<span className="dark:text-gray-100">Copy</span>
|
||||
</SecondaryMenuItem>
|
||||
<SecondaryMenuSeparator />
|
||||
|
||||
{subGraphID && (
|
||||
<>
|
||||
<SecondaryMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={20}
|
||||
className="mr-2 dark:text-gray-100"
|
||||
/>
|
||||
<span className="dark:text-gray-100">Open agent</span>
|
||||
</SecondaryMenuItem>
|
||||
<SecondaryMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SecondaryMenuItem variant="destructive" onSelect={deleteNode}>
|
||||
<TrashIcon
|
||||
size={20}
|
||||
className="mr-2 text-red-500 dark:text-red-400"
|
||||
/>
|
||||
<span className="dark:text-red-400">Delete</span>
|
||||
</SecondaryMenuItem>
|
||||
</SecondaryMenuContent>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormCreator } from "../../FormCreator";
|
||||
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
|
||||
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { uiSchema } from "./uiSchema";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BlockUIType } from "../../types";
|
||||
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
|
||||
export const FormCreator = React.memo(
|
||||
({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
import NodeHandle from "../handlers/NodeHandle";
|
||||
import { OutputNodeHandle } from "../handlers/NodeHandle";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { getTypeDisplayInfo } from "./helpers";
|
||||
import { generateHandleId } from "../handlers/helpers";
|
||||
import { BlockUIType } from "../../types";
|
||||
|
||||
export const OutputHandler = ({
|
||||
@@ -29,8 +28,73 @@ export const OutputHandler = ({
|
||||
const properties = outputSchema?.properties || {};
|
||||
const [isOutputVisible, setIsOutputVisible] = useState(true);
|
||||
|
||||
const showHandles = uiType !== BlockUIType.OUTPUT;
|
||||
|
||||
const renderOutputHandles = (
|
||||
schema: RJSFSchema,
|
||||
keyPrefix: string = "",
|
||||
titlePrefix: string = "",
|
||||
): React.ReactNode[] => {
|
||||
return Object.entries(schema).map(
|
||||
([key, fieldSchema]: [string, RJSFSchema]) => {
|
||||
const fullKey = keyPrefix ? `${keyPrefix}_#_${key}` : key;
|
||||
const fieldTitle = titlePrefix + (fieldSchema?.title || key);
|
||||
|
||||
const isConnected = isOutputConnected(nodeId, fullKey);
|
||||
const shouldShow = isConnected || isOutputVisible;
|
||||
const { displayType, colorClass, hexColor } =
|
||||
getTypeDisplayInfo(fieldSchema);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={fullKey} className="flex flex-col items-end gap-2">
|
||||
<div className="relative flex items-center gap-2">
|
||||
{fieldSchema?.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{fieldSchema?.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Text variant="body" className="text-slate-700">
|
||||
{fieldTitle}
|
||||
</Text>
|
||||
<Text variant="small" as="span" className={colorClass}>
|
||||
({displayType})
|
||||
</Text>
|
||||
|
||||
{showHandles && (
|
||||
<OutputNodeHandle
|
||||
field_name={fullKey}
|
||||
nodeId={nodeId}
|
||||
hexColor={hexColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recursively render nested properties */}
|
||||
{fieldSchema?.properties &&
|
||||
renderOutputHandles(
|
||||
fieldSchema.properties,
|
||||
fullKey,
|
||||
`${fieldTitle}.`,
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white py-3.5">
|
||||
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white py-3.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mr-4 h-fit min-w-0 p-0 hover:border-transparent hover:bg-transparent"
|
||||
@@ -49,50 +113,9 @@ export const OutputHandler = ({
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{Object.entries(properties).map(([key, property]: [string, any]) => {
|
||||
const isConnected = isOutputConnected(nodeId, key);
|
||||
const shouldShow = isConnected || isOutputVisible;
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(property);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={key} className="relative flex items-center gap-2">
|
||||
{property?.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{property?.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Text variant="body" className="text-slate-700">
|
||||
{property?.title || key}{" "}
|
||||
</Text>
|
||||
<Text variant="small" as="span" className={colorClass}>
|
||||
({displayType})
|
||||
</Text>
|
||||
|
||||
<NodeHandle
|
||||
handleId={
|
||||
uiType === BlockUIType.AGENT ? key : generateHandleId(key)
|
||||
}
|
||||
isConnected={isConnected}
|
||||
side="right"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{renderOutputHandles(properties)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,14 +92,38 @@ export const getTypeDisplayInfo = (schema: any) => {
|
||||
if (schema?.type === "string" && schema?.format) {
|
||||
const formatMap: Record<
|
||||
string,
|
||||
{ displayType: string; colorClass: string }
|
||||
{ displayType: string; colorClass: string; hexColor: string }
|
||||
> = {
|
||||
file: { displayType: "file", colorClass: "!text-green-500" },
|
||||
date: { displayType: "date", colorClass: "!text-blue-500" },
|
||||
time: { displayType: "time", colorClass: "!text-blue-500" },
|
||||
"date-time": { displayType: "datetime", colorClass: "!text-blue-500" },
|
||||
"long-text": { displayType: "text", colorClass: "!text-green-500" },
|
||||
"short-text": { displayType: "text", colorClass: "!text-green-500" },
|
||||
file: {
|
||||
displayType: "file",
|
||||
colorClass: "!text-green-500",
|
||||
hexColor: "#22c55e",
|
||||
},
|
||||
date: {
|
||||
displayType: "date",
|
||||
colorClass: "!text-blue-500",
|
||||
hexColor: "#3b82f6",
|
||||
},
|
||||
time: {
|
||||
displayType: "time",
|
||||
colorClass: "!text-blue-500",
|
||||
hexColor: "#3b82f6",
|
||||
},
|
||||
"date-time": {
|
||||
displayType: "datetime",
|
||||
colorClass: "!text-blue-500",
|
||||
hexColor: "#3b82f6",
|
||||
},
|
||||
"long-text": {
|
||||
displayType: "text",
|
||||
colorClass: "!text-green-500",
|
||||
hexColor: "#22c55e",
|
||||
},
|
||||
"short-text": {
|
||||
displayType: "text",
|
||||
colorClass: "!text-green-500",
|
||||
hexColor: "#22c55e",
|
||||
},
|
||||
};
|
||||
|
||||
const formatInfo = formatMap[schema.format];
|
||||
@@ -131,10 +155,23 @@ export const getTypeDisplayInfo = (schema: any) => {
|
||||
any: "!text-gray-500",
|
||||
};
|
||||
|
||||
const hexColorMap: Record<string, string> = {
|
||||
string: "#22c55e",
|
||||
number: "#3b82f6",
|
||||
integer: "#3b82f6",
|
||||
boolean: "#eab308",
|
||||
object: "#a855f7",
|
||||
array: "#6366f1",
|
||||
null: "#6b7280",
|
||||
any: "#6b7280",
|
||||
};
|
||||
|
||||
const colorClass = colorMap[schema?.type] || "!text-gray-500";
|
||||
const hexColor = hexColorMap[schema?.type] || "#6b7280";
|
||||
|
||||
return {
|
||||
displayType,
|
||||
colorClass,
|
||||
hexColor,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ControlPanelButton: React.FC<Props> = ({
|
||||
role={as === "div" ? "button" : undefined}
|
||||
disabled={as === "button" ? disabled : undefined}
|
||||
className={cn(
|
||||
"flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
|
||||
"flex w-auto items-center justify-center whitespace-normal bg-white px-4 py-4 text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
|
||||
selected &&
|
||||
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:cursor-not-allowed",
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React from "react";
|
||||
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
|
||||
export const BlockMenu = () => {
|
||||
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
|
||||
@@ -28,7 +27,7 @@ export const BlockMenu = () => {
|
||||
selected={blockMenuOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
<LegoIcon className="h-6 w-6" />
|
||||
<LegoIcon className="size-5" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
|
||||
import { FilterChip } from "../FilterChip";
|
||||
import { categories } from "./constants";
|
||||
import { FilterSheet } from "../FilterSheet/FilterSheet";
|
||||
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||
|
||||
export const BlockMenuFilters = () => {
|
||||
const {
|
||||
filters,
|
||||
addFilter,
|
||||
removeFilter,
|
||||
categoryCounts,
|
||||
creators,
|
||||
addCreator,
|
||||
removeCreator,
|
||||
} = useBlockMenuStore();
|
||||
|
||||
const handleFilterClick = (filter: GetV2BuilderSearchFilterAnyOfItem) => {
|
||||
if (filters.includes(filter)) {
|
||||
removeFilter(filter);
|
||||
} else {
|
||||
addFilter(filter);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatorClick = (creator: string) => {
|
||||
if (creators.includes(creator)) {
|
||||
removeCreator(creator);
|
||||
} else {
|
||||
addCreator(creator);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterSheet categories={categories} />
|
||||
{creators.length > 0 &&
|
||||
creators.map((creator) => (
|
||||
<FilterChip
|
||||
key={creator}
|
||||
name={"Created by " + creator.slice(0, 10) + "..."}
|
||||
selected={creators.includes(creator)}
|
||||
onClick={() => handleCreatorClick(creator)}
|
||||
/>
|
||||
))}
|
||||
{categories.map((category) => (
|
||||
<FilterChip
|
||||
key={category.key}
|
||||
name={category.name}
|
||||
selected={filters.includes(category.key)}
|
||||
onClick={() => handleFilterClick(category.key)}
|
||||
number={categoryCounts[category.key] ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||
import { CategoryKey } from "./types";
|
||||
|
||||
export const categories: Array<{ key: CategoryKey; name: string }> = [
|
||||
{ key: GetV2BuilderSearchFilterAnyOfItem.blocks, name: "Blocks" },
|
||||
{
|
||||
key: GetV2BuilderSearchFilterAnyOfItem.integrations,
|
||||
name: "Integrations",
|
||||
},
|
||||
{
|
||||
key: GetV2BuilderSearchFilterAnyOfItem.marketplace_agents,
|
||||
name: "Marketplace agents",
|
||||
},
|
||||
{ key: GetV2BuilderSearchFilterAnyOfItem.my_agents, name: "My agents" },
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||
|
||||
export type DefaultStateType =
|
||||
| "suggestion"
|
||||
| "all_blocks"
|
||||
| "input_blocks"
|
||||
| "action_blocks"
|
||||
| "output_blocks"
|
||||
| "integrations"
|
||||
| "marketplace_agents"
|
||||
| "my_agents";
|
||||
|
||||
export type CategoryKey = GetV2BuilderSearchFilterAnyOfItem;
|
||||
|
||||
export interface Filters {
|
||||
categories: {
|
||||
blocks: boolean;
|
||||
integrations: boolean;
|
||||
marketplace_agents: boolean;
|
||||
my_agents: boolean;
|
||||
providers: boolean;
|
||||
};
|
||||
createdBy: string[];
|
||||
}
|
||||
|
||||
export type CategoryCounts = Record<CategoryKey, number>;
|
||||
@@ -1,111 +1,14 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useBlockMenuSearch } from "./useBlockMenuSearch";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
|
||||
import { Block } from "../Block";
|
||||
import { UGCAgentBlock } from "../UGCAgentBlock";
|
||||
import { getSearchItemType } from "./helper";
|
||||
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NoSearchResult } from "../NoSearchResult";
|
||||
import { BlockMenuFilters } from "../BlockMenuFilters/BlockMenuFilters";
|
||||
import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearchContent";
|
||||
|
||||
export const BlockMenuSearch = () => {
|
||||
const {
|
||||
searchResults,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
searchLoading,
|
||||
handleAddLibraryAgent,
|
||||
handleAddMarketplaceAgent,
|
||||
addingLibraryAgentId,
|
||||
addingMarketplaceAgentSlug,
|
||||
} = useBlockMenuSearch();
|
||||
const { searchQuery } = useBlockMenuStore();
|
||||
|
||||
if (searchLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
blockMenuContainerStyle,
|
||||
"flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<LoadingSpinner className="size-13" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return <NoSearchResult />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={blockMenuContainerStyle}>
|
||||
<BlockMenuFilters />
|
||||
<Text variant="body-medium">Search results</Text>
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner className="size-13" />}
|
||||
className="space-y-2.5"
|
||||
>
|
||||
{searchResults.map((item: SearchResponseItemsItem, index: number) => {
|
||||
const { type, data } = getSearchItemType(item);
|
||||
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
|
||||
switch (type) {
|
||||
case "store_agent":
|
||||
return (
|
||||
<MarketplaceAgentBlock
|
||||
key={index}
|
||||
slug={data.slug}
|
||||
highlightedText={searchQuery}
|
||||
title={data.agent_name}
|
||||
image_url={data.agent_image}
|
||||
creator_name={data.creator}
|
||||
number_of_runs={data.runs}
|
||||
loading={addingMarketplaceAgentSlug === data.slug}
|
||||
onClick={() =>
|
||||
handleAddMarketplaceAgent({
|
||||
creator_name: data.creator,
|
||||
slug: data.slug,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "block":
|
||||
return (
|
||||
<Block
|
||||
key={index}
|
||||
title={data.name}
|
||||
highlightedText={searchQuery}
|
||||
description={data.description}
|
||||
blockData={data}
|
||||
/>
|
||||
);
|
||||
|
||||
case "library_agent":
|
||||
return (
|
||||
<UGCAgentBlock
|
||||
key={index}
|
||||
title={data.name}
|
||||
highlightedText={searchQuery}
|
||||
image_url={data.image_url}
|
||||
version={data.graph_version}
|
||||
edited_time={data.updated_at}
|
||||
isLoading={addingLibraryAgentId === data.id}
|
||||
onClick={() => handleAddLibraryAgent(data)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
<BlockMenuSearchContent />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { getSearchItemType } from "./helper";
|
||||
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
|
||||
import { Block } from "../Block";
|
||||
import { UGCAgentBlock } from "../UGCAgentBlock";
|
||||
import { useBlockMenuSearchContent } from "./useBlockMenuSearchContent";
|
||||
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { NoSearchResult } from "../NoSearchResult";
|
||||
|
||||
export const BlockMenuSearchContent = () => {
|
||||
const {
|
||||
searchResults,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
searchLoading,
|
||||
handleAddLibraryAgent,
|
||||
handleAddMarketplaceAgent,
|
||||
addingLibraryAgentId,
|
||||
addingMarketplaceAgentSlug,
|
||||
} = useBlockMenuSearchContent();
|
||||
|
||||
const { searchQuery } = useBlockMenuStore();
|
||||
|
||||
if (searchLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
blockMenuContainerStyle,
|
||||
"flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<LoadingSpinner className="size-13" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return <NoSearchResult />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner className="size-13" />}
|
||||
className="space-y-2.5"
|
||||
>
|
||||
{searchResults.map((item: SearchResponseItemsItem, index: number) => {
|
||||
const { type, data } = getSearchItemType(item);
|
||||
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
|
||||
switch (type) {
|
||||
case "store_agent":
|
||||
return (
|
||||
<MarketplaceAgentBlock
|
||||
key={index}
|
||||
slug={data.slug}
|
||||
highlightedText={searchQuery}
|
||||
title={data.agent_name}
|
||||
image_url={data.agent_image}
|
||||
creator_name={data.creator}
|
||||
number_of_runs={data.runs}
|
||||
loading={addingMarketplaceAgentSlug === data.slug}
|
||||
onClick={() =>
|
||||
handleAddMarketplaceAgent({
|
||||
creator_name: data.creator,
|
||||
slug: data.slug,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "block":
|
||||
return (
|
||||
<Block
|
||||
key={index}
|
||||
title={data.name}
|
||||
highlightedText={searchQuery}
|
||||
description={data.description}
|
||||
blockData={data}
|
||||
/>
|
||||
);
|
||||
|
||||
case "library_agent":
|
||||
return (
|
||||
<UGCAgentBlock
|
||||
key={index}
|
||||
title={data.name}
|
||||
highlightedText={searchQuery}
|
||||
image_url={data.image_url}
|
||||
version={data.graph_version}
|
||||
edited_time={data.updated_at}
|
||||
isLoading={addingLibraryAgentId === data.id}
|
||||
onClick={() => handleAddLibraryAgent(data)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
);
|
||||
};
|
||||
@@ -23,9 +23,19 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||
|
||||
export const useBlockMenuSearchContent = () => {
|
||||
const {
|
||||
searchQuery,
|
||||
searchId,
|
||||
setSearchId,
|
||||
filters,
|
||||
setCreatorsList,
|
||||
creators,
|
||||
setCategoryCounts,
|
||||
} = useBlockMenuStore();
|
||||
|
||||
export const useBlockMenuSearch = () => {
|
||||
const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
|
||||
const { toast } = useToast();
|
||||
const { addAgentToBuilder, addLibraryAgentToBuilder } =
|
||||
useAddAgentToBuilder();
|
||||
@@ -57,6 +67,8 @@ export const useBlockMenuSearch = () => {
|
||||
page_size: 8,
|
||||
search_query: searchQuery,
|
||||
search_id: searchId,
|
||||
filter: filters.length > 0 ? filters : undefined,
|
||||
by_creator: creators.length > 0 ? creators : undefined,
|
||||
},
|
||||
{
|
||||
query: { getNextPageParam: getPaginationNextPageNumber },
|
||||
@@ -98,6 +110,26 @@ export const useBlockMenuSearch = () => {
|
||||
}
|
||||
}, [searchQueryData, searchId, setSearchId]);
|
||||
|
||||
// from all the results, we need to get all the unique creators
|
||||
useEffect(() => {
|
||||
if (!searchQueryData?.pages?.length) {
|
||||
return;
|
||||
}
|
||||
const latestData = okData(searchQueryData.pages.at(-1));
|
||||
setCategoryCounts(
|
||||
(latestData?.total_items as Record<
|
||||
GetV2BuilderSearchFilterAnyOfItem,
|
||||
number
|
||||
>) || {
|
||||
blocks: 0,
|
||||
integrations: 0,
|
||||
marketplace_agents: 0,
|
||||
my_agents: 0,
|
||||
},
|
||||
);
|
||||
setCreatorsList(latestData?.items || []);
|
||||
}, [searchQueryData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchId && !searchQuery) {
|
||||
resetSearchSession();
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "lucide-react";
|
||||
import React, { ButtonHTMLAttributes } from "react";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import React, { ButtonHTMLAttributes, useState } from "react";
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
selected?: boolean;
|
||||
@@ -16,39 +18,51 @@ export const FilterChip: React.FC<Props> = ({
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none transition-transform duration-300 ease-in-out",
|
||||
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
|
||||
selected && "border-0 bg-violet-700 hover:border",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span
|
||||
<AnimatePresence mode="wait">
|
||||
<Button
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn(
|
||||
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
|
||||
selected && "text-zinc-50",
|
||||
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none",
|
||||
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
|
||||
selected && "border-0 bg-violet-700 hover:border",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{selected && (
|
||||
<>
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50 transition-all duration-300 ease-in-out group-hover:hidden">
|
||||
<X
|
||||
className="h-3 w-3 rounded-full text-violet-700"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</span>
|
||||
{number !== undefined && (
|
||||
<span className="hidden h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50 transition-all duration-300 ease-in-out animate-in fade-in zoom-in group-hover:flex">
|
||||
{number > 100 ? "100+" : number}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
|
||||
selected && "text-zinc-50",
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{selected && !isHovered && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0.5, scale: 0.5, filter: "blur(20px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0.5, scale: 0.5, filter: "blur(20px)" }}
|
||||
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50"
|
||||
>
|
||||
<XIcon size={12} weight="bold" className="text-violet-700" />
|
||||
</motion.span>
|
||||
)}
|
||||
{number !== undefined && isHovered && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0.5, scale: 0.5, filter: "blur(10px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0.5, scale: 0.5, filter: "blur(10px)" }}
|
||||
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
|
||||
className="flex h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50"
|
||||
>
|
||||
{number > 100 ? "100+" : number}
|
||||
</motion.span>
|
||||
)}
|
||||
</Button>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { FilterChip } from "../FilterChip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryKey } from "../BlockMenuFilters/types";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { Checkbox } from "@/components/__legacy__/ui/checkbox";
|
||||
import { useFilterSheet } from "./useFilterSheet";
|
||||
import { INITIAL_CREATORS_TO_SHOW } from "./constant";
|
||||
|
||||
export function FilterSheet({
|
||||
categories,
|
||||
}: {
|
||||
categories: Array<{ key: CategoryKey; name: string }>;
|
||||
}) {
|
||||
const {
|
||||
isOpen,
|
||||
localCategories,
|
||||
localCreators,
|
||||
displayedCreatorsCount,
|
||||
handleLocalCategoryChange,
|
||||
handleToggleShowMoreCreators,
|
||||
handleLocalCreatorChange,
|
||||
handleClearFilters,
|
||||
handleCloseButton,
|
||||
handleApplyFilters,
|
||||
hasLocalActiveFilters,
|
||||
visibleCreators,
|
||||
creators,
|
||||
handleOpenFilters,
|
||||
hasActiveFilters,
|
||||
} = useFilterSheet();
|
||||
|
||||
return (
|
||||
<div className="m-0 inline w-fit p-0">
|
||||
<FilterChip
|
||||
name={hasActiveFilters() ? "Edit filters" : "All filters"}
|
||||
onClick={handleOpenFilters}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute bottom-2 left-2 top-2 z-20 w-3/4 max-w-[22.5rem] space-y-4 overflow-hidden rounded-[0.75rem] bg-white pb-4 shadow-[0_4px_12px_2px_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
initial={{ x: "-100%", filter: "blur(10px)" }}
|
||||
animate={{ x: 0, filter: "blur(0px)" }}
|
||||
exit={{ x: "-110%", filter: "blur(10px)" }}
|
||||
transition={{ duration: 0.4, type: "spring", bounce: 0.2 }}
|
||||
>
|
||||
{/* Top section */}
|
||||
<div className="flex items-center justify-between px-5 pt-4">
|
||||
<Text variant="body">Filters</Text>
|
||||
<Button
|
||||
className="p-0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCloseButton}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="h-[1px] w-full text-zinc-300" />
|
||||
|
||||
{/* Category section */}
|
||||
<div className="space-y-4 px-5">
|
||||
<Text variant="large">Categories</Text>
|
||||
<div className="space-y-2">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.key}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={category.key}
|
||||
checked={localCategories.includes(category.key)}
|
||||
onCheckedChange={() =>
|
||||
handleLocalCategoryChange(category.key)
|
||||
}
|
||||
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
|
||||
/>
|
||||
<label
|
||||
htmlFor={category.key}
|
||||
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
|
||||
>
|
||||
{category.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created by section */}
|
||||
<div className="space-y-4 px-5">
|
||||
<p className="font-sans text-base font-medium text-zinc-800">
|
||||
Created by
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{visibleCreators.map((creator, i) => (
|
||||
<div key={i} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`creator-${creator}`}
|
||||
checked={localCreators.includes(creator)}
|
||||
onCheckedChange={() => handleLocalCreatorChange(creator)}
|
||||
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`creator-${creator}`}
|
||||
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
|
||||
>
|
||||
{creator}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{creators.length > INITIAL_CREATORS_TO_SHOW && (
|
||||
<Button
|
||||
variant={"link"}
|
||||
className="m-0 p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 underline hover:text-zinc-600"
|
||||
onClick={handleToggleShowMoreCreators}
|
||||
>
|
||||
{displayedCreatorsCount < creators.length ? "More" : "Less"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer section */}
|
||||
<div className="fixed bottom-0 flex w-full justify-between gap-3 border-t border-zinc-200 bg-white px-5 py-3">
|
||||
<Button
|
||||
size="small"
|
||||
variant={"outline"}
|
||||
onClick={handleClearFilters}
|
||||
className="rounded-[8px] px-2 py-1.5"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleApplyFilters}
|
||||
disabled={!hasLocalActiveFilters()}
|
||||
className="rounded-[8px] px-2 py-1.5"
|
||||
>
|
||||
Apply filters
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const INITIAL_CREATORS_TO_SHOW = 5;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
|
||||
import { useState } from "react";
|
||||
import { INITIAL_CREATORS_TO_SHOW } from "./constant";
|
||||
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||
|
||||
export const useFilterSheet = () => {
|
||||
const { filters, creators_list, creators, setFilters, setCreators } =
|
||||
useBlockMenuStore();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localCategories, setLocalCategories] =
|
||||
useState<GetV2BuilderSearchFilterAnyOfItem[]>(filters);
|
||||
const [localCreators, setLocalCreators] = useState<string[]>(creators);
|
||||
const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
|
||||
INITIAL_CREATORS_TO_SHOW,
|
||||
);
|
||||
|
||||
const handleLocalCategoryChange = (
|
||||
category: GetV2BuilderSearchFilterAnyOfItem,
|
||||
) => {
|
||||
setLocalCategories((prev) => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter((c) => c !== category);
|
||||
}
|
||||
return [...prev, category];
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
return filters.length > 0 || creators.length > 0;
|
||||
};
|
||||
|
||||
const handleToggleShowMoreCreators = () => {
|
||||
if (displayedCreatorsCount < creators.length) {
|
||||
setDisplayedCreatorsCount(creators.length);
|
||||
} else {
|
||||
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalCreatorChange = (creator: string) => {
|
||||
setLocalCreators((prev) => {
|
||||
if (prev.includes(creator)) {
|
||||
return prev.filter((c) => c !== creator);
|
||||
}
|
||||
return [...prev, creator];
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setLocalCategories([]);
|
||||
setLocalCreators([]);
|
||||
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
|
||||
};
|
||||
|
||||
const handleCloseButton = () => {
|
||||
setIsOpen(false);
|
||||
setLocalCategories(filters);
|
||||
setLocalCreators(creators);
|
||||
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters(localCategories);
|
||||
setCreators(localCreators);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenFilters = () => {
|
||||
setIsOpen(true);
|
||||
setLocalCategories(filters);
|
||||
setLocalCreators(creators);
|
||||
};
|
||||
|
||||
const hasLocalActiveFilters = () => {
|
||||
return localCategories.length > 0 || localCreators.length > 0;
|
||||
};
|
||||
|
||||
const visibleCreators = creators_list.slice(0, displayedCreatorsCount);
|
||||
|
||||
return {
|
||||
creators,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
localCategories,
|
||||
localCreators,
|
||||
displayedCreatorsCount,
|
||||
setDisplayedCreatorsCount,
|
||||
handleLocalCategoryChange,
|
||||
handleToggleShowMoreCreators,
|
||||
handleLocalCreatorChange,
|
||||
handleClearFilters,
|
||||
handleCloseButton,
|
||||
handleOpenFilters,
|
||||
handleApplyFilters,
|
||||
hasLocalActiveFilters,
|
||||
visibleCreators,
|
||||
hasActiveFilters,
|
||||
};
|
||||
};
|
||||
@@ -7,10 +7,10 @@ import { useNewControlPanel } from "./useNewControlPanel";
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
// import { ControlPanelButton } from "../ControlPanelButton";
|
||||
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
|
||||
export type Control = {
|
||||
@@ -56,7 +56,7 @@ export const NewControlPanel = memo(
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-10 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
"absolute left-4 top-10 z-10 overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useNewSaveControl } from "./useNewSaveControl";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||
import { FloppyDiskIcon } from "@phosphor-icons/react";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { useNewSaveControl } from "./useNewSaveControl";
|
||||
|
||||
export const NewSaveControl = () => {
|
||||
const { form, isSaving, graphVersion, handleSave } = useNewSaveControl();
|
||||
@@ -33,7 +32,7 @@ export const NewSaveControl = () => {
|
||||
selected={saveControlOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
<FloppyDiskIcon className="h-6 w-6" />
|
||||
<FloppyDiskIcon className="size-5" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
|
||||
import React from "react";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
|
||||
import { useGraphMenu } from "./useGraphMenu";
|
||||
|
||||
interface GraphSearchMenuProps {
|
||||
@@ -50,7 +50,7 @@ export const GraphSearchMenu: React.FC<GraphSearchMenuProps> = ({
|
||||
selected={blockMenuSelected === "search"}
|
||||
className="rounded-none"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-5 w-6" strokeWidth={2} />
|
||||
<MagnifyingGlassIcon className="size-5" strokeWidth={2} />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ControlPanelButton } from "./ControlPanelButton";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import { useHistoryStore } from "../../stores/historyStore";
|
||||
import { ControlPanelButton } from "./ControlPanelButton";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -43,7 +43,7 @@ export const UndoRedoButtons = () => {
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canUndo()} onClick={undo}>
|
||||
<ArrowUUpLeftIcon className="h-6 w-6" />
|
||||
<ArrowUUpLeftIcon className="size-5" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Undo</TooltipContent>
|
||||
@@ -52,7 +52,7 @@ export const UndoRedoButtons = () => {
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canRedo()} onClick={redo}>
|
||||
<ArrowUUpRightIcon className="h-6 w-6" />
|
||||
<ArrowUUpRightIcon className="size-5" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Redo</TooltipContent>
|
||||
|
||||
@@ -4,19 +4,12 @@ import {
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Clipboard, Maximize2 } from "lucide-react";
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../../../components/__legacy__/ui/dialog";
|
||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||
import { ScrollArea } from "../../../../../components/__legacy__/ui/scroll-area";
|
||||
import { Separator } from "../../../../../components/__legacy__/ui/separator";
|
||||
@@ -120,138 +113,155 @@ const ExpandableOutputDialog: FC<ExpandableOutputDialogProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="flex h-[90vh] w-[90vw] max-w-4xl flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 size={20} />
|
||||
Full Output Preview
|
||||
</div>
|
||||
{enableEnhancedOutputHandling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="enhanced-rendering-toggle"
|
||||
className="cursor-pointer select-none text-sm font-normal text-gray-600"
|
||||
>
|
||||
Enhanced Rendering
|
||||
</label>
|
||||
<Switch
|
||||
id="enhanced-rendering-toggle"
|
||||
checked={useEnhancedRenderer}
|
||||
onCheckedChange={setUseEnhancedRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Execution ID: <span className="font-mono text-xs">{execId}</span>
|
||||
<br />
|
||||
Pin:{" "}
|
||||
<span className="font-semibold">{beautifyString(pinName)}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{useEnhancedRenderer && outputItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
<Dialog
|
||||
title={
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 size={20} />
|
||||
Full Output Preview
|
||||
</div>
|
||||
{enableEnhancedOutputHandling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="enhanced-rendering-toggle"
|
||||
className="cursor-pointer select-none text-sm font-normal text-gray-600"
|
||||
>
|
||||
Enhanced Rendering
|
||||
</label>
|
||||
<Switch
|
||||
id="enhanced-rendering-toggle"
|
||||
checked={useEnhancedRenderer}
|
||||
onCheckedChange={setUseEnhancedRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{data.length > 0 ? (
|
||||
useEnhancedRenderer ? (
|
||||
<div className="space-y-4">
|
||||
{outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Item {index + 1} of {data.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemData =
|
||||
typeof item === "object"
|
||||
? JSON.stringify(item, null, 2)
|
||||
: String(item);
|
||||
navigator.clipboard
|
||||
.writeText(itemData)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Item ${index + 1} copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
Copy Item
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
height: "90vh",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pb-4">
|
||||
<p className="text-sm text-zinc-600">
|
||||
Execution ID: <span className="font-mono text-xs">{execId}</span>
|
||||
<br />
|
||||
Pin:{" "}
|
||||
<span className="font-semibold">{beautifyString(pinName)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.length} item{data.length !== 1 ? "s" : ""} total
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!useEnhancedRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyData}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
Copy All
|
||||
</Button>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{useEnhancedRenderer && outputItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{data.length > 0 ? (
|
||||
useEnhancedRenderer ? (
|
||||
<div className="space-y-4">
|
||||
{outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Item {index + 1} of {data.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemData =
|
||||
typeof item === "object"
|
||||
? JSON.stringify(item, null, 2)
|
||||
: String(item);
|
||||
navigator.clipboard
|
||||
.writeText(itemData)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Item ${index + 1} copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
Copy Item
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<Dialog.Footer className="flex justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.length} item{data.length !== 1 ? "s" : ""} total
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!useEnhancedRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyData}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
Copy All
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ConnectionData,
|
||||
CustomNodeData,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
||||
import { NodeTableInput } from "@/components/node-table-input";
|
||||
import {
|
||||
BlockIOArraySubSchema,
|
||||
BlockIOBooleanSubSchema,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { FC, useCallback, useEffect, useState } from "react";
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||
import {
|
||||
BlockIOTableSubSchema,
|
||||
TableRow,
|
||||
TableCellValue,
|
||||
TableRow,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { Input } from "./atoms/Input/Input";
|
||||
import { Button } from "./atoms/Button/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "../../../../../components/atoms/Button/Button";
|
||||
import { Input } from "../../../../../components/atoms/Input/Input";
|
||||
|
||||
interface NodeTableInputProps {
|
||||
/** Unique identifier for the node in the builder graph */
|
||||
@@ -1,17 +1,11 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import type {
|
||||
CredentialsMetaInput,
|
||||
GraphMeta,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
|
||||
|
||||
interface RunInputDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -70,21 +64,33 @@ export function RunnerInputDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={doClose}>
|
||||
<DialogContent className="flex w-[90vw] max-w-4xl flex-col p-10">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">Run your agent</DialogTitle>
|
||||
<DialogDescription className="mt-2">{graph.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AgentRunDraftView
|
||||
className="p-0"
|
||||
graph={graph}
|
||||
doRun={doRun ? handleRun : undefined}
|
||||
onRun={doRun ? undefined : doClose}
|
||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||
onCreateSchedule={doCreateSchedule ? undefined : doClose}
|
||||
/>
|
||||
</DialogContent>
|
||||
<Dialog
|
||||
title="Run your agent"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) doClose();
|
||||
},
|
||||
}}
|
||||
onClose={doClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col p-10">
|
||||
<p className="mt-2 text-sm text-zinc-600">{graph.name}</p>
|
||||
<AgentRunDraftView
|
||||
className="p-0"
|
||||
graph={graph}
|
||||
doRun={doRun ? handleRun : undefined}
|
||||
onRun={doRun ? undefined : doClose}
|
||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||
onCreateSchedule={doCreateSchedule ? undefined : doClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
|
||||
import { useGraphStore } from "../stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
draftService,
|
||||
clearTempFlowId,
|
||||
getTempFlowId,
|
||||
} from "@/services/builder-draft/draft-service";
|
||||
|
||||
export type SaveGraphOptions = {
|
||||
showToast?: boolean;
|
||||
@@ -52,12 +57,19 @@ export const useSaveGraph = ({
|
||||
const { mutateAsync: createNewGraph, isPending: isCreating } =
|
||||
usePostV1CreateNewGraph({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
onSuccess: async (response) => {
|
||||
const data = response.data as GraphModel;
|
||||
setQueryStates({
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
|
||||
const tempFlowId = getTempFlowId();
|
||||
if (tempFlowId) {
|
||||
await draftService.deleteDraft(tempFlowId);
|
||||
clearTempFlowId();
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
if (showToast) {
|
||||
toast({
|
||||
@@ -82,12 +94,18 @@ export const useSaveGraph = ({
|
||||
const { mutateAsync: updateGraph, isPending: isUpdating } =
|
||||
usePutV1UpdateGraphVersion({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
onSuccess: async (response) => {
|
||||
const data = response.data as GraphModel;
|
||||
setQueryStates({
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
|
||||
// Clear the draft for this flow after successful save
|
||||
if (data.id) {
|
||||
await draftService.deleteDraft(data.id);
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
if (showToast) {
|
||||
toast({
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs";
|
||||
import { useBuilderView } from "./components/BuilderViewTabs/useBuilderViewTabs";
|
||||
import { Flow } from "./components/FlowEditor/Flow/Flow";
|
||||
import { useBuilderView } from "./useBuilderView";
|
||||
|
||||
function BuilderContent() {
|
||||
const query = useSearchParams();
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { create } from "zustand";
|
||||
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
|
||||
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||
import { getSearchItemType } from "../components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||
|
||||
type BlockMenuStore = {
|
||||
searchQuery: string;
|
||||
searchId: string | undefined;
|
||||
defaultState: DefaultStateType;
|
||||
integration: string | undefined;
|
||||
filters: GetV2BuilderSearchFilterAnyOfItem[];
|
||||
creators: string[];
|
||||
creators_list: string[];
|
||||
categoryCounts: Record<GetV2BuilderSearchFilterAnyOfItem, number>;
|
||||
|
||||
setCategoryCounts: (
|
||||
counts: Record<GetV2BuilderSearchFilterAnyOfItem, number>,
|
||||
) => void;
|
||||
setCreatorsList: (searchData: SearchResponseItemsItem[]) => void;
|
||||
addCreator: (creator: string) => void;
|
||||
setCreators: (creators: string[]) => void;
|
||||
removeCreator: (creator: string) => void;
|
||||
addFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
|
||||
setFilters: (filters: GetV2BuilderSearchFilterAnyOfItem[]) => void;
|
||||
removeFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchId: (id: string | undefined) => void;
|
||||
setDefaultState: (state: DefaultStateType) => void;
|
||||
@@ -19,11 +37,44 @@ export const useBlockMenuStore = create<BlockMenuStore>((set) => ({
|
||||
searchId: undefined,
|
||||
defaultState: DefaultStateType.SUGGESTION,
|
||||
integration: undefined,
|
||||
filters: [],
|
||||
creators: [], // creator filters that are applied to the search results
|
||||
creators_list: [], // all creators that are available to filter by
|
||||
categoryCounts: {
|
||||
blocks: 0,
|
||||
integrations: 0,
|
||||
marketplace_agents: 0,
|
||||
my_agents: 0,
|
||||
},
|
||||
|
||||
setCategoryCounts: (counts) => set({ categoryCounts: counts }),
|
||||
setCreatorsList: (searchData) => {
|
||||
const marketplaceAgents = searchData.filter((item) => {
|
||||
return getSearchItemType(item).type === "store_agent";
|
||||
}) as StoreAgent[];
|
||||
|
||||
const newCreators = marketplaceAgents.map((agent) => agent.creator);
|
||||
|
||||
set((state) => ({
|
||||
creators_list: Array.from(
|
||||
new Set([...state.creators_list, ...newCreators]),
|
||||
),
|
||||
}));
|
||||
},
|
||||
setCreators: (creators) => set({ creators }),
|
||||
setFilters: (filters) => set({ filters }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSearchId: (id) => set({ searchId: id }),
|
||||
setDefaultState: (state) => set({ defaultState: state }),
|
||||
setIntegration: (integration) => set({ integration }),
|
||||
addFilter: (filter) =>
|
||||
set((state) => ({ filters: [...state.filters, filter] })),
|
||||
removeFilter: (filter) =>
|
||||
set((state) => ({ filters: state.filters.filter((f) => f !== filter) })),
|
||||
addCreator: (creator) =>
|
||||
set((state) => ({ creators: [...state.creators, creator] })),
|
||||
removeCreator: (creator) =>
|
||||
set((state) => ({ creators: state.creators.filter((c) => c !== creator) })),
|
||||
reset: () =>
|
||||
set({
|
||||
searchQuery: "",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
|
||||
import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
|
||||
import { MarkerType } from "@xyflow/react";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
||||
|
||||
type EdgeStore = {
|
||||
edges: CustomEdge[];
|
||||
@@ -13,6 +14,8 @@ type EdgeStore = {
|
||||
removeEdge: (edgeId: string) => void;
|
||||
upsertMany: (edges: CustomEdge[]) => void;
|
||||
|
||||
removeEdgesByHandlePrefix: (nodeId: string, handlePrefix: string) => void;
|
||||
|
||||
getNodeEdges: (nodeId: string) => CustomEdge[];
|
||||
isInputConnected: (nodeId: string, handle: string) => boolean;
|
||||
isOutputConnected: (nodeId: string, handle: string) => boolean;
|
||||
@@ -79,11 +82,27 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
return { edges: Array.from(byKey.values()) };
|
||||
}),
|
||||
|
||||
removeEdgesByHandlePrefix: (nodeId, handlePrefix) =>
|
||||
set((state) => ({
|
||||
edges: state.edges.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.target === nodeId &&
|
||||
e.targetHandle &&
|
||||
e.targetHandle.startsWith(handlePrefix)
|
||||
),
|
||||
),
|
||||
})),
|
||||
|
||||
getNodeEdges: (nodeId) =>
|
||||
get().edges.filter((e) => e.source === nodeId || e.target === nodeId),
|
||||
|
||||
isInputConnected: (nodeId, handle) =>
|
||||
get().edges.some((e) => e.target === nodeId && e.targetHandle === handle),
|
||||
isInputConnected: (nodeId, handle) => {
|
||||
const cleanedHandle = cleanUpHandleId(handle);
|
||||
return get().edges.some(
|
||||
(e) => e.target === nodeId && e.targetHandle === cleanedHandle,
|
||||
);
|
||||
},
|
||||
|
||||
isOutputConnected: (nodeId, handle) =>
|
||||
get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle),
|
||||
@@ -105,20 +124,21 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
targetNodeId: string,
|
||||
executionResult: NodeExecutionResult,
|
||||
) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.map((edge) => {
|
||||
set((state) => {
|
||||
let hasChanges = false;
|
||||
|
||||
const newEdges = state.edges.map((edge) => {
|
||||
if (edge.target !== targetNodeId) {
|
||||
return edge;
|
||||
}
|
||||
|
||||
const beadData =
|
||||
edge.data?.beadData ??
|
||||
new Map<string, NodeExecutionResult["status"]>();
|
||||
const beadData = new Map(edge.data?.beadData ?? new Map());
|
||||
|
||||
if (
|
||||
edge.targetHandle &&
|
||||
edge.targetHandle in executionResult.input_data
|
||||
) {
|
||||
const inputValue = edge.targetHandle
|
||||
? executionResult.input_data[edge.targetHandle]
|
||||
: undefined;
|
||||
|
||||
if (inputValue !== undefined && inputValue !== null) {
|
||||
beadData.set(executionResult.node_exec_id, executionResult.status);
|
||||
}
|
||||
|
||||
@@ -136,6 +156,11 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
beadUp = beadDown + 1;
|
||||
}
|
||||
|
||||
if (edge.data?.beadUp === beadUp && edge.data?.beadDown === beadDown) {
|
||||
return edge;
|
||||
}
|
||||
|
||||
hasChanges = true;
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
@@ -145,8 +170,10 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
beadData,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
return hasChanges ? { edges: newEdges } : state;
|
||||
});
|
||||
},
|
||||
|
||||
resetEdgeBeads: () => {
|
||||
|
||||
@@ -13,6 +13,10 @@ import { useHistoryStore } from "./historyStore";
|
||||
import { useEdgeStore } from "./edgeStore";
|
||||
import { BlockUIType } from "../components/types";
|
||||
import { pruneEmptyValues } from "@/lib/utils";
|
||||
import {
|
||||
ensurePathExists,
|
||||
parseHandleIdToPath,
|
||||
} from "@/components/renderers/InputRenderer/helpers";
|
||||
|
||||
// Minimum movement (in pixels) required before logging position change to history
|
||||
// Prevents spamming history with small movements when clicking on inputs inside blocks
|
||||
@@ -62,6 +66,8 @@ type NodeStore = {
|
||||
errors: { [key: string]: string },
|
||||
) => void;
|
||||
clearAllNodeErrors: () => void; // Add this
|
||||
|
||||
syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
@@ -305,4 +311,35 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
syncHardcodedValuesWithHandleIds: (nodeId: string) => {
|
||||
const node = get().nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const handleIds = useEdgeStore.getState().getAllHandleIdsOfANode(nodeId);
|
||||
const additionalHandles = handleIds.filter((h) => h.includes("_#_"));
|
||||
|
||||
if (additionalHandles.length === 0) return;
|
||||
|
||||
const hardcodedValues = JSON.parse(
|
||||
JSON.stringify(node.data.hardcodedValues || {}),
|
||||
);
|
||||
|
||||
let modified = false;
|
||||
|
||||
additionalHandles.forEach((handleId) => {
|
||||
const segments = parseHandleIdToPath(handleId);
|
||||
if (ensurePathExists(hardcodedValues, segments)) {
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, hardcodedValues } } : n,
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { BuilderView } from "./BuilderViewTabs";
|
||||
import { BuilderView } from "./components/BuilderViewTabs/BuilderViewTabs";
|
||||
|
||||
export function useBuilderView() {
|
||||
const isNewFlowEditorEnabled = useGetFlag(Flag.NEW_FLOW_EDITOR);
|
||||
@@ -143,6 +143,7 @@ export function CredentialsInput({
|
||||
size="small"
|
||||
onClick={handleActionButtonClick}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
@@ -155,6 +156,7 @@ export function CredentialsInput({
|
||||
size="small"
|
||||
onClick={handleActionButtonClick}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { getV1GetGraphVersion } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import {
|
||||
getGetV2ListLibraryAgentsQueryKey,
|
||||
useDeleteV2DeleteLibraryAgent,
|
||||
} from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { exportAsJSONFile } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/utils/time";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
|
||||
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
|
||||
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
|
||||
@@ -30,6 +38,41 @@ export function EmptyTasks({
|
||||
onScheduleCreated,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
|
||||
|
||||
async function handleDeleteAgent() {
|
||||
if (!agent.id) return;
|
||||
|
||||
setIsDeletingAgent(true);
|
||||
|
||||
try {
|
||||
await deleteAgent({ libraryAgentId: agent.id });
|
||||
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
|
||||
toast({ title: "Agent deleted" });
|
||||
setShowDeleteDialog(false);
|
||||
router.push("/library");
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "Failed to delete agent",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
@@ -147,9 +190,50 @@ export function EmptyTasks({
|
||||
<Button variant="secondary" size="small" onClick={handleExport}>
|
||||
Export agent to file
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Delete agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen: showDeleteDialog,
|
||||
set: setShowDeleteDialog,
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
title="Delete agent"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete this agent? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isDeletingAgent}
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAgent}
|
||||
loading={isDeletingAgent}
|
||||
>
|
||||
Delete Agent
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
import { SelectedViewLayout } from "../SelectedViewLayout";
|
||||
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
|
||||
import { SelectedScheduleActions } from "./components/SelectedScheduleActions/SelectedScheduleActions";
|
||||
import { useSelectedScheduleView } from "./useSelectedScheduleView";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { EyeIcon } from "@phosphor-icons/react";
|
||||
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
|
||||
import { useScheduleDetailHeader } from "../../RunDetailHeader/useScheduleDetailHeader";
|
||||
import { SelectedActionsWrap } from "../../SelectedActionsWrap";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function SelectedScheduleActions({ agent, scheduleId }: Props) {
|
||||
const { openInBuilderHref } = useScheduleDetailHeader(
|
||||
agent.graph_id,
|
||||
scheduleId,
|
||||
agent.graph_version,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedActionsWrap>
|
||||
{openInBuilderHref && (
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
as="NextLink"
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
aria-label="View scheduled task details"
|
||||
>
|
||||
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
|
||||
</Button>
|
||||
)}
|
||||
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
|
||||
</SelectedActionsWrap>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { EyeIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { AgentActionsDropdown } from "../../../AgentActionsDropdown";
|
||||
import { SelectedActionsWrap } from "../../../SelectedActionsWrap";
|
||||
import { useSelectedScheduleActions } from "./useSelectedScheduleActions";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function SelectedScheduleActions({
|
||||
agent,
|
||||
scheduleId,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const {
|
||||
openInBuilderHref,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
handleDelete,
|
||||
isDeleting,
|
||||
} = useSelectedScheduleActions({ agent, scheduleId, onDeleted });
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedActionsWrap>
|
||||
{openInBuilderHref && (
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
as="NextLink"
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
aria-label="View scheduled task details"
|
||||
>
|
||||
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Delete schedule"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<TrashIcon weight="bold" size={18} />
|
||||
)}
|
||||
</Button>
|
||||
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
|
||||
</SelectedActionsWrap>
|
||||
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen: showDeleteDialog,
|
||||
set: setShowDeleteDialog,
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
title="Delete schedule"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete this schedule? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
Delete Schedule
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
|
||||
useDeleteV1DeleteExecutionSchedule,
|
||||
} from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UseSelectedScheduleActionsProps {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function useSelectedScheduleActions({
|
||||
agent,
|
||||
scheduleId,
|
||||
onDeleted,
|
||||
}: UseSelectedScheduleActionsProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const deleteMutation = useDeleteV1DeleteExecutionSchedule({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Schedule deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryOptions(
|
||||
agent.graph_id,
|
||||
).queryKey,
|
||||
});
|
||||
setShowDeleteDialog(false);
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
toast({
|
||||
title: "Failed to delete schedule",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!scheduleId) return;
|
||||
deleteMutation.mutate({ scheduleId });
|
||||
}
|
||||
|
||||
const openInBuilderHref = `/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`;
|
||||
|
||||
return {
|
||||
openInBuilderHref,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
handleDelete,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Heart } from "lucide-react";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
|
||||
export default function FavoritesSection() {
|
||||
export function FavoritesSection() {
|
||||
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
|
||||
const {
|
||||
allAgents: favoriteAgents,
|
||||
@@ -33,7 +32,7 @@ export default function FavoritesSection() {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-[10px] p-2 pb-[10px]">
|
||||
<Heart className="h-5 w-5 fill-red-500 text-red-500" />
|
||||
<HeartIcon className="h-5 w-5 fill-red-500 text-red-500" />
|
||||
<span className="font-poppin text-[18px] font-semibold leading-[28px] text-neutral-800">
|
||||
Favorites
|
||||
</span>
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
// import LibraryNotificationDropdown from "./library-notification-dropdown";
|
||||
import { LibrarySearchBar } from "../LibrarySearchBar/LibrarySearchBar";
|
||||
import LibraryUploadAgentDialog from "../LibraryUploadAgentDialog/LibraryUploadAgentDialog";
|
||||
import LibrarySearchBar from "../LibrarySearchBar/LibrarySearchBar";
|
||||
|
||||
type LibraryActionHeaderProps = Record<string, never>;
|
||||
interface Props {
|
||||
setSearchTerm: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* LibraryActionHeader component - Renders a header with search, notifications and filters
|
||||
*/
|
||||
const LibraryActionHeader: React.FC<LibraryActionHeaderProps> = ({}) => {
|
||||
export function LibraryActionHeader({ setSearchTerm }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-[32px] hidden items-start justify-between md:flex">
|
||||
{/* <LibraryNotificationDropdown /> */}
|
||||
<LibrarySearchBar />
|
||||
<div className="mb-[32px] hidden items-center justify-center gap-4 md:flex">
|
||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />
|
||||
<LibraryUploadAgentDialog />
|
||||
</div>
|
||||
|
||||
{/* Mobile and tablet */}
|
||||
<div className="flex flex-col gap-4 p-4 pt-[52px] md:hidden">
|
||||
<div className="flex w-full justify-between">
|
||||
{/* <LibraryNotificationDropdown /> */}
|
||||
<LibraryUploadAgentDialog />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<LibrarySearchBar />
|
||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryActionHeader;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import LibrarySortMenu from "../LibrarySortMenu/LibrarySortMenu";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
|
||||
|
||||
interface LibraryActionSubHeaderProps {
|
||||
interface Props {
|
||||
agentCount: number;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
|
||||
export default function LibraryActionSubHeader({
|
||||
agentCount,
|
||||
}: LibraryActionSubHeaderProps) {
|
||||
export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
|
||||
return (
|
||||
<div className="flex items-center justify-between pb-[10px]">
|
||||
<div className="flex items-center gap-[10px] p-2">
|
||||
<span className="font-poppin w-[96px] text-[18px] font-semibold leading-[28px] text-neutral-800">
|
||||
My agents
|
||||
</span>
|
||||
<span
|
||||
className="w-[70px] font-sans text-[14px] font-normal leading-6"
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<Text variant="h4">My agents</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
data-testid="agents-count"
|
||||
className="text-zinc-500"
|
||||
>
|
||||
{agentCount} agents
|
||||
</span>
|
||||
{agentCount}
|
||||
</Text>
|
||||
</div>
|
||||
<LibrarySortMenu />
|
||||
<LibrarySortMenu setLibrarySort={setLibrarySort} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,332 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CaretCircleRightIcon } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
import { Heart } from "@phosphor-icons/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
import NextLink from "next/link";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import {
|
||||
getV2ListLibraryAgentsResponse,
|
||||
getV2ListFavoriteLibraryAgentsResponse,
|
||||
} from "@/app/api/__generated__/endpoints/library/library";
|
||||
import BackendAPI, { LibraryAgentID } from "@/lib/autogpt-server-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Link } from "@/components/atoms/Link/Link";
|
||||
import { AgentCardMenu } from "./components/AgentCardMenu";
|
||||
import { FavoriteButton } from "./components/FavoriteButton";
|
||||
import { useLibraryAgentCard } from "./useLibraryAgentCard";
|
||||
|
||||
interface LibraryAgentCardProps {
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export default function LibraryAgentCard({
|
||||
agent: {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
graph_id,
|
||||
can_access_graph,
|
||||
export function LibraryAgentCard({ agent }: Props) {
|
||||
const { id, name, graph_id, can_access_graph, image_url } = agent;
|
||||
|
||||
const {
|
||||
isFromMarketplace,
|
||||
isAgentFavoritingEnabled,
|
||||
isFavorite,
|
||||
profile,
|
||||
creator_image_url,
|
||||
image_url,
|
||||
is_favorite,
|
||||
},
|
||||
}: LibraryAgentCardProps) {
|
||||
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
|
||||
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const api = new BackendAPI();
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
// Sync local state with prop when it changes (e.g., after query invalidation)
|
||||
useEffect(() => {
|
||||
setIsFavorite(is_favorite);
|
||||
}, [is_favorite]);
|
||||
|
||||
const updateQueryData = (newIsFavorite: boolean) => {
|
||||
// Update the agent in all library agent queries
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["/api/library/agents"] },
|
||||
(
|
||||
oldData:
|
||||
| InfiniteData<getV2ListLibraryAgentsResponse, number | undefined>
|
||||
| undefined,
|
||||
) => {
|
||||
if (!oldData?.pages) return oldData;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => {
|
||||
if (page.status !== 200) return page;
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
agents: page.data.agents.map((agent: LibraryAgent) =>
|
||||
agent.id === id
|
||||
? { ...agent, is_favorite: newIsFavorite }
|
||||
: agent,
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Update or remove from favorites query based on new state
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["/api/library/agents/favorites"] },
|
||||
(
|
||||
oldData:
|
||||
| InfiniteData<
|
||||
getV2ListFavoriteLibraryAgentsResponse,
|
||||
number | undefined
|
||||
>
|
||||
| undefined,
|
||||
) => {
|
||||
if (!oldData?.pages) return oldData;
|
||||
|
||||
if (newIsFavorite) {
|
||||
// Add to favorites if not already there
|
||||
const exists = oldData.pages.some(
|
||||
(page) =>
|
||||
page.status === 200 &&
|
||||
page.data.agents.some((agent: LibraryAgent) => agent.id === id),
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
const firstPage = oldData.pages[0];
|
||||
if (firstPage?.status === 200) {
|
||||
const updatedAgent = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
graph_id,
|
||||
can_access_graph,
|
||||
creator_image_url,
|
||||
image_url,
|
||||
is_favorite: true,
|
||||
};
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: [
|
||||
{
|
||||
...firstPage,
|
||||
data: {
|
||||
...firstPage.data,
|
||||
agents: [updatedAgent, ...firstPage.data.agents],
|
||||
pagination: {
|
||||
...firstPage.data.pagination,
|
||||
total_items: firstPage.data.pagination.total_items + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
...oldData.pages.slice(1).map((page) =>
|
||||
page.status === 200
|
||||
? {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
pagination: {
|
||||
...page.data.pagination,
|
||||
total_items: page.data.pagination.total_items + 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: page,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from favorites
|
||||
let removedCount = 0;
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => {
|
||||
if (page.status !== 200) return page;
|
||||
|
||||
const filteredAgents = page.data.agents.filter(
|
||||
(agent: LibraryAgent) => agent.id !== id,
|
||||
);
|
||||
|
||||
if (filteredAgents.length < page.data.agents.length) {
|
||||
removedCount = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
agents: filteredAgents,
|
||||
pagination: {
|
||||
...page.data.pagination,
|
||||
total_items:
|
||||
page.data.pagination.total_items - removedCount,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return oldData;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.preventDefault(); // Prevent navigation when clicking the heart
|
||||
e.stopPropagation();
|
||||
|
||||
if (isUpdating || !isAgentFavoritingEnabled) return;
|
||||
|
||||
const newIsFavorite = !isFavorite;
|
||||
|
||||
// Optimistic update
|
||||
setIsFavorite(newIsFavorite);
|
||||
updateQueryData(newIsFavorite);
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await api.updateLibraryAgent(id as LibraryAgentID, {
|
||||
is_favorite: newIsFavorite,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
|
||||
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
console.error("Failed to update favorite status:", error);
|
||||
setIsFavorite(!newIsFavorite);
|
||||
updateQueryData(!newIsFavorite);
|
||||
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update favorite status. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
handleToggleFavorite,
|
||||
} = useLibraryAgentCard({ agent });
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="library-agent-card"
|
||||
data-agent-id={id}
|
||||
className="group inline-flex w-full max-w-[434px] flex-col items-start justify-start gap-2.5 rounded-[26px] bg-white transition-all duration-300 hover:shadow-lg dark:bg-transparent dark:hover:shadow-gray-700"
|
||||
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white transition-all duration-300 hover:shadow-md"
|
||||
>
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
className="relative h-[200px] w-full overflow-hidden rounded-[20px]"
|
||||
>
|
||||
{!image_url ? (
|
||||
<div
|
||||
className={`h-full w-full ${
|
||||
[
|
||||
"bg-gradient-to-r from-green-200 to-blue-200",
|
||||
"bg-gradient-to-r from-pink-200 to-purple-200",
|
||||
"bg-gradient-to-r from-yellow-200 to-orange-200",
|
||||
"bg-gradient-to-r from-blue-200 to-cyan-200",
|
||||
"bg-gradient-to-r from-indigo-200 to-purple-200",
|
||||
][parseInt(id.slice(0, 8), 16) % 5]
|
||||
}`}
|
||||
style={{
|
||||
backgroundSize: "200% 200%",
|
||||
animation: "gradient 15s ease infinite",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={image_url}
|
||||
alt={`${name} preview image`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
{isAgentFavoritingEnabled && (
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
className={cn(
|
||||
"absolute right-4 top-4 rounded-full bg-white/90 p-2 backdrop-blur-sm transition-all duration-200",
|
||||
"hover:scale-110 hover:bg-white",
|
||||
"focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
isUpdating && "cursor-not-allowed opacity-50",
|
||||
!isFavorite && "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
disabled={isUpdating}
|
||||
aria-label={
|
||||
isFavorite ? "Remove from favorites" : "Add to favorites"
|
||||
}
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
weight={isFavorite ? "fill" : "regular"}
|
||||
className={cn(
|
||||
"transition-colors duration-200",
|
||||
isFavorite
|
||||
? "text-red-500"
|
||||
: "text-gray-600 hover:text-red-500",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AgentCardMenu agent={agent} />
|
||||
<NextLink href={`/library/agents/${id}`} className="w-full flex-shrink-0">
|
||||
<div className="flex items-center gap-2 px-4 pt-3">
|
||||
<Avatar className="h-4 w-4 rounded-full">
|
||||
<AvatarImage
|
||||
src={
|
||||
creator_image_url
|
||||
? creator_image_url
|
||||
: "/avatar-placeholder.png"
|
||||
isFromMarketplace
|
||||
? creator_image_url || "/avatar-placeholder.png"
|
||||
: profile?.avatar_url || "/avatar-placeholder.png"
|
||||
}
|
||||
alt={`${name} creator avatar`}
|
||||
/>
|
||||
<AvatarFallback size={64}>{name.charAt(0)}</AvatarFallback>
|
||||
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="uppercase tracking-wide text-zinc-400"
|
||||
>
|
||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||
</Text>
|
||||
</div>
|
||||
</Link>
|
||||
{isAgentFavoritingEnabled && (
|
||||
<FavoriteButton
|
||||
isFavorite={isFavorite}
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
)}
|
||||
</NextLink>
|
||||
|
||||
<div className="flex w-full flex-1 flex-col px-4 py-4">
|
||||
<Link href={`/library/agents/${id}`}>
|
||||
<h3 className="mb-2 line-clamp-2 font-poppins text-2xl font-semibold leading-tight text-[#272727] dark:text-neutral-100">
|
||||
<div className="flex w-full flex-1 flex-col px-4 pb-2">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
|
||||
>
|
||||
<Text
|
||||
variant="h5"
|
||||
data-testid="library-agent-card-name"
|
||||
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
</Text>
|
||||
|
||||
<p className="line-clamp-3 flex-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
{!image_url ? (
|
||||
<div
|
||||
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
|
||||
[
|
||||
"bg-gradient-to-r from-green-200 to-blue-200",
|
||||
"bg-gradient-to-r from-pink-200 to-purple-200",
|
||||
"bg-gradient-to-r from-yellow-200 to-orange-200",
|
||||
"bg-gradient-to-r from-blue-200 to-cyan-200",
|
||||
"bg-gradient-to-r from-indigo-200 to-purple-200",
|
||||
][parseInt(id.slice(0, 8), 16) % 5]
|
||||
}`}
|
||||
style={{
|
||||
backgroundSize: "200% 200%",
|
||||
animation: "gradient 15s ease infinite",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={image_url}
|
||||
alt={`${name} preview image`}
|
||||
width={107}
|
||||
height={58}
|
||||
className="flex-shrink-0 rounded-small object-cover"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex-grow" />
|
||||
{/* Spacer */}
|
||||
|
||||
<div className="items-between mt-4 flex w-full justify-between gap-3">
|
||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
>
|
||||
See runs
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
>
|
||||
Open in builder
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGetV2ListLibraryAgentsQueryKey,
|
||||
useDeleteV2DeleteLibraryAgent,
|
||||
usePostV2ForkLibraryAgent,
|
||||
} from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { DotsThree } from "@phosphor-icons/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AgentCardMenuProps {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function AgentCardMenu({ agent }: AgentCardMenuProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||
const [isDuplicatingAgent, setIsDuplicatingAgent] = useState(false);
|
||||
|
||||
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
|
||||
const { mutateAsync: forkAgent } = usePostV2ForkLibraryAgent();
|
||||
|
||||
async function handleDuplicateAgent() {
|
||||
if (!agent.id) return;
|
||||
|
||||
setIsDuplicatingAgent(true);
|
||||
|
||||
try {
|
||||
const result = await forkAgent({ libraryAgentId: agent.id });
|
||||
|
||||
if (result.status === 200) {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Agent duplicated",
|
||||
description: `${result.data.name} has been created.`,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "Failed to duplicate agent",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDuplicatingAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAgent() {
|
||||
if (!agent.id) return;
|
||||
|
||||
setIsDeletingAgent(true);
|
||||
|
||||
try {
|
||||
await deleteAgent({ libraryAgentId: agent.id });
|
||||
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: getGetV2ListLibraryAgentsQueryKey(),
|
||||
});
|
||||
|
||||
toast({ title: "Agent deleted" });
|
||||
setShowDeleteDialog(false);
|
||||
router.push("/library");
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "Failed to delete agent",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="absolute right-2 top-1 rounded p-1.5 transition-opacity hover:bg-neutral-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="More actions"
|
||||
>
|
||||
<DotsThree className="h-5 w-5 text-neutral-600" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{agent.can_access_graph && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Edit agent
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicateAgent();
|
||||
}}
|
||||
disabled={isDuplicatingAgent}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Duplicate agent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
className="flex items-center gap-2 text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
Delete agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen: showDeleteDialog,
|
||||
set: setShowDeleteDialog,
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
title="Delete agent"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete this agent? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isDeletingAgent}
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAgent}
|
||||
loading={isDeletingAgent}
|
||||
>
|
||||
Delete Agent
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
isFavorite: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function FavoriteButton({ isFavorite, onClick }: FavoriteButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full bg-white/90 p-2 backdrop-blur-sm transition-all duration-200",
|
||||
"hover:scale-110 hover:bg-white",
|
||||
"focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
!isFavorite && "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<HeartIcon
|
||||
size={20}
|
||||
weight={isFavorite ? "fill" : "regular"}
|
||||
className={cn(
|
||||
"transition-colors duration-200",
|
||||
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { InfiniteData, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
getV2ListFavoriteLibraryAgentsResponse,
|
||||
getV2ListLibraryAgentsResponse,
|
||||
} from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
|
||||
interface UpdateFavoriteInQueriesParams {
|
||||
queryClient: QueryClient;
|
||||
agentId: string;
|
||||
agent: LibraryAgent;
|
||||
newIsFavorite: boolean;
|
||||
}
|
||||
|
||||
export function updateFavoriteInQueries({
|
||||
queryClient,
|
||||
agentId,
|
||||
agent,
|
||||
newIsFavorite,
|
||||
}: UpdateFavoriteInQueriesParams) {
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["/api/library/agents"] },
|
||||
(
|
||||
oldData:
|
||||
| InfiniteData<getV2ListLibraryAgentsResponse, number | undefined>
|
||||
| undefined,
|
||||
) => {
|
||||
if (!oldData?.pages) return oldData;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => {
|
||||
if (page.status !== 200) return page;
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
agents: page.data.agents.map((currentAgent: LibraryAgent) =>
|
||||
currentAgent.id === agentId
|
||||
? { ...currentAgent, is_favorite: newIsFavorite }
|
||||
: currentAgent,
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["/api/library/agents/favorites"] },
|
||||
(
|
||||
oldData:
|
||||
| InfiniteData<
|
||||
getV2ListFavoriteLibraryAgentsResponse,
|
||||
number | undefined
|
||||
>
|
||||
| undefined,
|
||||
) => {
|
||||
if (!oldData?.pages) return oldData;
|
||||
|
||||
if (newIsFavorite) {
|
||||
const exists = oldData.pages.some(
|
||||
(page) =>
|
||||
page.status === 200 &&
|
||||
page.data.agents.some(
|
||||
(currentAgent: LibraryAgent) => currentAgent.id === agentId,
|
||||
),
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
const firstPage = oldData.pages[0];
|
||||
if (firstPage?.status === 200) {
|
||||
const updatedAgent = {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
graph_id: agent.graph_id,
|
||||
can_access_graph: agent.can_access_graph,
|
||||
creator_image_url: agent.creator_image_url,
|
||||
image_url: agent.image_url,
|
||||
is_favorite: true,
|
||||
};
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: [
|
||||
{
|
||||
...firstPage,
|
||||
data: {
|
||||
...firstPage.data,
|
||||
agents: [updatedAgent, ...firstPage.data.agents],
|
||||
pagination: {
|
||||
...firstPage.data.pagination,
|
||||
total_items: firstPage.data.pagination.total_items + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
...oldData.pages.slice(1).map((page) =>
|
||||
page.status === 200
|
||||
? {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
pagination: {
|
||||
...page.data.pagination,
|
||||
total_items: page.data.pagination.total_items + 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: page,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => {
|
||||
if (page.status !== 200) return page;
|
||||
|
||||
const filteredAgents = page.data.agents.filter(
|
||||
(currentAgent: LibraryAgent) => currentAgent.id !== agentId,
|
||||
);
|
||||
|
||||
const removedCount =
|
||||
filteredAgents.length < page.data.agents.length ? 1 : 0;
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
agents: filteredAgents,
|
||||
pagination: {
|
||||
...page.data.pagination,
|
||||
total_items: page.data.pagination.total_items - removedCount,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return oldData;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { usePatchV2UpdateLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { updateFavoriteInQueries } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function useLibraryAgentCard({ agent }: Props) {
|
||||
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
|
||||
agent;
|
||||
|
||||
const isFromMarketplace = Boolean(marketplace_listing);
|
||||
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
|
||||
const [isFavorite, setIsFavorite] = useState(is_favorite);
|
||||
const { toast } = useToast();
|
||||
const queryClient = getQueryClient();
|
||||
const { mutateAsync: updateLibraryAgent } = usePatchV2UpdateLibraryAgent();
|
||||
|
||||
const { data: profile } = useGetV2GetUserProfile({
|
||||
query: {
|
||||
select: okData,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(is_favorite);
|
||||
}, [is_favorite]);
|
||||
|
||||
function updateQueryData(newIsFavorite: boolean) {
|
||||
updateFavoriteInQueries({
|
||||
queryClient,
|
||||
agentId: id,
|
||||
agent,
|
||||
newIsFavorite,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToggleFavorite(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isAgentFavoritingEnabled) return;
|
||||
|
||||
const newIsFavorite = !isFavorite;
|
||||
|
||||
setIsFavorite(newIsFavorite);
|
||||
updateQueryData(newIsFavorite);
|
||||
|
||||
try {
|
||||
await updateLibraryAgent({
|
||||
libraryAgentId: id,
|
||||
data: { is_favorite: newIsFavorite },
|
||||
});
|
||||
|
||||
toast({
|
||||
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
|
||||
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
|
||||
});
|
||||
} catch {
|
||||
setIsFavorite(!newIsFavorite);
|
||||
updateQueryData(!newIsFavorite);
|
||||
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update favorite status. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isFromMarketplace,
|
||||
isAgentFavoritingEnabled,
|
||||
isFavorite,
|
||||
profile,
|
||||
creator_image_url,
|
||||
handleToggleFavorite,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
"use client";
|
||||
import LibraryActionSubHeader from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { useLibraryAgentList } from "./useLibraryAgentList";
|
||||
|
||||
export default function LibraryAgentList() {
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
librarySort: LibraryAgentSort;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
|
||||
export function LibraryAgentList({
|
||||
searchTerm,
|
||||
librarySort,
|
||||
setLibrarySort,
|
||||
}: Props) {
|
||||
const {
|
||||
agentLoading,
|
||||
agentCount,
|
||||
@@ -12,28 +24,27 @@ export default function LibraryAgentList() {
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useLibraryAgentList();
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
|
||||
);
|
||||
} = useLibraryAgentList({ searchTerm, librarySort });
|
||||
|
||||
return (
|
||||
<>
|
||||
<LibraryActionSubHeader agentCount={agentCount} />
|
||||
<LibraryActionSubHeader
|
||||
agentCount={agentCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
/>
|
||||
<div className="px-2">
|
||||
{agentLoading ? (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner />}
|
||||
loader={<LoadingSpinner size="medium" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import {
|
||||
getPaginatedTotalCount,
|
||||
getPaginationNextPageNumber,
|
||||
unpaginate,
|
||||
} from "@/app/api/helpers";
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useLibraryPageContext } from "../state-provider";
|
||||
import { useLibraryAgentsStore } from "@/hooks/useLibraryAgents/store";
|
||||
import { getInitialData } from "./helpers";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const useLibraryAgentList = () => {
|
||||
const { searchTerm, librarySort } = useLibraryPageContext();
|
||||
const { agents: cachedAgents } = useLibraryAgentsStore();
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
librarySort: LibraryAgentSort;
|
||||
}
|
||||
|
||||
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
||||
const queryClient = getQueryClient();
|
||||
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
||||
|
||||
const {
|
||||
data: agentsQueryData,
|
||||
@@ -23,18 +28,28 @@ export const useLibraryAgentList = () => {
|
||||
} = useGetV2ListLibraryAgentsInfinite(
|
||||
{
|
||||
page: 1,
|
||||
page_size: 8,
|
||||
page_size: 20,
|
||||
search_term: searchTerm || undefined,
|
||||
sort_by: librarySort,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
initialData: getInitialData(cachedAgents, searchTerm, 8),
|
||||
getNextPageParam: getPaginationNextPageNumber,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Reset queries when sort changes to ensure fresh data with correct sorting
|
||||
useEffect(() => {
|
||||
if (prevSortRef.current !== null && prevSortRef.current !== librarySort) {
|
||||
// Reset all library agent queries to ensure fresh fetch with new sort
|
||||
queryClient.resetQueries({
|
||||
queryKey: ["/api/library/agents"],
|
||||
});
|
||||
}
|
||||
prevSortRef.current = librarySort;
|
||||
}, [librarySort, queryClient]);
|
||||
|
||||
const allAgents = agentsQueryData
|
||||
? unpaginate(agentsQueryData, "agents")
|
||||
: [];
|
||||
@@ -48,4 +63,4 @@ export const useLibraryAgentList = () => {
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import {
|
||||
CirclePlayIcon,
|
||||
ClipboardCopy,
|
||||
ImageIcon,
|
||||
PlayCircle,
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NotificationCardData {
|
||||
type: "text" | "image" | "video" | "audio";
|
||||
title: string;
|
||||
id: string;
|
||||
content?: string;
|
||||
mediaUrl?: string;
|
||||
}
|
||||
|
||||
interface NotificationCardProps {
|
||||
notification: NotificationCardData;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NotificationCard = ({
|
||||
notification: { type, title, content, mediaUrl },
|
||||
onClose,
|
||||
}: NotificationCardProps) => {
|
||||
const barHeights = Array.from({ length: 60 }, () =>
|
||||
Math.floor(Math.random() * (34 - 20 + 1) + 20),
|
||||
);
|
||||
|
||||
const handleClose = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[430px] space-y-[22px] rounded-[14px] border border-neutral-100 bg-neutral-50 p-[16px] pt-[12px]">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* count */}
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<p className="font-sans text-[12px] font-medium text-neutral-500">
|
||||
1/4
|
||||
</p>
|
||||
<p className="h-[26px] rounded-[45px] bg-green-100 px-[9px] py-[3px] font-sans text-[12px] font-medium text-green-800">
|
||||
Success
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* cross icon */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 hover:bg-transparent"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X
|
||||
className="h-6 w-6 text-[#020617] hover:scale-105"
|
||||
strokeWidth={1.25}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-[6px] p-0">
|
||||
<p className="font-sans text-[14px] font-medium leading-[20px] text-neutral-500">
|
||||
New Output Ready!
|
||||
</p>
|
||||
<h2 className="font-poppin text-[20px] font-medium leading-7 text-neutral-800">
|
||||
{title}
|
||||
</h2>
|
||||
{type === "text" && <Separator />}
|
||||
</div>
|
||||
|
||||
<div className="p-0">
|
||||
{type === "text" && (
|
||||
// Maybe in future we give markdown support
|
||||
<div className="mt-[-8px] line-clamp-6 font-sans text-sm font-[400px] text-neutral-600">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "image" &&
|
||||
(mediaUrl ? (
|
||||
<div className="relative h-[200px] w-full">
|
||||
<Image
|
||||
src={mediaUrl}
|
||||
alt={title}
|
||||
fill
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[244px] w-full items-center justify-center rounded-lg bg-[#D9D9D9]">
|
||||
<ImageIcon
|
||||
className="h-[138px] w-[138px] text-neutral-400"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{type === "video" && (
|
||||
<div className="space-y-4">
|
||||
{mediaUrl ? (
|
||||
<video src={mediaUrl} controls className="w-full rounded-lg" />
|
||||
) : (
|
||||
<div className="flex h-[219px] w-[398px] items-center justify-center rounded-lg bg-[#D9D9D9]">
|
||||
<PlayCircle
|
||||
className="h-16 w-16 text-neutral-500"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "audio" && (
|
||||
<div className="flex gap-2">
|
||||
<CirclePlayIcon
|
||||
className="h-10 w-10 rounded-full bg-neutral-800 text-white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
{/* <audio src={mediaUrl} controls className="w-full" /> */}
|
||||
{barHeights.map((h, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-[8px] bg-neutral-500`}
|
||||
style={{
|
||||
height: `${h}px`,
|
||||
width: "3px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 p-0">
|
||||
<div className="space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.share({
|
||||
title,
|
||||
text: content,
|
||||
url: mediaUrl,
|
||||
});
|
||||
}}
|
||||
className="h-10 w-10 rounded-full border-neutral-800 p-0"
|
||||
>
|
||||
<Share2 className="h-5 w-5" strokeWidth={1} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(content || mediaUrl || "")
|
||||
}
|
||||
className="h-10 w-10 rounded-full border-neutral-800 p-0"
|
||||
>
|
||||
<ClipboardCopy className="h-5 w-5" strokeWidth={1} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button className="h-[40px] rounded-[52px] bg-neutral-800 px-4 py-2">
|
||||
See run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCard;
|
||||
@@ -1,132 +0,0 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
|
||||
import { motion, useAnimationControls } from "framer-motion";
|
||||
import { BellIcon, X } from "lucide-react";
|
||||
import { Button } from "@/components/__legacy__/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
import NotificationCard, {
|
||||
NotificationCardData,
|
||||
} from "../LibraryNotificationCard/LibraryNotificationCard";
|
||||
|
||||
export default function LibraryNotificationDropdown(): React.ReactNode {
|
||||
const controls = useAnimationControls();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState<
|
||||
NotificationCardData[] | null
|
||||
>(null);
|
||||
|
||||
const initialNotificationData = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "audio",
|
||||
title: "Audio Processing Complete",
|
||||
id: "4",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
title: "LinkedIn Post Generator: YouTube to Professional Content",
|
||||
id: "1",
|
||||
content:
|
||||
"As artificial intelligence (AI) continues to evolve, it's increasingly clear that AI isn't just a trend—it's reshaping the way we work, innovate, and solve complex problems. However, for many professionals, the question remains: How can I leverage AI to drive meaningful results in my own field? In this article, we'll explore how AI can empower businesses and individuals alike to be more efficient, make better decisions, and unlock new opportunities. Whether you're in tech, finance, healthcare, or any other industry, understanding the potential of AI can set you apart.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
title: "New Image Upload",
|
||||
id: "2",
|
||||
},
|
||||
{
|
||||
type: "video",
|
||||
title: "Video Processing Complete",
|
||||
id: "3",
|
||||
},
|
||||
] as NotificationCardData[],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialNotificationData) {
|
||||
setNotifications(initialNotificationData);
|
||||
}
|
||||
}, [initialNotificationData]);
|
||||
|
||||
const handleHoverStart = () => {
|
||||
controls.start({
|
||||
rotate: [0, -10, 10, -10, 10, 0],
|
||||
transition: { duration: 0.5 },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger className="sm:flex-1" asChild>
|
||||
<Button
|
||||
variant={open ? "primary" : "outline"}
|
||||
onMouseEnter={handleHoverStart}
|
||||
onMouseLeave={handleHoverStart}
|
||||
className="w-fit max-w-[161px] transition-all duration-200 ease-in-out sm:w-[161px]"
|
||||
>
|
||||
<motion.div animate={controls}>
|
||||
<BellIcon
|
||||
className="h-5 w-5 transition-all duration-200 ease-in-out sm:mr-2"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="hidden items-center transition-opacity duration-300 sm:inline-flex"
|
||||
>
|
||||
Your updates
|
||||
<span className="ml-2 text-[14px]">
|
||||
{notifications?.length || 0}
|
||||
</span>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
sideOffset={22}
|
||||
className="relative left-[16px] h-[80vh] w-fit overflow-y-auto rounded-[26px] bg-[#C5C5CA] p-5"
|
||||
>
|
||||
<DropdownMenuLabel className="z-10 mb-4 font-sans text-[18px] text-white">
|
||||
Agent run updates
|
||||
</DropdownMenuLabel>
|
||||
<button
|
||||
className="absolute right-[10px] top-[20px] h-fit w-fit"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6 text-white hover:text-white/60" />
|
||||
</button>
|
||||
<div className="space-y-[12px]">
|
||||
{notifications && notifications.length ? (
|
||||
notifications.map((notification) => (
|
||||
<DropdownMenuItem key={notification.id} className="p-0">
|
||||
<NotificationCard
|
||||
notification={notification}
|
||||
onClose={() =>
|
||||
setNotifications((prev) => {
|
||||
if (!prev) return null;
|
||||
return prev.filter((n) => n.id !== notification.id);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<div className="w-[464px] py-4 text-center text-white">
|
||||
No notifications present
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,37 @@
|
||||
"use client";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { useLibrarySearchbar } from "./useLibrarySearchbar";
|
||||
|
||||
export default function LibrarySearchBar(): React.ReactNode {
|
||||
const { handleSearchInput, handleClear, setIsFocused, isFocused, inputRef } =
|
||||
useLibrarySearchbar();
|
||||
interface Props {
|
||||
setSearchTerm: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LibrarySearchBar({ setSearchTerm }: Props) {
|
||||
const { handleSearchInput } = useLibrarySearchbar({ setSearchTerm });
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="search-bar"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className="relative z-[21] mx-auto flex h-[50px] w-full max-w-[500px] flex-1 cursor-pointer items-center rounded-[45px] bg-[#EDEDED] px-[24px] py-[10px]"
|
||||
className="relative z-[21] -mb-6 flex w-full items-center md:w-auto"
|
||||
>
|
||||
<Search
|
||||
className="mr-2 h-[29px] w-[29px] text-neutral-900"
|
||||
strokeWidth={1.25}
|
||||
<MagnifyingGlassIcon
|
||||
width={18}
|
||||
height={18}
|
||||
className="absolute left-4 top-[34%] z-20 -translate-y-1/2 text-zinc-800"
|
||||
/>
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => !inputRef.current?.value && setIsFocused(false)}
|
||||
label="Search agents"
|
||||
id="library-search-bar"
|
||||
hideLabel
|
||||
onChange={handleSearchInput}
|
||||
className="flex-1 border-none font-sans text-[16px] font-normal leading-7 shadow-none focus:shadow-none focus:ring-0"
|
||||
className="min-w-[18rem] pl-12 lg:min-w-[30rem]"
|
||||
type="text"
|
||||
data-testid="library-textbox"
|
||||
placeholder="Search agents"
|
||||
/>
|
||||
|
||||
{isFocused && inputRef.current?.value && (
|
||||
<X
|
||||
className="ml-2 h-[29px] w-[29px] cursor-pointer text-neutral-900"
|
||||
strokeWidth={1.25}
|
||||
onClick={handleClear}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user