Merge branch 'dev' into add-iffy-moderation

This commit is contained in:
Bently
2025-03-24 10:47:34 +00:00
committed by GitHub
51 changed files with 3168 additions and 1419 deletions

View File

@@ -6,11 +6,8 @@ from backend.data.model import SchemaField
from backend.util import json
from backend.util.file import MediaFile, store_media_file
from backend.util.mock import MockObject
from backend.util.text import TextFormatter
from backend.util.type import convert
formatter = TextFormatter()
class FileStoreBlock(Block):
class Input(BlockSchema):
@@ -91,29 +88,6 @@ class StoreValueBlock(Block):
yield "output", input_data.data or input_data.input
class PrintToConsoleBlock(Block):
class Input(BlockSchema):
text: str = SchemaField(description="The text to print to the console.")
class Output(BlockSchema):
status: str = SchemaField(description="The status of the print operation.")
def __init__(self):
super().__init__(
id="f3b1c1b2-4c4f-4f0d-8d2f-4c4f0d8d2f4c",
description="Print the given text to the console, this is used for a debugging purpose.",
categories={BlockCategory.BASIC},
input_schema=PrintToConsoleBlock.Input,
output_schema=PrintToConsoleBlock.Output,
test_input={"text": "Hello, World!"},
test_output=("status", "printed"),
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
print(">>>>> Print: ", input_data.text)
yield "status", "printed"
class FindInDictionaryBlock(Block):
class Input(BlockSchema):
input: Any = SchemaField(description="Dictionary to lookup from")
@@ -174,188 +148,6 @@ class FindInDictionaryBlock(Block):
yield "missing", input_data.input
class AgentInputBlock(Block):
"""
This block is used to provide input to the graph.
It takes in a value, name, description, default values list and bool to limit selection to default values.
It Outputs the value passed as input.
"""
class Input(BlockSchema):
name: str = SchemaField(description="The name of the input.")
value: Any = SchemaField(
description="The value to be passed as input.",
default=None,
)
title: str | None = SchemaField(
description="The title of the input.", default=None, advanced=True
)
description: str | None = SchemaField(
description="The description of the input.",
default=None,
advanced=True,
)
placeholder_values: List[Any] = SchemaField(
description="The placeholder values to be passed as input.",
default=[],
advanced=True,
)
limit_to_placeholder_values: bool = SchemaField(
description="Whether to limit the selection to placeholder values.",
default=False,
advanced=True,
)
advanced: bool = SchemaField(
description="Whether to show the input in the advanced section, if the field is not required.",
default=False,
advanced=True,
)
secret: bool = SchemaField(
description="Whether the input should be treated as a secret.",
default=False,
advanced=True,
)
class Output(BlockSchema):
result: Any = SchemaField(description="The value passed as input.")
def __init__(self):
super().__init__(
id="c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
description="This block is used to provide input to the graph.",
input_schema=AgentInputBlock.Input,
output_schema=AgentInputBlock.Output,
test_input=[
{
"value": "Hello, World!",
"name": "input_1",
"description": "This is a test input.",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Hello, World!",
"name": "input_2",
"description": "This is a test input.",
"placeholder_values": ["Hello, World!"],
"limit_to_placeholder_values": True,
},
],
test_output=[
("result", "Hello, World!"),
("result", "Hello, World!"),
],
categories={BlockCategory.INPUT, BlockCategory.BASIC},
block_type=BlockType.INPUT,
static_output=True,
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
yield "result", input_data.value
class AgentOutputBlock(Block):
"""
Records the output of the graph for users to see.
Behavior:
If `format` is provided and the `value` is of a type that can be formatted,
the block attempts to format the recorded_value using the `format`.
If formatting fails or no `format` is provided, the raw `value` is output.
"""
class Input(BlockSchema):
value: Any = SchemaField(
description="The value to be recorded as output.",
default=None,
advanced=False,
)
name: str = SchemaField(description="The name of the output.")
title: str | None = SchemaField(
description="The title of the output.",
default=None,
advanced=True,
)
description: str | None = SchemaField(
description="The description of the output.",
default=None,
advanced=True,
)
format: str = SchemaField(
description="The format string to be used to format the recorded_value. Use Jinja2 syntax.",
default="",
advanced=True,
)
advanced: bool = SchemaField(
description="Whether to treat the output as advanced.",
default=False,
advanced=True,
)
secret: bool = SchemaField(
description="Whether the output should be treated as a secret.",
default=False,
advanced=True,
)
class Output(BlockSchema):
output: Any = SchemaField(description="The value recorded as output.")
name: Any = SchemaField(description="The name of the value recorded as output.")
def __init__(self):
super().__init__(
id="363ae599-353e-4804-937e-b2ee3cef3da4",
description="Stores the output of the graph for users to see.",
input_schema=AgentOutputBlock.Input,
output_schema=AgentOutputBlock.Output,
test_input=[
{
"value": "Hello, World!",
"name": "output_1",
"description": "This is a test output.",
"format": "{{ output_1 }}!!",
},
{
"value": "42",
"name": "output_2",
"description": "This is another test output.",
"format": "{{ output_2 }}",
},
{
"value": MockObject(value="!!", key="key"),
"name": "output_3",
"description": "This is a test output with a mock object.",
"format": "{{ output_3 }}",
},
],
test_output=[
("output", "Hello, World!!!"),
("output", "42"),
("output", MockObject(value="!!", key="key")),
],
categories={BlockCategory.OUTPUT, BlockCategory.BASIC},
block_type=BlockType.OUTPUT,
static_output=True,
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
"""
Attempts to format the recorded_value using the fmt_string if provided.
If formatting fails or no fmt_string is given, returns the original recorded_value.
"""
if input_data.format:
try:
yield "output", formatter.format_string(
input_data.format, {input_data.name: input_data.value}
)
except Exception as e:
yield "output", f"Error: {e}, {input_data.value}"
else:
yield "output", input_data.value
yield "name", input_data.name
class AddToDictionaryBlock(Block):
class Input(BlockSchema):
dictionary: dict[Any, Any] = SchemaField(

View File

@@ -0,0 +1,542 @@
from datetime import date, time
from typing import Any, Optional
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType
from backend.data.model import SchemaField
from backend.util.file import MediaFile, store_media_file
from backend.util.mock import MockObject
from backend.util.text import TextFormatter
formatter = TextFormatter()
class AgentInputBlock(Block):
"""
This block is used to provide input to the graph.
It takes in a value, name, description, default values list and bool to limit selection to default values.
It Outputs the value passed as input.
"""
class Input(BlockSchema):
name: str = SchemaField(description="The name of the input.")
value: Any = SchemaField(
description="The value to be passed as input.",
default=None,
)
title: str | None = SchemaField(
description="The title of the input.", default=None, advanced=True
)
description: str | None = SchemaField(
description="The description of the input.",
default=None,
advanced=True,
)
placeholder_values: list = SchemaField(
description="The placeholder values to be passed as input.",
default=[],
advanced=True,
)
limit_to_placeholder_values: bool = SchemaField(
description="Whether to limit the selection to placeholder values.",
default=False,
advanced=True,
)
advanced: bool = SchemaField(
description="Whether to show the input in the advanced section, if the field is not required.",
default=False,
advanced=True,
)
secret: bool = SchemaField(
description="Whether the input should be treated as a secret.",
default=False,
advanced=True,
)
class Output(BlockSchema):
result: Any = SchemaField(description="The value passed as input.")
def __init__(self, **kwargs):
super().__init__(
**{
"id": "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
"description": "Base block for user inputs.",
"input_schema": AgentInputBlock.Input,
"output_schema": AgentInputBlock.Output,
"test_input": [
{
"value": "Hello, World!",
"name": "input_1",
"description": "Example test input.",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Hello, World!",
"name": "input_2",
"description": "Example test input with placeholders.",
"placeholder_values": ["Hello, World!"],
"limit_to_placeholder_values": True,
},
],
"test_output": [
("result", "Hello, World!"),
("result", "Hello, World!"),
],
"categories": {BlockCategory.INPUT, BlockCategory.BASIC},
"block_type": BlockType.INPUT,
"static_output": True,
**kwargs,
}
)
def run(self, input_data: Input, *args, **kwargs) -> BlockOutput:
if input_data.value is not None:
yield "result", input_data.value
class AgentOutputBlock(Block):
"""
Records the output of the graph for users to see.
Behavior:
If `format` is provided and the `value` is of a type that can be formatted,
the block attempts to format the recorded_value using the `format`.
If formatting fails or no `format` is provided, the raw `value` is output.
"""
class Input(BlockSchema):
value: Any = SchemaField(
description="The value to be recorded as output.",
default=None,
advanced=False,
)
name: str = SchemaField(description="The name of the output.")
title: str | None = SchemaField(
description="The title of the output.",
default=None,
advanced=True,
)
description: str | None = SchemaField(
description="The description of the output.",
default=None,
advanced=True,
)
format: str = SchemaField(
description="The format string to be used to format the recorded_value. Use Jinja2 syntax.",
default="",
advanced=True,
)
advanced: bool = SchemaField(
description="Whether to treat the output as advanced.",
default=False,
advanced=True,
)
secret: bool = SchemaField(
description="Whether the output should be treated as a secret.",
default=False,
advanced=True,
)
class Output(BlockSchema):
output: Any = SchemaField(description="The value recorded as output.")
name: Any = SchemaField(description="The name of the value recorded as output.")
def __init__(self):
super().__init__(
id="363ae599-353e-4804-937e-b2ee3cef3da4",
description="Stores the output of the graph for users to see.",
input_schema=AgentOutputBlock.Input,
output_schema=AgentOutputBlock.Output,
test_input=[
{
"value": "Hello, World!",
"name": "output_1",
"description": "This is a test output.",
"format": "{{ output_1 }}!!",
},
{
"value": "42",
"name": "output_2",
"description": "This is another test output.",
"format": "{{ output_2 }}",
},
{
"value": MockObject(value="!!", key="key"),
"name": "output_3",
"description": "This is a test output with a mock object.",
"format": "{{ output_3 }}",
},
],
test_output=[
("output", "Hello, World!!!"),
("output", "42"),
("output", MockObject(value="!!", key="key")),
],
categories={BlockCategory.OUTPUT, BlockCategory.BASIC},
block_type=BlockType.OUTPUT,
static_output=True,
)
def run(self, input_data: Input, *args, **kwargs) -> BlockOutput:
"""
Attempts to format the recorded_value using the fmt_string if provided.
If formatting fails or no fmt_string is given, returns the original recorded_value.
"""
if input_data.format:
try:
yield "output", formatter.format_string(
input_data.format, {input_data.name: input_data.value}
)
except Exception as e:
yield "output", f"Error: {e}, {input_data.value}"
else:
yield "output", input_data.value
yield "name", input_data.name
class AgentShortTextInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[str] = SchemaField(
description="Short text input.",
default=None,
advanced=False,
title="Default Value",
json_schema_extra={"format": "short-text"},
)
class Output(AgentInputBlock.Output):
result: str = SchemaField(description="Short text result.")
def __init__(self):
super().__init__(
id="7fcd3bcb-8e1b-4e69-903d-32d3d4a92158",
description="Block for short text input (single-line).",
input_schema=AgentShortTextInputBlock.Input,
output_schema=AgentShortTextInputBlock.Output,
test_input=[
{
"value": "Hello",
"name": "short_text_1",
"description": "Short text example 1",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Quick test",
"name": "short_text_2",
"description": "Short text example 2",
"placeholder_values": ["Quick test", "Another option"],
"limit_to_placeholder_values": True,
},
],
test_output=[
("result", "Hello"),
("result", "Quick test"),
],
)
class AgentLongTextInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[str] = SchemaField(
description="Long text input (potentially multi-line).",
default=None,
advanced=False,
title="Default Value",
json_schema_extra={"format": "long-text"},
)
class Output(AgentInputBlock.Output):
result: str = SchemaField(description="Long text result.")
def __init__(self):
super().__init__(
id="90a56ffb-7024-4b2b-ab50-e26c5e5ab8ba",
description="Block for long text input (multi-line).",
input_schema=AgentLongTextInputBlock.Input,
output_schema=AgentLongTextInputBlock.Output,
test_input=[
{
"value": "Lorem ipsum dolor sit amet...",
"name": "long_text_1",
"description": "Long text example 1",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": "Another multiline text input.",
"name": "long_text_2",
"description": "Long text example 2",
"placeholder_values": ["Another multiline text input."],
"limit_to_placeholder_values": True,
},
],
test_output=[
("result", "Lorem ipsum dolor sit amet..."),
("result", "Another multiline text input."),
],
)
class AgentNumberInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[int] = SchemaField(
description="Number input.",
default=None,
advanced=False,
title="Default Value",
)
class Output(AgentInputBlock.Output):
result: int = SchemaField(description="Number result.")
def __init__(self):
super().__init__(
id="96dae2bb-97a2-41c2-bd2f-13a3b5a8ea98",
description="Block for number input.",
input_schema=AgentNumberInputBlock.Input,
output_schema=AgentNumberInputBlock.Output,
test_input=[
{
"value": 42,
"name": "number_input_1",
"description": "Number example 1",
"placeholder_values": [],
"limit_to_placeholder_values": False,
},
{
"value": 314,
"name": "number_input_2",
"description": "Number example 2",
"placeholder_values": [314, 2718],
"limit_to_placeholder_values": True,
},
],
test_output=[
("result", 42),
("result", 314),
],
)
class AgentDateInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[date] = SchemaField(
description="Date input (YYYY-MM-DD).",
default=None,
advanced=False,
title="Default Value",
)
class Output(AgentInputBlock.Output):
result: date = SchemaField(description="Date result.")
def __init__(self):
super().__init__(
id="7e198b09-4994-47db-8b4d-952d98241817",
description="Block for date input.",
input_schema=AgentDateInputBlock.Input,
output_schema=AgentDateInputBlock.Output,
test_input=[
{
# If your system can parse JSON date strings to date objects
"value": str(date(2025, 3, 19)),
"name": "date_input_1",
"description": "Example date input 1",
},
{
"value": str(date(2023, 12, 31)),
"name": "date_input_2",
"description": "Example date input 2",
},
],
test_output=[
("result", date(2025, 3, 19)),
("result", date(2023, 12, 31)),
],
)
class AgentTimeInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: Optional[time] = SchemaField(
description="Time input (HH:MM:SS).",
default=None,
advanced=False,
title="Default Value",
)
class Output(AgentInputBlock.Output):
result: time = SchemaField(description="Time result.")
def __init__(self):
super().__init__(
id="2a1c757e-86cf-4c7e-aacf-060dc382e434",
description="Block for time input.",
input_schema=AgentTimeInputBlock.Input,
output_schema=AgentTimeInputBlock.Output,
test_input=[
{
"value": str(time(9, 30, 0)),
"name": "time_input_1",
"description": "Time example 1",
},
{
"value": str(time(23, 59, 59)),
"name": "time_input_2",
"description": "Time example 2",
},
],
test_output=[
("result", time(9, 30, 0)),
("result", time(23, 59, 59)),
],
)
class AgentFileInputBlock(AgentInputBlock):
"""
A simplified file-upload block. In real usage, you might have a custom
file type or handle binary data. Here, we'll store a string path as the example.
"""
class Input(AgentInputBlock.Input):
value: Optional[MediaFile] = SchemaField(
description="Path or reference to an uploaded file.",
default=None,
advanced=False,
title="Default Value",
)
class Output(AgentInputBlock.Output):
result: str = SchemaField(description="File reference/path result.")
def __init__(self):
super().__init__(
id="95ead23f-8283-4654-aef3-10c053b74a31",
description="Block for file upload input (string path for example).",
input_schema=AgentFileInputBlock.Input,
output_schema=AgentFileInputBlock.Output,
test_input=[
{
"value": "data:image/png;base64,MQ==",
"name": "file_upload_1",
"description": "Example file upload 1",
},
],
test_output=[
("result", str),
],
)
def run(
self,
input_data: Input,
*,
graph_exec_id: str,
**kwargs,
) -> BlockOutput:
if not input_data.value:
return
file_path = store_media_file(
graph_exec_id=graph_exec_id,
file=input_data.value,
return_content=False,
)
yield "result", file_path
class AgentDropdownInputBlock(AgentInputBlock):
"""
A specialized text input block that relies on placeholder_values +
limit_to_placeholder_values to present a dropdown.
"""
class Input(AgentInputBlock.Input):
value: Optional[str] = SchemaField(
description="Text selected from a dropdown.",
default=None,
advanced=False,
title="Default Value",
)
placeholder_values: list = SchemaField(
description="Possible values for the dropdown.",
default=[],
advanced=False,
title="Dropdown Options",
)
limit_to_placeholder_values: bool = SchemaField(
description="Whether the selection is limited to placeholder values.",
default=True,
)
class Output(AgentInputBlock.Output):
result: str = SchemaField(description="Selected dropdown value.")
def __init__(self):
super().__init__(
id="655d6fdf-a334-421c-b733-520549c07cd1",
description="Block for dropdown text selection.",
input_schema=AgentDropdownInputBlock.Input,
output_schema=AgentDropdownInputBlock.Output,
test_input=[
{
"value": "Option A",
"name": "dropdown_1",
"placeholder_values": ["Option A", "Option B", "Option C"],
"limit_to_placeholder_values": True,
"description": "Dropdown example 1",
},
{
"value": "Option C",
"name": "dropdown_2",
"placeholder_values": ["Option A", "Option B", "Option C"],
"limit_to_placeholder_values": True,
"description": "Dropdown example 2",
},
],
test_output=[
("result", "Option A"),
("result", "Option C"),
],
)
class AgentToggleInputBlock(AgentInputBlock):
class Input(AgentInputBlock.Input):
value: bool = SchemaField(
description="Boolean toggle input.",
default=False,
advanced=False,
title="Default Value",
)
class Output(AgentInputBlock.Output):
result: bool = SchemaField(description="Boolean toggle result.")
def __init__(self):
super().__init__(
id="cbf36ab5-df4a-43b6-8a7f-f7ed8652116e",
description="Block for boolean toggle input.",
input_schema=AgentToggleInputBlock.Input,
output_schema=AgentToggleInputBlock.Output,
test_input=[
{
"value": True,
"name": "toggle_1",
"description": "Toggle example 1",
},
{
"value": False,
"name": "toggle_2",
"description": "Toggle example 2",
},
],
test_output=[
("result", True),
("result", False),
],
)

View File

@@ -142,7 +142,9 @@ class ScreenshotWebPageBlock(Block):
return {
"image": store_media_file(
graph_exec_id=graph_exec_id,
file=f"data:image/{format.value};base64,{b64encode(response.content).decode('utf-8')}",
file=MediaFile(
f"data:image/{format.value};base64,{b64encode(response.content).decode('utf-8')}"
),
return_content=True,
)
}

View File

@@ -15,14 +15,11 @@ from prisma.enums import (
from prisma.errors import UniqueViolationError
from prisma.models import CreditRefundRequest, CreditTransaction, User
from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential
from backend.data import db
from backend.data.block import Block, BlockInput, get_block
from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.cost import BlockCost, BlockCostType
from backend.data.execution import NodeExecutionEntry
from backend.data.cost import BlockCost
from backend.data.model import (
AutoTopUpConfig,
RefundRequest,
@@ -31,6 +28,7 @@ from backend.data.model import (
)
from backend.data.notifications import NotificationEventDTO, RefundRequestData
from backend.data.user import get_user_by_id
from backend.executor.utils import UsageTransactionMetadata
from backend.notifications import NotificationManager
from backend.util.exceptions import InsufficientBalanceError
from backend.util.service import get_service_client
@@ -91,20 +89,20 @@ class UserCreditBase(ABC):
@abstractmethod
async def spend_credits(
self,
entry: NodeExecutionEntry,
data_size: float,
run_time: float,
user_id: str,
cost: int,
metadata: UsageTransactionMetadata,
) -> int:
"""
Spend the credits for the user based on the block usage.
Spend the credits for the user based on the cost.
Args:
entry (NodeExecutionEntry): The node execution identifiers & data.
data_size (float): The size of the data being processed.
run_time (float): The time taken to run the block.
user_id (str): The user ID.
cost (int): The cost to spend.
metadata (UsageTransactionMetadata): The metadata of the transaction.
Returns:
int: amount of credit spent
int: The remaining balance.
"""
pass
@@ -348,16 +346,6 @@ class UserCreditBase(ABC):
return user_balance + amount, tx.transactionKey
class UsageTransactionMetadata(BaseModel):
graph_exec_id: str | None = None
graph_id: str | None = None
node_id: str | None = None
node_exec_id: str | None = None
block_id: str | None = None
block: str | None = None
input: BlockInput | None = None
class UserCredit(UserCreditBase):
@thread_cached
def notification_client(self) -> NotificationManager:
@@ -378,89 +366,21 @@ class UserCredit(UserCreditBase):
)
)
def _block_usage_cost(
self,
block: Block,
input_data: BlockInput,
data_size: float,
run_time: float,
) -> tuple[int, BlockInput]:
block_costs = BLOCK_COSTS.get(type(block))
if not block_costs:
return 0, {}
for block_cost in block_costs:
if not self._is_cost_filter_match(block_cost.cost_filter, input_data):
continue
if block_cost.cost_type == BlockCostType.RUN:
return block_cost.cost_amount, block_cost.cost_filter
if block_cost.cost_type == BlockCostType.SECOND:
return (
int(run_time * block_cost.cost_amount),
block_cost.cost_filter,
)
if block_cost.cost_type == BlockCostType.BYTE:
return (
int(data_size * block_cost.cost_amount),
block_cost.cost_filter,
)
return 0, {}
def _is_cost_filter_match(
self, cost_filter: BlockInput, input_data: BlockInput
) -> bool:
"""
Filter rules:
- If cost_filter is an object, then check if cost_filter is the subset of input_data
- Otherwise, check if cost_filter is equal to input_data.
- Undefined, null, and empty string are considered as equal.
"""
if not isinstance(cost_filter, dict) or not isinstance(input_data, dict):
return cost_filter == input_data
return all(
(not input_data.get(k) and not v)
or (input_data.get(k) and self._is_cost_filter_match(v, input_data[k]))
for k, v in cost_filter.items()
)
async def spend_credits(
self,
entry: NodeExecutionEntry,
data_size: float,
run_time: float,
user_id: str,
cost: int,
metadata: UsageTransactionMetadata,
) -> int:
block = get_block(entry.block_id)
if not block:
raise ValueError(f"Block not found: {entry.block_id}")
cost, matching_filter = self._block_usage_cost(
block=block, input_data=entry.data, data_size=data_size, run_time=run_time
)
if cost == 0:
return 0
balance, _ = await self._add_transaction(
user_id=entry.user_id,
user_id=user_id,
amount=-cost,
transaction_type=CreditTransactionType.USAGE,
metadata=Json(
UsageTransactionMetadata(
graph_exec_id=entry.graph_exec_id,
graph_id=entry.graph_id,
node_id=entry.node_id,
node_exec_id=entry.node_exec_id,
block_id=entry.block_id,
block=block.name,
input=matching_filter,
).model_dump()
),
metadata=Json(metadata.model_dump()),
)
user_id = entry.user_id
# Auto top-up if balance is below threshold.
auto_top_up = await get_auto_top_up(user_id)
@@ -470,7 +390,7 @@ class UserCredit(UserCreditBase):
user_id=user_id,
amount=auto_top_up.amount,
# Avoid multiple auto top-ups within the same graph execution.
key=f"AUTO-TOP-UP-{user_id}-{entry.graph_exec_id}",
key=f"AUTO-TOP-UP-{user_id}-{metadata.graph_exec_id}",
ceiling_balance=auto_top_up.threshold,
)
except Exception as e:
@@ -479,7 +399,7 @@ class UserCredit(UserCreditBase):
f"Auto top-up failed for user {user_id}, balance: {balance}, amount: {auto_top_up.amount}, error: {e}"
)
return cost
return balance
async def top_up_credits(self, user_id: str, amount: int):
await self._top_up_credits(user_id, amount)

View File

@@ -6,6 +6,7 @@ from typing import Any, Literal, Optional, Type
import prisma
from prisma import Json
from prisma.enums import SubmissionStatus
from prisma.models import (
AgentGraph,
AgentGraphExecution,
@@ -17,7 +18,7 @@ from prisma.types import AgentGraphExecutionWhereInput, AgentGraphWhereInput
from pydantic.fields import Field, computed_field
from backend.blocks.agent import AgentExecutorBlock
from backend.blocks.basic import AgentInputBlock, AgentOutputBlock
from backend.blocks.io import AgentInputBlock, AgentOutputBlock
from backend.util import type as type_utils
from .block import Block, BlockInput, BlockSchema, BlockType, get_block, get_blocks
@@ -229,20 +230,28 @@ class GraphExecution(GraphExecutionMeta):
# inputs from Agent Input Blocks
exec.input_data["name"]: exec.input_data.get("value")
for exec in node_executions
if exec.block_id == _INPUT_BLOCK_ID
if (
(block := get_block(exec.block_id))
and block.block_type == BlockType.INPUT
)
},
**{
# input from webhook-triggered block
"payload": exec.input_data["payload"]
for exec in node_executions
if (block := get_block(exec.block_id))
and block.block_type in [BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL]
if (
(block := get_block(exec.block_id))
and block.block_type
in [BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL]
)
},
}
outputs: dict[str, list] = defaultdict(list)
for exec in node_executions:
if exec.block_id == _OUTPUT_BLOCK_ID:
if (
block := get_block(exec.block_id)
) and block.block_type == BlockType.OUTPUT:
outputs[exec.input_data["name"]].append(
exec.input_data.get("value", None)
)
@@ -732,7 +741,7 @@ async def get_graph(
"agentId": graph_id,
"agentVersion": version or graph.version,
"isDeleted": False,
"StoreListing": {"is": {"isApproved": True}},
"submissionStatus": SubmissionStatus.APPROVED,
}
)
)
@@ -740,7 +749,7 @@ async def get_graph(
return None
if for_export:
sub_graphs = await _get_sub_graphs(graph)
sub_graphs = await get_sub_graphs(graph)
return GraphModel.from_db(
graph=graph,
sub_graphs=sub_graphs,
@@ -750,7 +759,7 @@ async def get_graph(
return GraphModel.from_db(graph, for_export)
async def _get_sub_graphs(graph: AgentGraph) -> list[AgentGraph]:
async def get_sub_graphs(graph: AgentGraph) -> list[AgentGraph]:
"""
Iteratively fetches all sub-graphs of a given graph, and flattens them into a list.
This call involves a DB fetch in batch, breadth-first, per-level of graph depth.

View File

@@ -144,6 +144,7 @@ def SchemaField(
depends_on: list[str] | None = None,
image_upload: Optional[bool] = None,
image_output: Optional[bool] = None,
json_schema_extra: dict[str, Any] | None = None,
**kwargs,
) -> T:
if default is PydanticUndefined and default_factory is None:
@@ -151,7 +152,7 @@ def SchemaField(
elif advanced is None:
advanced = True
json_extra = {
json_schema_extra = {
k: v
for k, v in {
"placeholder": placeholder,
@@ -161,6 +162,7 @@ def SchemaField(
"depends_on": depends_on,
"image_upload": image_upload,
"image_output": image_output,
**(json_schema_extra or {}),
}.items()
if v is not None
}
@@ -172,7 +174,7 @@ def SchemaField(
title=title,
description=description,
exclude=exclude,
json_schema_extra=json_extra,
json_schema_extra=json_schema_extra,
**kwargs,
) # type: ignore
@@ -413,7 +415,6 @@ class NodeExecutionStats(BaseModel):
error: Optional[Exception | str] = None
walltime: float = 0
cputime: float = 0
cost: float = 0
input_size: int = 0
output_size: int = 0
llm_call_count: int = 0

View File

@@ -372,7 +372,7 @@ class UserNotificationBatchDTO(BaseModel):
type=model.type,
notifications=[
UserNotificationEventDTO.from_db(notification)
for notification in model.notifications or []
for notification in model.Notifications or []
],
created_at=model.createdAt,
updated_at=model.updatedAt,
@@ -410,7 +410,7 @@ async def create_or_add_to_user_notification_batch(
"type": notification_type,
}
},
include={"notifications": True},
include={"Notifications": True},
)
if not existing_batch:
@@ -427,9 +427,9 @@ async def create_or_add_to_user_notification_batch(
data={
"userId": user_id,
"type": notification_type,
"notifications": {"connect": [{"id": notification_event.id}]},
"Notifications": {"connect": [{"id": notification_event.id}]},
},
include={"notifications": True},
include={"Notifications": True},
)
return UserNotificationBatchDTO.from_db(resp)
else:
@@ -445,9 +445,9 @@ async def create_or_add_to_user_notification_batch(
resp = await tx.usernotificationbatch.update(
where={"id": existing_batch.id},
data={
"notifications": {"connect": [{"id": notification_event.id}]}
"Notifications": {"connect": [{"id": notification_event.id}]}
},
include={"notifications": True},
include={"Notifications": True},
)
if not resp:
raise DatabaseError(
@@ -467,13 +467,13 @@ async def get_user_notification_oldest_message_in_batch(
try:
batch = await UserNotificationBatch.prisma().find_first(
where={"userId": user_id, "type": notification_type},
include={"notifications": True},
include={"Notifications": True},
)
if not batch:
return None
if not batch.notifications:
if not batch.Notifications:
return None
sorted_notifications = sorted(batch.notifications, key=lambda x: x.createdAt)
sorted_notifications = sorted(batch.Notifications, key=lambda x: x.createdAt)
return (
UserNotificationEventDTO.from_db(sorted_notifications[0])
@@ -518,7 +518,7 @@ async def get_user_notification_batch(
try:
batch = await UserNotificationBatch.prisma().find_first(
where={"userId": user_id, "type": notification_type},
include={"notifications": True},
include={"Notifications": True},
)
return UserNotificationBatchDTO.from_db(batch) if batch else None
except Exception as e:
@@ -534,11 +534,11 @@ async def get_all_batches_by_type(
batches = await UserNotificationBatch.prisma().find_many(
where={
"type": notification_type,
"notifications": {
"Notifications": {
"some": {} # Only return batches with at least one notification
},
},
include={"notifications": True},
include={"Notifications": True},
)
return [UserNotificationBatchDTO.from_db(batch) for batch in batches]
except Exception as e:

View File

@@ -1,5 +1,5 @@
from backend.app import run_processes
from backend.executor import DatabaseManager, ExecutionManager
from backend.executor import ExecutionManager
def main():

View File

@@ -1,7 +1,6 @@
from backend.data.credit import get_user_credit_model
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
from backend.data.execution import (
ExecutionResult,
NodeExecutionEntry,
RedisExecutionEventBus,
create_graph_execution,
get_execution_results,
@@ -46,8 +45,10 @@ config = Config()
_user_credit_model = get_user_credit_model()
async def _spend_credits(entry: NodeExecutionEntry) -> int:
return await _user_credit_model.spend_credits(entry, 0, 0)
async def _spend_credits(
user_id: str, cost: int, metadata: UsageTransactionMetadata
) -> int:
return await _user_credit_model.spend_credits(user_id, cost, metadata)
class DatabaseManager(AppService):

View File

@@ -13,7 +13,7 @@ import asyncio
from redis.lock import Lock as RedisLock
from backend.blocks.basic import AgentOutputBlock
from backend.blocks.io import AgentOutputBlock
from backend.data.model import GraphExecutionStats, NodeExecutionStats
from backend.data.notifications import (
AgentRunData,
@@ -49,6 +49,11 @@ from backend.data.execution import (
parse_execution_output,
)
from backend.data.graph import GraphModel, Link, Node
from backend.executor.utils import (
UsageTransactionMetadata,
block_usage_cost,
execution_usage_cost,
)
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.util import json
from backend.util.decorator import error_logged, time_measured
@@ -208,11 +213,7 @@ def execute_node(
extra_exec_kwargs[field_name] = credentials
output_size = 0
cost = 0
try:
# Charge the user for the execution before running the block.
cost = db_client.spend_credits(data)
outputs: dict[str, Any] = {}
for output_name, output_data in node_block.execute(
input_data, **extra_exec_kwargs
@@ -268,7 +269,6 @@ def execute_node(
)
execution_stats.input_size = input_size
execution_stats.output_size = output_size
execution_stats.cost = cost
def _enqueue_next_nodes(
@@ -647,6 +647,53 @@ class Executor:
cls._handle_agent_run_notif(graph_exec, exec_stats)
@classmethod
def _charge_usage(
cls,
node_exec: NodeExecutionEntry,
execution_count: int,
execution_stats: GraphExecutionStats,
) -> int:
block = get_block(node_exec.block_id)
if not block:
logger.error(f"Block {node_exec.block_id} not found.")
return execution_count
cost, matching_filter = block_usage_cost(block=block, input_data=node_exec.data)
if cost > 0:
cls.db_client.spend_credits(
user_id=node_exec.user_id,
cost=cost,
metadata=UsageTransactionMetadata(
graph_exec_id=node_exec.graph_exec_id,
graph_id=node_exec.graph_id,
node_exec_id=node_exec.node_exec_id,
node_id=node_exec.node_id,
block_id=node_exec.block_id,
block=block.name,
input=matching_filter,
),
)
execution_stats.cost += cost
cost, execution_count = execution_usage_cost(execution_count)
if cost > 0:
cls.db_client.spend_credits(
user_id=node_exec.user_id,
cost=cost,
metadata=UsageTransactionMetadata(
graph_exec_id=node_exec.graph_exec_id,
graph_id=node_exec.graph_id,
input={
"execution_count": execution_count,
"charge": "Execution Cost",
},
),
)
execution_stats.cost += cost
return execution_count
@classmethod
@time_measured
def _on_graph_execution(
@@ -683,8 +730,8 @@ class Executor:
for node_exec in graph_exec.start_node_execs:
queue.add(node_exec)
exec_cost_counter = 0
running_executions: dict[str, AsyncResult] = {}
low_balance_error: Optional[InsufficientBalanceError] = None
def make_exec_callback(exec_data: NodeExecutionEntry):
@@ -694,17 +741,13 @@ class Executor:
if not isinstance(result, NodeExecutionStats):
return
nonlocal exec_stats, low_balance_error
nonlocal exec_stats
exec_stats.node_count += 1
exec_stats.nodes_cputime += result.cputime
exec_stats.nodes_walltime += result.walltime
exec_stats.cost += result.cost
if (err := result.error) and isinstance(err, Exception):
exec_stats.node_error_count += 1
if isinstance(err, InsufficientBalanceError):
low_balance_error = err
return callback
while not queue.empty():
@@ -726,6 +769,30 @@ class Executor:
f"Dispatching node execution {exec_data.node_exec_id} "
f"for node {exec_data.node_id}",
)
try:
exec_cost_counter = cls._charge_usage(
node_exec=exec_data,
execution_count=exec_cost_counter + 1,
execution_stats=exec_stats,
)
except InsufficientBalanceError as error:
exec_id = exec_data.node_exec_id
cls.db_client.upsert_execution_output(exec_id, "error", str(error))
exec_update = cls.db_client.update_execution_status(
exec_id, ExecutionStatus.FAILED
)
cls.db_client.send_execution_update(exec_update)
cls._handle_low_balance_notif(
graph_exec.user_id,
graph_exec.graph_id,
exec_stats,
error,
)
raise
running_executions[exec_data.node_id] = cls.executor.apply_async(
cls.on_node_execution,
(queue, exec_data),
@@ -749,32 +816,24 @@ class Executor:
log_metadata.info(f"Finished graph execution {graph_exec.graph_exec_id}")
if isinstance(low_balance_error, InsufficientBalanceError):
cls._handle_low_balance_notif(
graph_exec.user_id,
graph_exec.graph_id,
exec_stats,
low_balance_error,
)
raise low_balance_error
except Exception as e:
log_metadata.exception(
f"Failed graph execution {graph_exec.graph_exec_id}: {e}"
)
error = e
finally:
if error:
log_metadata.error(
f"Failed graph execution {graph_exec.graph_exec_id}: {error}"
)
execution_status = ExecutionStatus.FAILED
else:
execution_status = ExecutionStatus.COMPLETED
if not cancel.is_set():
finished = True
cancel.set()
cancel_thread.join()
clean_exec_files(graph_exec.graph_exec_id)
return (
exec_stats,
ExecutionStatus.FAILED if error else ExecutionStatus.COMPLETED,
error,
)
return exec_stats, execution_status, error
@classmethod
def _handle_agent_run_notif(

View File

@@ -0,0 +1,97 @@
from pydantic import BaseModel
from backend.data.block import Block, BlockInput
from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.cost import BlockCostType
from backend.util.settings import Config
config = Config()
class UsageTransactionMetadata(BaseModel):
graph_exec_id: str | None = None
graph_id: str | None = None
node_id: str | None = None
node_exec_id: str | None = None
block_id: str | None = None
block: str | None = None
input: BlockInput | None = None
def execution_usage_cost(execution_count: int) -> tuple[int, int]:
"""
Calculate the cost of executing a graph based on the number of executions.
Args:
execution_count: Number of executions
Returns:
Tuple of cost amount and remaining execution count
"""
return (
execution_count
// config.execution_cost_count_threshold
* config.execution_cost_per_threshold,
execution_count % config.execution_cost_count_threshold,
)
def block_usage_cost(
block: Block,
input_data: BlockInput,
data_size: float = 0,
run_time: float = 0,
) -> tuple[int, BlockInput]:
"""
Calculate the cost of using a block based on the input data and the block type.
Args:
block: Block object
input_data: Input data for the block
data_size: Size of the input data in bytes
run_time: Execution time of the block in seconds
Returns:
Tuple of cost amount and cost filter
"""
block_costs = BLOCK_COSTS.get(type(block))
if not block_costs:
return 0, {}
for block_cost in block_costs:
if not _is_cost_filter_match(block_cost.cost_filter, input_data):
continue
if block_cost.cost_type == BlockCostType.RUN:
return block_cost.cost_amount, block_cost.cost_filter
if block_cost.cost_type == BlockCostType.SECOND:
return (
int(run_time * block_cost.cost_amount),
block_cost.cost_filter,
)
if block_cost.cost_type == BlockCostType.BYTE:
return (
int(data_size * block_cost.cost_amount),
block_cost.cost_filter,
)
return 0, {}
def _is_cost_filter_match(cost_filter: BlockInput, input_data: BlockInput) -> bool:
"""
Filter rules:
- If cost_filter is an object, then check if cost_filter is the subset of input_data
- Otherwise, check if cost_filter is equal to input_data.
- Undefined, null, and empty string are considered as equal.
"""
if not isinstance(cost_filter, dict) or not isinstance(input_data, dict):
return cost_filter == input_data
return all(
(not input_data.get(k) and not v)
or (input_data.get(k) and _is_cost_filter_match(v, input_data[k]))
for k, v in cost_filter.items()
)

View File

@@ -18,6 +18,7 @@ import backend.data.graph
import backend.data.user
import backend.server.integrations.router
import backend.server.routers.v1
import backend.server.v2.admin.store_admin_routes
import backend.server.v2.library.db
import backend.server.v2.library.model
import backend.server.v2.library.routes
@@ -100,6 +101,11 @@ app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/ap
app.include_router(
backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store"
)
app.include_router(
backend.server.v2.admin.store_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library"
)
@@ -257,12 +263,16 @@ class AgentServer(backend.util.service.AppProcess):
):
return await backend.server.v2.store.routes.create_submission(request, user_id)
### ADMIN ###
@staticmethod
async def test_review_store_listing(
request: backend.server.v2.store.model.ReviewSubmissionRequest,
user: autogpt_libs.auth.models.User,
):
return await backend.server.v2.store.routes.review_submission(request, user)
return await backend.server.v2.admin.store_admin_routes.review_submission(
request.store_listing_version_id, request, user
)
@staticmethod
def test_create_credentials(

View File

@@ -0,0 +1,100 @@
import logging
import typing
import autogpt_libs.auth.depends
import fastapi
import fastapi.responses
import prisma.enums
import backend.server.v2.store.db
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter(prefix="/admin", tags=["store", "admin"])
@router.get(
"/listings",
response_model=backend.server.v2.store.model.StoreListingsWithVersionsResponse,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def get_admin_listings_with_versions(
status: typing.Optional[prisma.enums.SubmissionStatus] = None,
search: typing.Optional[str] = None,
page: int = 1,
page_size: int = 20,
):
"""
Get store listings with their version history for admins.
This provides a consolidated view of listings with their versions,
allowing for an expandable UI in the admin dashboard.
Args:
status: Filter by submission status (PENDING, APPROVED, REJECTED)
search: Search by name, description, or user email
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreListingsWithVersionsResponse with listings and their versions
"""
try:
listings = await backend.server.v2.store.db.get_admin_listings_with_versions(
status=status,
search_query=search,
page=page,
page_size=page_size,
)
return listings
except Exception as e:
logger.exception("Error getting admin listings with versions: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving listings with versions"
},
)
@router.post(
"/submissions/{store_listing_version_id}/review",
response_model=backend.server.v2.store.model.StoreSubmission,
dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)],
)
async def review_submission(
store_listing_version_id: str,
request: backend.server.v2.store.model.ReviewSubmissionRequest,
user: typing.Annotated[
autogpt_libs.auth.models.User,
fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user),
],
):
"""
Review a store listing submission.
Args:
store_listing_version_id: ID of the submission to review
request: Review details including approval status and comments
user: Authenticated admin user performing the review
Returns:
StoreSubmission with updated review information
"""
try:
submission = await backend.server.v2.store.db.review_store_submission(
store_listing_version_id=store_listing_version_id,
is_approved=request.is_approved,
external_comments=request.comments,
internal_comments=request.internal_comments or "",
reviewer_id=user.user_id,
)
return submission
except Exception as e:
logger.exception("Error reviewing submission: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while reviewing the submission"},
)

View File

@@ -1,5 +1,6 @@
from datetime import datetime
import prisma.enums
import prisma.errors
import prisma.models
import pytest
@@ -91,7 +92,6 @@ async def test_add_agent_to_library(mocker):
updatedAt=datetime.now(),
agentId="agent1",
agentVersion=1,
slug="test-agent",
name="Test Agent",
subHeading="Test Agent Subheading",
imageUrls=["https://example.com/image.jpg"],
@@ -100,7 +100,8 @@ async def test_add_agent_to_library(mocker):
isFeatured=False,
isDeleted=False,
isAvailable=True,
isApproved=True,
storeListingId="listing123",
submissionStatus=prisma.enums.SubmissionStatus.APPROVED,
Agent=prisma.models.AgentGraph(
id="agent1",
version=1,

View File

@@ -1,5 +1,5 @@
import logging
from datetime import datetime
from datetime import datetime, timezone
import fastapi
import prisma.enums
@@ -10,7 +10,8 @@ import prisma.types
import backend.data.graph
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
from backend.data.graph import GraphModel
from backend.data.graph import GraphModel, get_sub_graphs
from backend.data.includes import AGENT_GRAPH_INCLUDE
logger = logging.getLogger(__name__)
@@ -43,6 +44,9 @@ async def get_store_agents(
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
"""
Get PUBLIC store agents from the StoreAgent view
"""
logger.debug(
f"Getting store agents. featured={featured}, creator={creator}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
)
@@ -128,6 +132,7 @@ async def get_store_agents(
async def get_store_agent_details(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
"""Get PUBLIC store agent details from the StoreAgent view"""
logger.debug(f"Getting store agent details for {username}/{agent_name}")
try:
@@ -141,6 +146,20 @@ async def get_store_agent_details(
f"Agent {username}/{agent_name} not found"
)
# Retrieve StoreListing to get active_version_id and has_approved_version
store_listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
slug=agent_name,
owningUserId=username, # Direct equality check instead of 'has'
),
include={"ActiveVersion": True},
)
active_version_id = store_listing.activeVersionId if store_listing else None
has_approved_version = (
store_listing.hasApprovedVersion if store_listing else False
)
logger.debug(f"Found agent details for {username}/{agent_name}")
return backend.server.v2.store.model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
@@ -157,6 +176,8 @@ async def get_store_agent_details(
rating=agent.rating,
versions=agent.versions,
last_updated=agent.updated_at,
active_version_id=active_version_id,
has_approved_version=has_approved_version,
)
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
@@ -174,6 +195,7 @@ async def get_store_creators(
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
"""Get PUBLIC store creators from the Creator view"""
logger.debug(
f"Getting store creators. featured={featured}, search={search_query}, sorted_by={sorted_by}, page={page}"
)
@@ -321,6 +343,7 @@ async def get_store_creator_details(
async def get_store_submissions(
user_id: str, page: int = 1, page_size: int = 20
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
"""Get store submissions for the authenticated user -- not an admin"""
logger.debug(f"Getting store submissions for user {user_id}, page={page}")
try:
@@ -342,8 +365,9 @@ async def get_store_submissions(
total_pages = (total + page_size - 1) // page_size
# Convert to response models
submission_models = [
backend.server.v2.store.model.StoreSubmission(
submission_models = []
for sub in submissions:
submission_model = backend.server.v2.store.model.StoreSubmission(
agent_id=sub.agent_id,
agent_version=sub.agent_version,
name=sub.name,
@@ -351,13 +375,18 @@ async def get_store_submissions(
slug=sub.slug,
description=sub.description,
image_urls=sub.image_urls or [],
date_submitted=sub.date_submitted or datetime.now(),
date_submitted=sub.date_submitted or datetime.now(tz=timezone.utc),
status=sub.status,
runs=sub.runs or 0,
rating=sub.rating or 0.0,
store_listing_version_id=sub.store_listing_version_id,
reviewer_id=sub.reviewer_id,
review_comments=sub.review_comments,
# internal_comments omitted for regular users
reviewed_at=sub.reviewed_at,
changes_summary=sub.changes_summary,
)
for sub in submissions
]
submission_models.append(submission_model)
logger.debug(f"Found {len(submission_models)} submissions")
return backend.server.v2.store.model.StoreSubmissionsResponse(
@@ -389,7 +418,7 @@ async def delete_store_submission(
submission_id: str,
) -> bool:
"""
Delete a store listing submission.
Delete a store listing submission as the submitting user.
Args:
user_id: ID of the authenticated user
@@ -436,9 +465,10 @@ async def create_store_submission(
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str = "Initial Submission",
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new store listing submission.
Create the first (and only) store listing and thus submission as a normal user
Args:
user_id: ID of the authenticated user submitting the listing
@@ -449,7 +479,9 @@ async def create_store_submission(
video_url: Optional URL to video demo
image_urls: List of image URLs for the listing
description: Description of the agent
sub_heading: Optional sub-heading for the agent
categories: List of categories for the agent
changes_summary: Summary of changes made in this submission
Returns:
StoreSubmission: The created store submission
@@ -479,45 +511,66 @@ async def create_store_submission(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
listing = await prisma.models.StoreListing.prisma().find_first(
# Check if listing already exists for this agent
existing_listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
agentId=agent_id, owningUserId=user_id
)
)
if listing is not None:
logger.warning(f"Listing already exists for agent {agent_id}")
raise backend.server.v2.store.exceptions.ListingExistsError(
"Listing already exists for this agent"
if existing_listing is not None:
logger.info(
f"Listing already exists for agent {agent_id}, creating new version instead"
)
# Create the store listing
listing = await prisma.models.StoreListing.prisma().create(
data={
"agentId": agent_id,
"agentVersion": agent_version,
"owningUserId": user_id,
"createdAt": datetime.now(),
"StoreListingVersions": {
"create": {
"agentId": agent_id,
"agentVersion": agent_version,
"slug": slug,
"name": name,
"videoUrl": video_url,
"imageUrls": image_urls,
"description": description,
"categories": categories,
"subHeading": sub_heading,
}
},
# Delegate to create_store_version which already handles this case correctly
return await create_store_version(
user_id=user_id,
agent_id=agent_id,
agent_version=agent_version,
store_listing_id=existing_listing.id,
name=name,
video_url=video_url,
image_urls=image_urls,
description=description,
sub_heading=sub_heading,
categories=categories,
changes_summary=changes_summary,
)
# If no existing listing, create a new one
data = prisma.types.StoreListingCreateInput(
slug=slug,
agentId=agent_id,
agentVersion=agent_version,
owningUserId=user_id,
createdAt=datetime.now(tz=timezone.utc),
Versions={
"create": [
prisma.types.StoreListingVersionCreateInput(
agentId=agent_id,
agentVersion=agent_version,
name=name,
videoUrl=video_url,
imageUrls=image_urls,
description=description,
categories=categories,
subHeading=sub_heading,
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
submittedAt=datetime.now(tz=timezone.utc),
changesSummary=changes_summary,
)
]
},
include={"StoreListingVersions": True},
)
listing = await prisma.models.StoreListing.prisma().create(
data=data,
include=prisma.types.StoreListingInclude(Versions=True),
)
store_listing_version_id = (
listing.StoreListingVersions[0].id
if listing.StoreListingVersions is not None
and len(listing.StoreListingVersions) > 0
listing.Versions[0].id
if listing.Versions is not None and len(listing.Versions) > 0
else None
)
@@ -536,6 +589,7 @@ async def create_store_submission(
runs=0,
rating=0.0,
store_listing_version_id=store_listing_version_id,
changes_summary=changes_summary,
)
except (
@@ -550,13 +604,137 @@ async def create_store_submission(
) from e
async def create_store_version(
user_id: str,
agent_id: str,
agent_version: int,
store_listing_id: str,
name: str,
video_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str = "Update Submission",
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new version for an existing store listing
Args:
user_id: ID of the authenticated user submitting the version
agent_id: ID of the agent being submitted
agent_version: Version of the agent being submitted
store_listing_id: ID of the existing store listing
name: Name of the agent
video_url: Optional URL to video demo
image_urls: List of image URLs for the listing
description: Description of the agent
categories: List of categories for the agent
changes_summary: Summary of changes from the previous version
Returns:
StoreSubmission: The created store submission
"""
logger.debug(
f"Creating new version for store listing {store_listing_id} for user {user_id}, agent {agent_id} v{agent_version}"
)
try:
# First verify the listing belongs to this user
listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
id=store_listing_id, owningUserId=user_id
),
include={"Versions": {"order_by": {"version": "desc"}, "take": 1}},
)
if not listing:
raise backend.server.v2.store.exceptions.ListingNotFoundError(
f"Store listing not found. User ID: {user_id}, Listing ID: {store_listing_id}"
)
# Verify the agent belongs to this user
agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput(
id=agent_id, version=agent_version, userId=user_id
)
)
if not agent:
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
# Get the latest version number
latest_version = listing.Versions[0] if listing.Versions else None
next_version = (latest_version.version + 1) if latest_version else 1
# Create a new version for the existing listing
new_version = await prisma.models.StoreListingVersion.prisma().create(
data=prisma.types.StoreListingVersionCreateInput(
version=next_version,
agentId=agent_id,
agentVersion=agent_version,
name=name,
videoUrl=video_url,
imageUrls=image_urls,
description=description,
categories=categories,
subHeading=sub_heading,
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
submittedAt=datetime.now(),
changesSummary=changes_summary,
storeListingId=store_listing_id,
)
)
logger.debug(
f"Created new version for listing {store_listing_id} of agent {agent_id}"
)
# Return submission details
return backend.server.v2.store.model.StoreSubmission(
agent_id=agent_id,
agent_version=agent_version,
name=name,
slug=listing.slug,
sub_heading=sub_heading,
description=description,
image_urls=image_urls,
date_submitted=datetime.now(),
status=prisma.enums.SubmissionStatus.PENDING,
runs=0,
rating=0.0,
store_listing_version_id=new_version.id,
changes_summary=changes_summary,
version=next_version,
)
except prisma.errors.PrismaError as e:
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create new store version"
) from e
async def create_store_review(
user_id: str,
store_listing_version_id: str,
score: int,
comments: str | None = None,
) -> backend.server.v2.store.model.StoreReview:
"""Create a review for a store listing as a user to detail their experience"""
try:
data = prisma.types.StoreListingReviewUpsertInput(
update=prisma.types.StoreListingReviewUpdateInput(
score=score,
comments=comments,
),
create=prisma.types.StoreListingReviewCreateInput(
reviewByUserId=user_id,
storeListingVersionId=store_listing_version_id,
score=score,
comments=comments,
),
)
review = await prisma.models.StoreListingReview.prisma().upsert(
where={
"storeListingVersionId_reviewByUserId": {
@@ -564,18 +742,7 @@ async def create_store_review(
"reviewByUserId": user_id,
}
},
data={
"create": {
"reviewByUserId": user_id,
"storeListingVersionId": store_listing_version_id,
"score": score,
"comments": comments,
},
"update": {
"score": score,
"comments": comments,
},
},
data=data,
)
return backend.server.v2.store.model.StoreReview(
@@ -597,7 +764,7 @@ async def get_user_profile(
try:
profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id} # type: ignore
where={"userId": user_id}
)
if not profile:
@@ -702,6 +869,7 @@ async def get_my_agents(
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.MyAgentsResponse:
"""Get the agents for the authenticated user"""
logger.debug(f"Getting my agents for user {user_id}, page={page}")
try:
@@ -780,15 +948,60 @@ async def get_agent(
return graph
#####################################################
################## ADMIN FUNCTIONS ##################
#####################################################
async def _get_missing_sub_store_listing(
graph: prisma.models.AgentGraph,
) -> list[prisma.models.AgentGraph]:
"""
Agent graph can have sub-graphs, and those sub-graphs also need to be store listed.
This method fetches the sub-graphs, and returns the ones not listed in the store.
"""
sub_graphs = await get_sub_graphs(graph)
if not sub_graphs:
return []
# Fetch all the sub-graphs that are listed, and return the ones missing.
store_listed_sub_graphs = {
(listing.agentId, listing.agentVersion)
for listing in await prisma.models.StoreListingVersion.prisma().find_many(
where={
"OR": [
{"agentId": sub_graph.id, "agentVersion": sub_graph.version}
for sub_graph in sub_graphs
],
"submissionStatus": prisma.enums.SubmissionStatus.APPROVED,
"isDeleted": False,
}
)
}
return [
sub_graph
for sub_graph in sub_graphs
if (sub_graph.id, sub_graph.version) not in store_listed_sub_graphs
]
async def review_store_submission(
store_listing_version_id: str, is_approved: bool, comments: str, reviewer_id: str
) -> prisma.models.StoreListingSubmission:
"""Review a store listing submission."""
store_listing_version_id: str,
is_approved: bool,
external_comments: str,
internal_comments: str,
reviewer_id: str,
) -> backend.server.v2.store.model.StoreSubmission:
"""Review a store listing submission as an admin."""
try:
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id},
include={"StoreListing": True},
include={
"StoreListing": True,
"Agent": {"include": AGENT_GRAPH_INCLUDE}, # type: ignore
},
)
)
@@ -798,10 +1011,34 @@ async def review_store_submission(
detail=f"Store listing version {store_listing_version_id} not found",
)
if is_approved:
# If approving, update the listing to indicate it has an approved version
if is_approved and store_listing_version.Agent:
heading = f"Sub-graph of {store_listing_version.name}v{store_listing_version.agentVersion}"
sub_store_listing_versions = [
prisma.types.StoreListingVersionCreateWithoutRelationsInput(
agentId=sub_graph.id,
agentVersion=sub_graph.version,
name=sub_graph.name or heading,
submissionStatus=prisma.enums.SubmissionStatus.APPROVED,
subHeading=heading,
description=f"{heading}: {sub_graph.description}",
changesSummary=f"This listing is added as a {heading} / #{store_listing_version.agentId}.",
isAvailable=False, # Hide sub-graphs from the store by default.
submittedAt=datetime.now(tz=timezone.utc),
)
for sub_graph in await _get_missing_sub_store_listing(
store_listing_version.Agent
)
]
await prisma.models.StoreListing.prisma().update(
where={"id": store_listing_version.StoreListing.id},
data={"isApproved": True},
data={
"hasApprovedVersion": True,
"ActiveVersion": {"connect": {"id": store_listing_version_id}},
"Versions": {"create": sub_store_listing_versions},
},
)
submission_status = (
@@ -810,36 +1047,230 @@ async def review_store_submission(
else prisma.enums.SubmissionStatus.REJECTED
)
update_data: prisma.types.StoreListingSubmissionUpdateInput = {
"Status": submission_status,
"reviewComments": comments,
# Update the version with review information
update_data: prisma.types.StoreListingVersionUpdateInput = {
"submissionStatus": submission_status,
"reviewComments": external_comments,
"internalComments": internal_comments,
"Reviewer": {"connect": {"id": reviewer_id}},
"StoreListing": {"connect": {"id": store_listing_version.StoreListing.id}},
"reviewedAt": datetime.now(tz=timezone.utc),
}
create_data: prisma.types.StoreListingSubmissionCreateInput = {
**update_data,
"StoreListingVersion": {"connect": {"id": store_listing_version_id}},
}
submission = await prisma.models.StoreListingSubmission.prisma().upsert(
where={"storeListingVersionId": store_listing_version_id},
data={
"create": create_data,
"update": update_data,
},
# Update the version
submission = await prisma.models.StoreListingVersion.prisma().update(
where={"id": store_listing_version_id},
data=update_data,
include={"StoreListing": True},
)
if not submission:
raise fastapi.HTTPException( # FIXME: don't return HTTP exceptions here
status_code=404,
detail=f"Store listing submission {store_listing_version_id} not found",
raise backend.server.v2.store.exceptions.DatabaseError(
f"Failed to update store listing version {store_listing_version_id}"
)
return submission
# Convert to Pydantic model for consistency
return backend.server.v2.store.model.StoreSubmission(
agent_id=submission.agentId,
agent_version=submission.agentVersion,
name=submission.name,
sub_heading=submission.subHeading,
slug=(
submission.StoreListing.slug
if hasattr(submission, "storeListing") and submission.StoreListing
else ""
),
description=submission.description,
image_urls=submission.imageUrls or [],
date_submitted=submission.submittedAt or submission.createdAt,
status=submission.submissionStatus,
runs=0, # Default values since we don't have this data here
rating=0.0,
store_listing_version_id=submission.id,
reviewer_id=submission.reviewerId,
review_comments=submission.reviewComments,
internal_comments=submission.internalComments,
reviewed_at=submission.reviewedAt,
changes_summary=submission.changesSummary,
)
except Exception as e:
logger.error(f"Could not create store submission review: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store submission review"
) from e
async def get_admin_listings_with_versions(
status: prisma.enums.SubmissionStatus | None = None,
search_query: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreListingsWithVersionsResponse:
"""
Get store listings for admins with all their versions.
Args:
status: Filter by submission status (PENDING, APPROVED, REJECTED)
search_query: Search by name, description, or user email
page: Page number for pagination
page_size: Number of items per page
Returns:
StoreListingsWithVersionsResponse with listings and their versions
"""
logger.debug(
f"Getting admin store listings with status={status}, search={search_query}, page={page}"
)
try:
# Build the where clause for StoreListing
where_dict: prisma.types.StoreListingWhereInput = {
"isDeleted": False,
}
if status:
where_dict["Versions"] = {"some": {"submissionStatus": status}}
sanitized_query = sanitize_query(search_query)
if sanitized_query:
# Find users with matching email
matching_users = await prisma.models.User.prisma().find_many(
where={"email": {"contains": sanitized_query, "mode": "insensitive"}},
)
user_ids = [user.id for user in matching_users]
# Set up OR conditions
where_dict["OR"] = [
{"slug": {"contains": sanitized_query, "mode": "insensitive"}},
{
"Versions": {
"some": {
"name": {"contains": sanitized_query, "mode": "insensitive"}
}
}
},
{
"Versions": {
"some": {
"description": {
"contains": sanitized_query,
"mode": "insensitive",
}
}
}
},
{
"Versions": {
"some": {
"subHeading": {
"contains": sanitized_query,
"mode": "insensitive",
}
}
}
},
]
# Add user_id condition if any users matched
if user_ids:
where_dict["OR"].append({"owningUserId": {"in": user_ids}})
# Calculate pagination
skip = (page - 1) * page_size
# Create proper Prisma types for the query
where = prisma.types.StoreListingWhereInput(**where_dict)
include = prisma.types.StoreListingInclude(
Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing(
order_by=prisma.types._StoreListingVersion_version_OrderByInput(
version="desc"
)
),
OwningUser=True,
)
# Query listings with their versions
listings = await prisma.models.StoreListing.prisma().find_many(
where=where,
skip=skip,
take=page_size,
include=include,
order=[{"createdAt": "desc"}],
)
# Get total count for pagination
total = await prisma.models.StoreListing.prisma().count(where=where)
total_pages = (total + page_size - 1) // page_size
# Convert to response models
listings_with_versions = []
for listing in listings:
versions: list[backend.server.v2.store.model.StoreSubmission] = []
# If we have versions, turn them into StoreSubmission models
for version in listing.Versions or []:
version_model = backend.server.v2.store.model.StoreSubmission(
agent_id=version.agentId,
agent_version=version.agentVersion,
name=version.name,
sub_heading=version.subHeading,
slug=listing.slug,
description=version.description,
image_urls=version.imageUrls or [],
date_submitted=version.submittedAt or version.createdAt,
status=version.submissionStatus,
runs=0, # Default values since we don't have this data here
rating=0.0, # Default values since we don't have this data here
store_listing_version_id=version.id,
reviewer_id=version.reviewerId,
review_comments=version.reviewComments,
internal_comments=version.internalComments,
reviewed_at=version.reviewedAt,
changes_summary=version.changesSummary,
version=version.version,
)
versions.append(version_model)
# Get the latest version (first in the sorted list)
latest_version = versions[0] if versions else None
creator_email = listing.OwningUser.email if listing.OwningUser else None
listing_with_versions = (
backend.server.v2.store.model.StoreListingWithVersions(
listing_id=listing.id,
slug=listing.slug,
agent_id=listing.agentId,
agent_version=listing.agentVersion,
active_version_id=listing.activeVersionId,
has_approved_version=listing.hasApprovedVersion,
creator_email=creator_email,
latest_version=latest_version,
versions=versions,
)
)
listings_with_versions.append(listing_with_versions)
logger.debug(f"Found {len(listings_with_versions)} listings for admin")
return backend.server.v2.store.model.StoreListingsWithVersionsResponse(
listings=listings_with_versions,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error fetching admin store listings: {e}")
# Return empty response rather than exposing internal errors
return backend.server.v2.store.model.StoreListingsWithVersionsResponse(
listings=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=0,
total_pages=0,
page_size=page_size,
),
)

View File

@@ -1,5 +1,6 @@
from datetime import datetime
import prisma.enums
import prisma.errors
import prisma.models
import pytest
@@ -83,21 +84,35 @@ async def test_get_store_agent_details(mocker):
updated_at=datetime.now(),
)
# Mock prisma call
# Create a mock StoreListing result
mock_store_listing = mocker.MagicMock()
mock_store_listing.activeVersionId = "active-version-id"
mock_store_listing.hasApprovedVersion = True
# Mock StoreAgent prisma call
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
# Mock StoreListing prisma call - this is what was missing
mock_store_listing_db = mocker.patch("prisma.models.StoreListing.prisma")
mock_store_listing_db.return_value.find_first = mocker.AsyncMock(
return_value=mock_store_listing
)
# Call function
result = await db.get_store_agent_details("creator", "test-agent")
# Verify results
assert result.slug == "test-agent"
assert result.agent_name == "Test Agent"
assert result.active_version_id == "active-version-id"
assert result.has_approved_version is True
# Verify mock called correctly
# Verify mocks called correctly
mock_store_agent.return_value.find_first.assert_called_once_with(
where={"creator_username": "creator", "slug": "test-agent"}
)
mock_store_listing_db.return_value.find_first.assert_called_once()
@pytest.mark.asyncio
@@ -153,16 +168,16 @@ async def test_create_store_submission(mocker):
createdAt=datetime.now(),
updatedAt=datetime.now(),
isDeleted=False,
isApproved=False,
hasApprovedVersion=False,
slug="test-agent",
agentId="agent-id",
agentVersion=1,
owningUserId="user-id",
StoreListingVersions=[
Versions=[
prisma.models.StoreListingVersion(
id="version-id",
agentId="agent-id",
agentVersion=1,
slug="test-agent",
name="Test Agent",
description="Test description",
createdAt=datetime.now(),
@@ -173,8 +188,9 @@ async def test_create_store_submission(mocker):
isFeatured=False,
isDeleted=False,
version=1,
storeListingId="listing-id",
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
isAvailable=True,
isApproved=False,
)
],
)

View File

@@ -70,6 +70,12 @@ class ProfileNotFoundError(StoreError):
pass
class ListingNotFoundError(StoreError):
"""Raised when a store listing is not found"""
pass
class SubmissionNotFoundError(StoreError):
"""Raised when a submission is not found"""

View File

@@ -67,6 +67,9 @@ class StoreAgentDetails(pydantic.BaseModel):
versions: list[str]
last_updated: datetime.datetime
active_version_id: str | None = None
has_approved_version: bool = False
class Creator(pydantic.BaseModel):
name: str
@@ -117,6 +120,19 @@ class StoreSubmission(pydantic.BaseModel):
runs: int
rating: float
store_listing_version_id: str | None = None
version: int | None = None # Actual version number from the database
reviewer_id: str | None = None
review_comments: str | None = None # External comments visible to creator
internal_comments: str | None = None # Private notes for admin use only
reviewed_at: datetime.datetime | None = None
changes_summary: str | None = None
reviewer_id: str | None = None
review_comments: str | None = None # External comments visible to creator
internal_comments: str | None = None # Private notes for admin use only
reviewed_at: datetime.datetime | None = None
changes_summary: str | None = None
class StoreSubmissionsResponse(pydantic.BaseModel):
@@ -124,6 +140,27 @@ class StoreSubmissionsResponse(pydantic.BaseModel):
pagination: Pagination
class StoreListingWithVersions(pydantic.BaseModel):
"""A store listing with its version history"""
listing_id: str
slug: str
agent_id: str
agent_version: int
active_version_id: str | None = None
has_approved_version: bool = False
creator_email: str | None = None
latest_version: StoreSubmission | None = None
versions: list[StoreSubmission] = []
class StoreListingsWithVersionsResponse(pydantic.BaseModel):
"""Response model for listings with version history"""
listings: list[StoreListingWithVersions]
pagination: Pagination
class StoreSubmissionRequest(pydantic.BaseModel):
agent_id: str
agent_version: int
@@ -134,6 +171,7 @@ class StoreSubmissionRequest(pydantic.BaseModel):
image_urls: list[str] = []
description: str = ""
categories: list[str] = []
changes_summary: str | None = None
class ProfileDetails(pydantic.BaseModel):
@@ -158,4 +196,5 @@ class StoreReviewCreate(pydantic.BaseModel):
class ReviewSubmissionRequest(pydantic.BaseModel):
store_listing_version_id: str
is_approved: bool
comments: str
comments: str # External comments visible to creator
internal_comments: str | None = None # Private admin notes

View File

@@ -34,7 +34,7 @@ router = fastapi.APIRouter()
async def get_profile(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
],
):
"""
Get the profile details for the authenticated user.
@@ -338,7 +338,7 @@ async def get_creator(
async def get_my_agents(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
],
):
try:
agents = await backend.server.v2.store.db.get_my_agents(user_id)
@@ -466,7 +466,7 @@ async def create_submission(
HTTPException: If there is an error creating the submission
"""
try:
submission = await backend.server.v2.store.db.create_store_submission(
return await backend.server.v2.store.db.create_store_submission(
user_id=user_id,
agent_id=submission_request.agent_id,
agent_version=submission_request.agent_version,
@@ -477,8 +477,8 @@ async def create_submission(
description=submission_request.description,
sub_heading=submission_request.sub_heading,
categories=submission_request.categories,
changes_summary=submission_request.changes_summary or "Initial Submission",
)
return submission
except Exception:
logger.exception("Exception occurred whilst creating store submission")
return fastapi.responses.JSONResponse(
@@ -626,31 +626,3 @@ async def download_agent_file(
return fastapi.responses.FileResponse(
tmp_file.name, filename=file_name, media_type="application/json"
)
@router.post(
"/submissions/review/{store_listing_version_id}",
tags=["store", "private"],
)
async def review_submission(
request: backend.server.v2.store.model.ReviewSubmissionRequest,
user: typing.Annotated[
autogpt_libs.auth.models.User,
fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user),
],
):
# Proceed with the review submission logic
try:
submission = await backend.server.v2.store.db.review_store_submission(
store_listing_version_id=request.store_listing_version_id,
is_approved=request.is_approved,
comments=request.comments,
reviewer_id=user.user_id,
)
return submission
except Exception as e:
logger.error(f"Could not create store submission review: {e}")
raise fastapi.HTTPException(
status_code=500,
detail="An error occurred while creating the store submission review",
)

View File

@@ -1,6 +1,7 @@
from prisma.models import User
from backend.blocks.basic import AgentInputBlock, PrintToConsoleBlock
from backend.blocks.basic import StoreValueBlock
from backend.blocks.io import AgentInputBlock
from backend.blocks.text import FillTextTemplateBlock
from backend.data import graph
from backend.data.graph import create_graph
@@ -29,7 +30,7 @@ def create_test_graph() -> graph.Graph:
"""
InputBlock
\
---- FillTextTemplateBlock ---- PrintToConsoleBlock
---- FillTextTemplateBlock ---- StoreValueBlock
/
InputBlock
"""
@@ -52,7 +53,7 @@ def create_test_graph() -> graph.Graph:
"values_#_c": "!!!",
},
),
graph.Node(block_id=PrintToConsoleBlock().id),
graph.Node(block_id=StoreValueBlock().id),
]
links = [
graph.Link(
@@ -71,7 +72,7 @@ def create_test_graph() -> graph.Graph:
source_id=nodes[2].id,
sink_id=nodes[3].id,
source_name="output",
sink_name="text",
sink_name="input",
),
]
@@ -93,11 +94,7 @@ async def sample_agent():
user_id=test_user.id,
node_input=input_data,
)
print(response)
result = await wait_execution(
test_user.id, test_graph.id, response.graph_exec_id, 10
)
print(result)
await wait_execution(test_user.id, test_graph.id, response.graph_exec_id, 10)
if __name__ == "__main__":

View File

@@ -29,15 +29,25 @@ def clean_exec_files(graph_exec_id: str, file: str = "") -> None:
shutil.rmtree(exec_path)
"""
MediaFile is a string that represents a file. It can be one of the following:
- Data URI: base64 encoded media file. See https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data/
- URL: Media file hosted on the internet, it starts with http:// or https://.
- Local path (anything else): A temporary file path living within graph execution time.
Note: Replace this type alias into a proper class, when more information is needed.
"""
MediaFile = str
class MediaFile(str):
"""
MediaFile is a string that represents a file. It can be one of the following:
- Data URI: base64 encoded media file. See https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data/
- URL: Media file hosted on the internet, it starts with http:// or https://.
- Local path (anything else): A temporary file path living within graph execution time.
Note: Replace this type alias into a proper class, when more information is needed.
"""
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
return handler(str)
@classmethod
def __get_pydantic_json_schema__(cls, core_schema, handler):
json_schema = handler(core_schema)
json_schema["format"] = "file"
return json_schema
def store_media_file(

View File

@@ -113,6 +113,14 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
default="%Y-%W", # This will allow for weekly refunds per user.
description="Time key format for refund requests.",
)
execution_cost_count_threshold: int = Field(
default=100,
description="Number of executions after which the cost is calculated.",
)
execution_cost_per_threshold: int = Field(
default=1,
description="Cost per execution in cents after each threshold.",
)
model_config = SettingsConfigDict(
env_file=".env",

View File

@@ -0,0 +1,372 @@
/*
Warnings:
- The enum type "SubmissionStatus" will be replaced. The 'DAFT' value is removed, so any data using 'DAFT' will be updated to 'DRAFT'. If there are rows still expecting 'DAFT' after this change, it will fail.
- You are about to drop the column "isApproved" on the "StoreListing" table. All the data in that column will be lost.
- You are about to drop the column "slug" on the "StoreListingVersion" table. All the data in that column will be lost.
- You are about to drop the "StoreListingSubmission" table. Data in that table (beyond what is copied over) will be permanently lost.
- A unique constraint covering the column "activeVersionId" on the "StoreListing" table will be added. If duplicates already exist, this will fail.
- A unique constraint covering the columns ("storeListingId","version") on "StoreListingVersion" will be added. If duplicates already exist, this will fail.
- The "storeListingId" column on "StoreListingVersion" is set to NOT NULL. If any rows currently have a NULL value, this step will fail.
- The views "StoreSubmission", "StoreAgent", and "Creator" are dropped and recreated. Any usage or references to them will be momentarily disrupted until the views are recreated.
*/
BEGIN;
-- First, drop all views that depend on the columns and types we're modifying
DROP VIEW IF EXISTS "StoreSubmission";
DROP VIEW IF EXISTS "StoreAgent";
DROP VIEW IF EXISTS "Creator";
-- Create the new enum type
CREATE TYPE "SubmissionStatus_new" AS ENUM ('DRAFT', 'PENDING', 'APPROVED', 'REJECTED');
-- Modify the column with the correct casing (Status with capital S)
ALTER TABLE "StoreListingSubmission" ALTER COLUMN "Status" DROP DEFAULT;
ALTER TABLE "StoreListingSubmission"
ALTER COLUMN "Status" TYPE "SubmissionStatus_new"
USING (
CASE WHEN "Status"::text = 'DAFT' THEN 'DRAFT'::text
ELSE "Status"::text
END
)::"SubmissionStatus_new";
-- Rename the enum types
ALTER TYPE "SubmissionStatus" RENAME TO "SubmissionStatus_old";
ALTER TYPE "SubmissionStatus_new" RENAME TO "SubmissionStatus";
DROP TYPE "SubmissionStatus_old";
-- Set default back
ALTER TABLE "StoreListingSubmission" ALTER COLUMN "Status" SET DEFAULT 'PENDING';
-- Drop constraints
ALTER TABLE "StoreListingSubmission" DROP CONSTRAINT IF EXISTS "StoreListingSubmission_reviewerId_fkey";
-- Drop indexes
DROP INDEX IF EXISTS "StoreListing_isDeleted_isApproved_idx";
DROP INDEX IF EXISTS "StoreListingSubmission_storeListingVersionId_key";
-- Modify StoreListing
ALTER TABLE "StoreListing"
DROP COLUMN IF EXISTS "isApproved",
ADD COLUMN IF NOT EXISTS "activeVersionId" TEXT,
ADD COLUMN IF NOT EXISTS "hasApprovedVersion" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "slug" TEXT;
-- First add ALL columns to StoreListingVersion (including the submissionStatus column)
ALTER TABLE "StoreListingVersion"
ADD COLUMN IF NOT EXISTS "reviewerId" TEXT,
ADD COLUMN IF NOT EXISTS "reviewComments" TEXT,
ADD COLUMN IF NOT EXISTS "internalComments" TEXT,
ADD COLUMN IF NOT EXISTS "reviewedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "changesSummary" TEXT,
ADD COLUMN IF NOT EXISTS "submissionStatus" "SubmissionStatus" NOT NULL DEFAULT 'DRAFT',
ADD COLUMN IF NOT EXISTS "submittedAt" TIMESTAMP(3),
ALTER COLUMN "storeListingId" SET NOT NULL;
-- NOW copy data from StoreListingSubmission to StoreListingVersion
DO $$
BEGIN
-- First, check what columns actually exist in the StoreListingSubmission table
DECLARE
has_reviewerId BOOLEAN := (
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'StoreListingSubmission'
AND column_name = 'reviewerId'
)
);
has_reviewComments BOOLEAN := (
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'StoreListingSubmission'
AND column_name = 'reviewComments'
)
);
has_changesSummary BOOLEAN := (
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'StoreListingSubmission'
AND column_name = 'changesSummary'
)
);
BEGIN
-- Only copy fields that we know exist
IF has_reviewerId THEN
UPDATE "StoreListingVersion" AS v
SET "reviewerId" = s."reviewerId"
FROM "StoreListingSubmission" AS s
WHERE v."id" = s."storeListingVersionId";
END IF;
IF has_reviewComments THEN
UPDATE "StoreListingVersion" AS v
SET "reviewComments" = s."reviewComments"
FROM "StoreListingSubmission" AS s
WHERE v."id" = s."storeListingVersionId";
END IF;
IF has_changesSummary THEN
UPDATE "StoreListingVersion" AS v
SET "changesSummary" = s."changesSummary"
FROM "StoreListingSubmission" AS s
WHERE v."id" = s."storeListingVersionId";
END IF;
END;
-- Update submission status based on StoreListingSubmission status
UPDATE "StoreListingVersion" AS v
SET "submissionStatus" = s."Status"
FROM "StoreListingSubmission" AS s
WHERE v."id" = s."storeListingVersionId";
-- Update reviewedAt timestamps for versions with APPROVED or REJECTED status
UPDATE "StoreListingVersion" AS v
SET "reviewedAt" = s."updatedAt"
FROM "StoreListingSubmission" AS s
WHERE v."id" = s."storeListingVersionId"
AND s."Status" IN ('APPROVED', 'REJECTED');
END;
$$;
-- Drop the StoreListingSubmission table
DROP TABLE IF EXISTS "StoreListingSubmission";
-- Copy slugs from StoreListingVersion to StoreListing
WITH latest_versions AS (
SELECT
"storeListingId",
"slug",
ROW_NUMBER() OVER (PARTITION BY "storeListingId" ORDER BY "version" DESC) as rn
FROM "StoreListingVersion"
)
UPDATE "StoreListing" sl
SET "slug" = lv."slug"
FROM latest_versions lv
WHERE sl."id" = lv."storeListingId"
AND lv.rn = 1;
-- Make StoreListing.slug required and unique
ALTER TABLE "StoreListing" ALTER COLUMN "slug" SET NOT NULL;
CREATE UNIQUE INDEX "StoreListing_owningUserId_slug_key" ON "StoreListing"("owningUserId", "slug");
DROP INDEX "StoreListing_owningUserId_idx";
-- Drop the slug column from StoreListingVersion since it's now on StoreListing
ALTER TABLE "StoreListingVersion" DROP COLUMN "slug";
-- Update both sides of the relation from one-to-one to one-to-many
-- The AgentGraph->StoreListingVersion relationship is now one-to-many
-- Drop the unique constraint but add a non-unique index for query performance
ALTER TABLE "StoreListingVersion" DROP CONSTRAINT IF EXISTS "StoreListingVersion_agentId_agentVersion_key";
CREATE INDEX IF NOT EXISTS "StoreListingVersion_agentId_agentVersion_idx"
ON "StoreListingVersion"("agentId", "agentVersion");
-- Set isApproved based on submissionStatus before removing it
UPDATE "StoreListingVersion"
SET "submissionStatus" = 'APPROVED'
WHERE "isApproved" = true;
-- Drop the isApproved column from StoreListingVersion since it's redundant with submissionStatus
ALTER TABLE "StoreListingVersion" DROP COLUMN "isApproved";
-- Initialize hasApprovedVersion for existing StoreListing rows ***
-- This sets "hasApprovedVersion" = TRUE for any StoreListing
-- that has at least one corresponding version with "APPROVED" status.
UPDATE "StoreListing" sl
SET "hasApprovedVersion" = (
SELECT COUNT(*) > 0
FROM "StoreListingVersion" slv
WHERE slv."storeListingId" = sl.id
AND slv."submissionStatus" = 'APPROVED'
AND sl."agentId" = slv."agentId"
AND sl."agentVersion" = slv."agentVersion"
);
-- Create new indexes
CREATE UNIQUE INDEX IF NOT EXISTS "StoreListing_activeVersionId_key"
ON "StoreListing"("activeVersionId");
CREATE INDEX IF NOT EXISTS "StoreListing_isDeleted_hasApprovedVersion_idx"
ON "StoreListing"("isDeleted", "hasApprovedVersion");
CREATE INDEX IF NOT EXISTS "StoreListingVersion_storeListingId_submissionStatus_isAvailable_idx"
ON "StoreListingVersion"("storeListingId", "submissionStatus", "isAvailable");
CREATE INDEX IF NOT EXISTS "StoreListingVersion_submissionStatus_idx"
ON "StoreListingVersion"("submissionStatus");
CREATE UNIQUE INDEX IF NOT EXISTS "StoreListingVersion_storeListingId_version_key"
ON "StoreListingVersion"("storeListingId", "version");
-- Add foreign keys
ALTER TABLE "StoreListing"
ADD CONSTRAINT "StoreListing_activeVersionId_fkey"
FOREIGN KEY ("activeVersionId") REFERENCES "StoreListingVersion"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- Add reviewer foreign key
ALTER TABLE "StoreListingVersion"
ADD CONSTRAINT "StoreListingVersion_reviewerId_fkey"
FOREIGN KEY ("reviewerId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- Add index for reviewer
CREATE INDEX IF NOT EXISTS "StoreListingVersion_reviewerId_idx"
ON "StoreListingVersion"("reviewerId");
-- DropIndex
DROP INDEX "StoreListingVersion_agentId_agentVersion_key";
-- RenameIndex
ALTER INDEX "StoreListingVersion_storeListingId_submissionStatus_isAvailable_idx"
RENAME TO "StoreListingVersion_storeListingId_submissionStatus_isAvail_idx";
-- Recreate the views with updated column references
-- 1. Recreate StoreSubmission view
CREATE VIEW "StoreSubmission" AS
SELECT
sl.id AS listing_id,
sl."owningUserId" AS user_id,
slv."agentId" AS agent_id,
slv.version AS agent_version,
sl.slug,
COALESCE(slv.name, '') AS name,
slv."subHeading" AS sub_heading,
slv.description,
slv."imageUrls" AS image_urls,
slv."submittedAt" AS date_submitted,
slv."submissionStatus" AS status,
COALESCE(ar.run_count, 0::bigint) AS runs,
COALESCE(avg(sr.score::numeric), 0.0)::double precision AS rating,
-- Add the additional fields needed by the Pydantic model
slv.id AS store_listing_version_id,
slv."reviewerId" AS reviewer_id,
slv."reviewComments" AS review_comments,
slv."internalComments" AS internal_comments,
slv."reviewedAt" AS reviewed_at,
slv."changesSummary" AS changes_summary
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "AgentGraphExecution"."agentGraphId", count(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "AgentGraphExecution"."agentGraphId"
) ar ON ar."agentGraphId" = slv."agentId"
WHERE sl."isDeleted" = false
GROUP BY sl.id, sl."owningUserId", slv.id, slv."agentId", slv.version, sl.slug, slv.name,
slv."subHeading", slv.description, slv."imageUrls", slv."submittedAt",
slv."submissionStatus", slv."reviewerId", slv."reviewComments", slv."internalComments",
slv."reviewedAt", slv."changesSummary", ar.run_count;
-- 2. Recreate StoreAgent view
CREATE VIEW "StoreAgent" AS
WITH reviewstats AS (
SELECT sl_1.id AS "storeListingId",
count(sr.id) AS review_count,
avg(sr.score::numeric) AS avg_rating
FROM "StoreListing" sl_1
JOIN "StoreListingVersion" slv_1
ON slv_1."storeListingId" = sl_1.id
JOIN "StoreListingReview" sr
ON sr."storeListingVersionId" = slv_1.id
WHERE sl_1."isDeleted" = false
GROUP BY sl_1.id
), agentruns AS (
SELECT "AgentGraphExecution"."agentGraphId",
count(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "AgentGraphExecution"."agentGraphId"
)
SELECT sl.id AS listing_id,
slv.id AS "storeListingVersionId",
slv."createdAt" AS updated_at,
sl.slug,
COALESCE(slv.name, '') AS agent_name,
slv."videoUrl" AS agent_video,
COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image,
slv."isFeatured" AS featured,
p.username AS creator_username,
p."avatarUrl" AS creator_avatar,
slv."subHeading" AS sub_heading,
slv.description,
slv.categories,
COALESCE(ar.run_count, 0::bigint) AS runs,
COALESCE(rs.avg_rating, 0.0)::double precision AS rating,
array_agg(DISTINCT slv.version::text) AS versions
FROM "StoreListing" sl
JOIN "AgentGraph" a
ON sl."agentId" = a.id
AND sl."agentVersion" = a.version
LEFT JOIN "Profile" p
ON sl."owningUserId" = p."userId"
LEFT JOIN "StoreListingVersion" slv
ON slv."storeListingId" = sl.id
LEFT JOIN reviewstats rs
ON sl.id = rs."storeListingId"
LEFT JOIN agentruns ar
ON a.id = ar."agentGraphId"
WHERE sl."isDeleted" = false
AND sl."hasApprovedVersion" = true
AND slv."submissionStatus" = 'APPROVED'
GROUP BY sl.id, slv.id, sl.slug, slv."createdAt", slv.name, slv."videoUrl",
slv."imageUrls", slv."isFeatured", p.username, p."avatarUrl",
slv."subHeading", slv.description, slv.categories, ar.run_count,
rs.avg_rating;
-- 3. Recreate Creator view
CREATE VIEW "Creator" AS
WITH agentstats AS (
SELECT p_1.username,
count(DISTINCT sl.id) AS num_agents,
avg(COALESCE(sr.score, 0)::numeric) AS agent_rating,
sum(COALESCE(age.run_count, 0::bigint)) AS agent_runs
FROM "Profile" p_1
LEFT JOIN "StoreListing" sl
ON sl."owningUserId" = p_1."userId"
LEFT JOIN "StoreListingVersion" slv
ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr
ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "AgentGraphExecution"."agentGraphId",
count(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "AgentGraphExecution"."agentGraphId"
) age ON age."agentGraphId" = sl."agentId"
WHERE sl."isDeleted" = false
AND sl."hasApprovedVersion" = true
AND slv."submissionStatus" = 'APPROVED'
GROUP BY p_1.username
)
SELECT p.username,
p.name,
p."avatarUrl" AS avatar_url,
p.description,
array_agg(DISTINCT cats.c) FILTER (WHERE cats.c IS NOT NULL) AS top_categories,
p.links,
p."isFeatured" AS is_featured,
COALESCE(ast.num_agents, 0::bigint) AS num_agents,
COALESCE(ast.agent_rating, 0.0) AS agent_rating,
COALESCE(ast.agent_runs, 0::numeric) AS agent_runs
FROM "Profile" p
LEFT JOIN agentstats ast
ON ast.username = p.username
LEFT JOIN LATERAL (
SELECT unnest(slv.categories) AS c
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv
ON slv."storeListingId" = sl.id
WHERE sl."owningUserId" = p."userId"
AND sl."isDeleted" = false
AND sl."hasApprovedVersion" = true
AND slv."submissionStatus" = 'APPROVED'
) cats ON true
GROUP BY p.username, p.name, p."avatarUrl", p.description, p.links,
p."isFeatured", ast.num_agents, ast.agent_rating, ast.agent_runs;
COMMIT;

View File

@@ -44,14 +44,14 @@ model User {
AgentPreset AgentPreset[]
LibraryAgent LibraryAgent[]
Profile Profile[]
UserOnboarding UserOnboarding?
StoreListing StoreListing[]
StoreListingReview StoreListingReview[]
StoreListingSubmission StoreListingSubmission[]
APIKeys APIKey[]
IntegrationWebhooks IntegrationWebhook[]
UserNotificationBatch UserNotificationBatch[]
Profile Profile[]
UserOnboarding UserOnboarding?
StoreListing StoreListing[]
StoreListingReview StoreListingReview[]
StoreVersionsReviewed StoreListingVersion[]
APIKeys APIKey[]
IntegrationWebhooks IntegrationWebhook[]
UserNotificationBatch UserNotificationBatch[]
@@index([id])
@@index([email])
@@ -71,7 +71,7 @@ model UserOnboarding {
isCompleted Boolean @default(false)
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
@@ -86,13 +86,13 @@ model AgentGraph {
name String?
description String?
isActive Boolean @default(true)
isActive Boolean @default(true)
// Link to User model
userId String
// FIX: Do not cascade delete the agent when the user is deleted
// This allows us to delete user data with deleting the agent which maybe in use by other users
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
AgentNodes AgentNode[]
AgentGraphExecution AgentGraphExecution[]
@@ -100,7 +100,7 @@ model AgentGraph {
AgentPreset AgentPreset[]
LibraryAgent LibraryAgent[]
StoreListing StoreListing[]
StoreListingVersion StoreListingVersion?
StoreListingVersion StoreListingVersion[]
@@id(name: "graphVersionId", [id, version])
@@index([userId, isActive])
@@ -175,11 +175,11 @@ model UserNotificationBatch {
updatedAt DateTime @default(now()) @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
type NotificationType
notifications NotificationEvent[]
Notifications NotificationEvent[]
// Each user can only have one batch of a notification type at a time
@@unique([userId, type])
@@ -195,7 +195,7 @@ model LibraryAgent {
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
imageUrl String?
imageUrl String?
agentId String
agentVersion Int
@@ -319,7 +319,7 @@ model AgentGraphExecution {
// Link to User model -- Executed by this user
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
stats Json?
AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id])
@@ -384,7 +384,7 @@ model IntegrationWebhook {
updatedAt DateTime? @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Restrict) // Webhooks must be deregistered before deleting
User User @relation(fields: [userId], references: [id], onDelete: Restrict) // Webhooks must be deregistered before deleting
provider String // e.g. 'github'
credentialsId String // relation to the credentials that the webhook was created with
@@ -411,7 +411,7 @@ model AnalyticsDetails {
// Link to User model
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Analytics Categorical data used for filtering (indexable w and w/o userId)
type String
@@ -446,7 +446,7 @@ model AnalyticsMetrics {
// Link to User model
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
@@ -470,7 +470,7 @@ model CreditTransaction {
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
amount Int
type CreditTransactionType
@@ -579,19 +579,25 @@ view StoreAgent {
}
view StoreSubmission {
listing_id String @id
user_id String
slug String
name String
sub_heading String
description String
image_urls String[]
date_submitted DateTime
status SubmissionStatus
runs Int
rating Float
agent_id String
agent_version Int
listing_id String @id
user_id String
slug String
name String
sub_heading String
description String
image_urls String[]
date_submitted DateTime
status SubmissionStatus
runs Int
rating Float
agent_id String
agent_version Int
store_listing_version_id String
reviewer_id String?
review_comments String?
internal_comments String?
reviewed_at DateTime?
changes_summary String?
// Index or unique are not applied to views
}
@@ -601,11 +607,18 @@ model StoreListing {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
isDeleted Boolean @default(false)
// Not needed but makes lookups faster
isApproved Boolean @default(false)
isDeleted Boolean @default(false)
// Whether any version has been approved and is available for display
hasApprovedVersion Boolean @default(false)
// The agent link here is only so we can do lookup on agentId, for the listing the StoreListingVersion is used.
// URL-friendly identifier for this agent (moved from StoreListingVersion)
slug String
// The currently active version that should be shown to users
activeVersionId String? @unique
ActiveVersion StoreListingVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id])
// The agent link here is only so we can do lookup on agentId
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade)
@@ -613,14 +626,14 @@ model StoreListing {
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
StoreListingVersions StoreListingVersion[]
StoreListingSubmission StoreListingSubmission[]
// Relations
Versions StoreListingVersion[] @relation("ListingVersions")
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
@@unique([agentId])
@@index([owningUserId])
@@unique([owningUserId, slug])
// Used in the view query
@@index([isDeleted, isApproved])
@@index([isDeleted, hasApprovedVersion])
}
model StoreListingVersion {
@@ -634,10 +647,7 @@ model StoreListingVersion {
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version])
// The details for this version of the agent, this allows the author to update the details of the agent,
// But still allow using old versions of the agent with there original details.
// TODO: Create a database view that shows only the latest version of each store listing.
slug String
// Content fields
name String
subHeading String
videoUrl String?
@@ -647,20 +657,39 @@ model StoreListingVersion {
isFeatured Boolean @default(false)
isDeleted Boolean @default(false)
isDeleted Boolean @default(false)
// Old versions can be made unavailable by the author if desired
isAvailable Boolean @default(true)
// Not needed but makes lookups faster
isApproved Boolean @default(false)
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: Cascade)
storeListingId String?
StoreListingSubmission StoreListingSubmission[]
isAvailable Boolean @default(true)
// Reviews are on a specific version, but then aggregated up to the listing.
// This allows us to provide a review filter to current version of the agent.
StoreListingReview StoreListingReview[]
// Version workflow state
submissionStatus SubmissionStatus @default(DRAFT)
submittedAt DateTime?
@@unique([agentId, agentVersion])
// Relations
storeListingId String
StoreListing StoreListing @relation("ListingVersions", fields: [storeListingId], references: [id], onDelete: Cascade)
// This version might be the active version for a listing
ActiveFor StoreListing? @relation("ActiveVersion")
// Submission history
changesSummary String?
// Review information
reviewerId String?
Reviewer User? @relation(fields: [reviewerId], references: [id])
internalComments String? // Private notes for admin use only
reviewComments String? // Comments visible to creator
reviewedAt DateTime?
// Reviews for this specific version
Reviews StoreListingReview[]
@@unique([storeListingId, version])
@@index([storeListingId, submissionStatus, isAvailable])
@@index([submissionStatus])
@@index([reviewerId])
@@index([agentId, agentVersion]) // Non-unique index for efficient lookups
}
model StoreListingReview {
@@ -681,31 +710,10 @@ model StoreListingReview {
}
enum SubmissionStatus {
DAFT
PENDING
APPROVED
REJECTED
}
model StoreListingSubmission {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
storeListingId String
StoreListing StoreListing @relation(fields: [storeListingId], references: [id], onDelete: Cascade)
storeListingVersionId String
StoreListingVersion StoreListingVersion @relation(fields: [storeListingVersionId], references: [id], onDelete: Cascade)
reviewerId String
Reviewer User @relation(fields: [reviewerId], references: [id])
Status SubmissionStatus @default(PENDING)
reviewComments String?
@@unique([storeListingVersionId])
@@index([storeListingId])
DRAFT // Being prepared, not yet submitted
PENDING // Submitted, awaiting review
APPROVED // Reviewed and approved
REJECTED // Reviewed and rejected
}
enum APIKeyPermission {
@@ -732,7 +740,7 @@ model APIKey {
// Relation to user
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([key])
@@index([prefix])

View File

@@ -5,9 +5,11 @@ from prisma.enums import CreditTransactionType
from prisma.models import CreditTransaction
from backend.blocks.llm import AITextGeneratorBlock
from backend.data.block import get_block
from backend.data.credit import BetaUserCredit
from backend.data.execution import NodeExecutionEntry
from backend.data.user import DEFAULT_USER_ID
from backend.executor.utils import UsageTransactionMetadata, block_usage_cost
from backend.integrations.credentials_store import openai_credentials
from backend.util.test import SpinTestServer
@@ -27,13 +29,36 @@ async def top_up(amount: int):
)
async def spend_credits(entry: NodeExecutionEntry) -> int:
block = get_block(entry.block_id)
if not block:
raise RuntimeError(f"Block {entry.block_id} not found")
cost, matching_filter = block_usage_cost(block=block, input_data=entry.data)
await user_credit.spend_credits(
entry.user_id,
cost,
UsageTransactionMetadata(
graph_exec_id=entry.graph_exec_id,
graph_id=entry.graph_id,
node_id=entry.node_id,
node_exec_id=entry.node_exec_id,
block_id=entry.block_id,
block=entry.block_id,
input=matching_filter,
),
)
return cost
@pytest.mark.asyncio(scope="session")
async def test_block_credit_usage(server: SpinTestServer):
await disable_test_user_transactions()
await top_up(100)
current_credit = await user_credit.get_credits(DEFAULT_USER_ID)
spending_amount_1 = await user_credit.spend_credits(
spending_amount_1 = await spend_credits(
NodeExecutionEntry(
user_id=DEFAULT_USER_ID,
graph_id="test_graph",
@@ -50,12 +75,10 @@ async def test_block_credit_usage(server: SpinTestServer):
},
},
),
0.0,
0.0,
)
assert spending_amount_1 > 0
spending_amount_2 = await user_credit.spend_credits(
spending_amount_2 = await spend_credits(
NodeExecutionEntry(
user_id=DEFAULT_USER_ID,
graph_id="test_graph",
@@ -65,8 +88,6 @@ async def test_block_credit_usage(server: SpinTestServer):
block_id=AITextGeneratorBlock().id,
data={"model": "gpt-4-turbo", "api_key": "owned_api_key"},
),
0.0,
0.0,
)
assert spending_amount_2 == 0

View File

@@ -6,7 +6,8 @@ import fastapi.exceptions
import pytest
import backend.server.v2.store.model as store
from backend.blocks.basic import AgentInputBlock, AgentOutputBlock, StoreValueBlock
from backend.blocks.basic import StoreValueBlock
from backend.blocks.io import AgentInputBlock, AgentOutputBlock
from backend.data.block import BlockSchema
from backend.data.graph import Graph, Link, Node
from backend.data.model import SchemaField
@@ -242,7 +243,7 @@ async def test_access_store_listing_graph(server: SpinTestServer):
store_submission_request = store.StoreSubmissionRequest(
agent_id=created_graph.id,
agent_version=created_graph.version,
slug="test-slug",
slug=created_graph.id,
name="Test name",
sub_heading="Test sub heading",
video_url=None,

View File

@@ -7,7 +7,8 @@ from prisma.models import User
import backend.server.v2.library.model
import backend.server.v2.store.model
from backend.blocks.basic import AgentInputBlock, FindInDictionaryBlock, StoreValueBlock
from backend.blocks.basic import FindInDictionaryBlock, StoreValueBlock
from backend.blocks.io import AgentInputBlock
from backend.blocks.maths import CalculatorBlock, Operation
from backend.data import execution, graph
from backend.server.model import CreateGraph
@@ -123,8 +124,8 @@ async def assert_sample_graph_executions(
logger.info(f"Checking PrintToConsoleBlock execution: {exec}")
assert exec.status == execution.ExecutionStatus.COMPLETED
assert exec.graph_exec_id == graph_exec_id
assert exec.output_data == {"status": ["printed"]}
assert exec.input_data == {"text": "Hello, World!!!"}
assert exec.output_data == {"output": ["Hello, World!!!"]}
assert exec.input_data == {"input": "Hello, World!!!"}
assert exec.node_id == test_graph.nodes[3].id
@@ -492,7 +493,7 @@ async def test_store_listing_graph(server: SpinTestServer):
store_submission_request = backend.server.v2.store.model.StoreSubmissionRequest(
agent_id=test_graph.id,
agent_version=test_graph.version,
slug="test-slug",
slug=test_graph.id,
name="Test name",
sub_heading="Test sub heading",
video_url=None,

View File

@@ -328,12 +328,14 @@ async def main():
print(f"Inserting {NUM_USERS} store listings")
for graph in agent_graphs:
user = random.choice(users)
slug = faker.slug()
listing = await db.storelisting.create(
data={
"agentId": graph.id,
"agentVersion": graph.version,
"owningUserId": user.id,
"isApproved": random.choice([True, False]),
"hasApprovedVersion": random.choice([True, False]),
"slug": slug,
}
)
store_listings.append(listing)
@@ -347,7 +349,6 @@ async def main():
data={
"agentId": graph.id,
"agentVersion": graph.version,
"slug": faker.slug(),
"name": graph.name or faker.sentence(nb_words=3),
"subHeading": faker.sentence(),
"videoUrl": faker.url(),
@@ -356,8 +357,14 @@ async def main():
"categories": [faker.word() for _ in range(3)],
"isFeatured": random.choice([True, False]),
"isAvailable": True,
"isApproved": random.choice([True, False]),
"storeListingId": listing.id,
"submissionStatus": random.choice(
[
prisma.enums.SubmissionStatus.PENDING,
prisma.enums.SubmissionStatus.APPROVED,
prisma.enums.SubmissionStatus.REJECTED,
]
),
}
)
store_listing_versions.append(version)
@@ -386,10 +393,9 @@ async def main():
}
)
# Insert StoreListingSubmissions
print(f"Inserting {NUM_USERS} store listing submissions")
for listing in store_listings:
version = random.choice(store_listing_versions)
# Update StoreListingVersions with submission status (StoreListingSubmissions table no longer exists)
print(f"Updating {NUM_USERS} store listing versions with submission status")
for version in store_listing_versions:
reviewer = random.choice(users)
status: prisma.enums.SubmissionStatus = random.choice(
[
@@ -398,14 +404,14 @@ async def main():
prisma.enums.SubmissionStatus.REJECTED,
]
)
await db.storelistingsubmission.create(
await db.storelistingversion.update(
where={"id": version.id},
data={
"storeListingId": listing.id,
"storeListingVersionId": version.id,
"reviewerId": reviewer.id,
"Status": status,
"submissionStatus": status,
"Reviewer": {"connect": {"id": reviewer.id}},
"reviewComments": faker.text(),
}
"reviewedAt": datetime.now(),
},
)
# Insert APIKeys

View File

@@ -66,7 +66,7 @@
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.479.0",
"moment": "^2.30.1",
"next": "^14.2.21",
"next": "^14.2.25",
"next-themes": "^0.4.5",
"react": "^18",
"react-day-picker": "^9.6.1",

View File

@@ -8,8 +8,8 @@ const sidebarLinkGroups = [
{
links: [
{
text: "Agent Management",
href: "/admin/agents",
text: "Marketplace Management",
href: "/admin/marketplace",
icon: <Users className="h-6 w-6" />,
},
{
@@ -32,7 +32,7 @@ export default function AdminLayout({
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen w-screen max-w-[1360px] flex-col lg:flex-row">
<div className="flex min-h-screen w-screen flex-col lg:flex-row">
<Sidebar linkGroups={sidebarLinkGroups} />
<div className="flex-1 pl-4">{children}</div>
</div>

View File

@@ -0,0 +1,58 @@
"use server";
import { revalidatePath } from "next/cache";
import BackendApi from "@/lib/autogpt-server-api";
import {
NotificationPreferenceDTO,
StoreListingsWithVersionsResponse,
StoreSubmissionsResponse,
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
export async function approveAgent(formData: FormData) {
const data = {
store_listing_version_id: formData.get("id") as string,
is_approved: true,
comments: formData.get("comments") as string,
};
const api = new BackendApi();
await api.reviewSubmissionAdmin(data.store_listing_version_id, data);
revalidatePath("/admin/marketplace");
}
export async function rejectAgent(formData: FormData) {
const data = {
store_listing_version_id: formData.get("id") as string,
is_approved: false,
comments: formData.get("comments") as string,
internal_comments: formData.get("internal_comments") as string,
};
const api = new BackendApi();
await api.reviewSubmissionAdmin(data.store_listing_version_id, data);
revalidatePath("/admin/marketplace");
}
export async function getAdminListingsWithVersions(
status?: SubmissionStatus,
search?: string,
page: number = 1,
pageSize: number = 20,
): Promise<StoreListingsWithVersionsResponse> {
const data: Record<string, any> = {
page,
page_size: pageSize,
};
if (status) {
data.status = status;
}
if (search) {
data.search = search;
}
const api = new BackendApi();
const response = await api.getAdminListingsWithVersions(data);
return response;
}

View File

@@ -1,25 +1,62 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
import type { SubmissionStatus } from "@/lib/autogpt-server-api/types";
import { AdminAgentsDataTable } from "@/components/admin/marketplace/admin-agents-data-table";
import React from "react";
// import { getReviewableAgents } from "@/components/admin/marketplace/actions";
// import AdminMarketplaceAgentList from "@/components/admin/marketplace/AdminMarketplaceAgentList";
// import AdminFeaturedAgentsControl from "@/components/admin/marketplace/AdminFeaturedAgentsControl";
import { Separator } from "@/components/ui/separator";
async function AdminMarketplace() {
// const reviewableAgents = await getReviewableAgents();
async function AdminMarketplaceDashboard({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
const status = searchParams.status as SubmissionStatus | undefined;
const search = searchParams.search;
return (
<>
{/* <AdminMarketplaceAgentList agents={reviewableAgents.items} />
<Separator className="my-4" />
<AdminFeaturedAgentsControl className="mt-4" /> */}
</>
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Marketplace Management</h1>
<p className="text-gray-500">
Unified view for marketplace management and approval history
</p>
</div>
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading submissions...</div>
}
>
<AdminAgentsDataTable
initialPage={page}
initialStatus={status}
initialSearch={search}
/>
</Suspense>
</div>
</div>
);
}
export default async function AdminDashboardPage() {
export default async function AdminMarketplacePage({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedAdminMarketplace = await withAdminAccess(AdminMarketplace);
return <ProtectedAdminMarketplace />;
const ProtectedAdminMarketplace = await withAdminAccess(
AdminMarketplaceDashboard,
);
return <ProtectedAdminMarketplace searchParams={searchParams} />;
}

View File

@@ -461,6 +461,37 @@ const FlowEditor: React.FC<{
});
}, [nodes, setViewport, x, y]);
const fillDefaults = useCallback((obj: any, schema: any) => {
// Iterate over the schema properties
for (const key in schema.properties) {
if (schema.properties.hasOwnProperty(key)) {
const propertySchema = schema.properties[key];
// If the property is not in the object, initialize it with the default value
if (!obj.hasOwnProperty(key)) {
if (propertySchema.default !== undefined) {
obj[key] = propertySchema.default;
} else if (propertySchema.type === "object") {
// Recursively fill defaults for nested objects
obj[key] = fillDefaults({}, propertySchema);
} else if (propertySchema.type === "array") {
// Recursively fill defaults for arrays
obj[key] = fillDefaults([], propertySchema);
}
} else {
// If the property exists, recursively fill defaults for nested objects/arrays
if (propertySchema.type === "object") {
obj[key] = fillDefaults(obj[key], propertySchema);
} else if (propertySchema.type === "array") {
obj[key] = fillDefaults(obj[key], propertySchema);
}
}
}
}
return obj;
}, []);
const addNode = useCallback(
(blockId: string, nodeType: string, hardcodedValues: any = {}) => {
const nodeSchema = availableNodes.find((node) => node.id === blockId);
@@ -507,7 +538,10 @@ const FlowEditor: React.FC<{
categories: nodeSchema.categories,
inputSchema: nodeSchema.inputSchema,
outputSchema: nodeSchema.outputSchema,
hardcodedValues: hardcodedValues,
hardcodedValues: {
...fillDefaults({}, nodeSchema.inputSchema),
...hardcodedValues,
},
connections: [],
isOutputOpen: false,
block_id: blockId,

View File

@@ -1,149 +0,0 @@
// "use client";
// import {
// Dialog,
// DialogContent,
// DialogClose,
// DialogFooter,
// DialogHeader,
// DialogTitle,
// DialogTrigger,
// } from "@/components/ui/dialog";
// import { Button } from "@/components/ui/button";
// import {
// MultiSelector,
// MultiSelectorContent,
// MultiSelectorInput,
// MultiSelectorItem,
// MultiSelectorList,
// MultiSelectorTrigger,
// } from "@/components/ui/multiselect";
// import { Controller, useForm } from "react-hook-form";
// import {
// Select,
// SelectContent,
// SelectItem,
// SelectTrigger,
// SelectValue,
// } from "@/components/ui/select";
// import { useState } from "react";
// import { addFeaturedAgent } from "./actions";
// import { Agent } from "@/lib/marketplace-api/types";
// type FormData = {
// agent: string;
// categories: string[];
// };
// export const AdminAddFeaturedAgentDialog = ({
// categories,
// agents,
// }: {
// categories: string[];
// agents: Agent[];
// }) => {
// const [selectedAgent, setSelectedAgent] = useState<string>("");
// const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// const {
// control,
// handleSubmit,
// watch,
// setValue,
// formState: { errors },
// } = useForm<FormData>({
// defaultValues: {
// agent: "",
// categories: [],
// },
// });
// return (
// <Dialog>
// <DialogTrigger asChild>
// <Button variant="outline" size="sm">
// Add Featured Agent
// </Button>
// </DialogTrigger>
// <DialogContent>
// <DialogHeader>
// <DialogTitle>Add Featured Agent</DialogTitle>
// </DialogHeader>
// <div className="flex flex-col gap-4">
// <Controller
// name="agent"
// control={control}
// rules={{ required: true }}
// render={({ field }) => (
// <div>
// <label htmlFor={field.name}>Agent</label>
// <Select
// onValueChange={(value) => {
// field.onChange(value);
// setSelectedAgent(value);
// }}
// value={field.value || ""}
// >
// <SelectTrigger>
// <SelectValue placeholder="Select an agent" />
// </SelectTrigger>
// <SelectContent>
// {/* Populate with agents */}
// {agents.map((agent) => (
// <SelectItem key={agent.id} value={agent.id}>
// {agent.name}
// </SelectItem>
// ))}
// </SelectContent>
// </Select>
// </div>
// )}
// />
// <Controller
// name="categories"
// control={control}
// render={({ field }) => (
// <MultiSelector
// values={field.value || []}
// onValuesChange={(values) => {
// field.onChange(values);
// setSelectedCategories(values);
// }}
// >
// <MultiSelectorTrigger>
// <MultiSelectorInput placeholder="Select categories" />
// </MultiSelectorTrigger>
// <MultiSelectorContent>
// <MultiSelectorList>
// {categories.map((category) => (
// <MultiSelectorItem key={category} value={category}>
// {category}
// </MultiSelectorItem>
// ))}
// </MultiSelectorList>
// </MultiSelectorContent>
// </MultiSelector>
// )}
// />
// </div>
// <DialogFooter>
// <DialogClose asChild>
// <Button variant="outline">Cancel</Button>
// </DialogClose>
// <DialogClose asChild>
// <Button
// type="submit"
// onClick={async () => {
// // Handle adding the featured agent
// await addFeaturedAgent(selectedAgent, selectedCategories);
// // close the dialog
// }}
// >
// Add
// </Button>
// </DialogClose>
// </DialogFooter>
// </DialogContent>
// </Dialog>
// );
// };

View File

@@ -1,74 +0,0 @@
// import { Button } from "@/components/ui/button";
// import {
// getFeaturedAgents,
// removeFeaturedAgent,
// getCategories,
// getNotFeaturedAgents,
// } from "./actions";
// import FeaturedAgentsTable from "./FeaturedAgentsTable";
// import { AdminAddFeaturedAgentDialog } from "./AdminAddFeaturedAgentDialog";
// import { revalidatePath } from "next/cache";
// import * as Sentry from "@sentry/nextjs";
// export default async function AdminFeaturedAgentsControl({
// className,
// }: {
// className?: string;
// }) {
// // add featured agent button
// // modal to select agent?
// // modal to select categories?
// // table of featured agents
// // in table
// // remove featured agent button
// // edit featured agent categories button
// // table footer
// // Next page button
// // Previous page button
// // Page number input
// // Page size input
// // Total pages input
// // Go to page button
// const page = 1;
// const pageSize = 10;
// const agents = await getFeaturedAgents(page, pageSize);
// const categories = await getCategories();
// const notFeaturedAgents = await getNotFeaturedAgents();
// return (
// <div className={`flex flex-col gap-4 ${className}`}>
// <div className="mb-4 flex justify-between">
// <h3 className="text-lg font-semibold">Featured Agent Controls</h3>
// <AdminAddFeaturedAgentDialog
// categories={categories.unique_categories}
// agents={notFeaturedAgents.items}
// />
// </div>
// <FeaturedAgentsTable
// agents={agents.items}
// globalActions={[
// {
// component: <Button>Remove</Button>,
// action: async (rows) => {
// "use server";
// return await Sentry.withServerActionInstrumentation(
// "removeFeaturedAgent",
// {},
// async () => {
// const all = rows.map((row) => removeFeaturedAgent(row.id));
// await Promise.all(all);
// revalidatePath("/marketplace");
// },
// );
// },
// },
// ]}
// />
// </div>
// );
// }

View File

@@ -1,36 +0,0 @@
// import { Agent } from "@/lib/marketplace-api";
// import AdminMarketplaceCard from "./AdminMarketplaceCard";
// import { ClipboardX } from "lucide-react";
// export default function AdminMarketplaceAgentList({
// agents,
// className,
// }: {
// agents: Agent[];
// className?: string;
// }) {
// if (agents.length === 0) {
// return (
// <div className={className}>
// <h3 className="text-lg font-semibold">Agents to review</h3>
// <div className="flex flex-col items-center justify-center py-12 text-gray-500">
// <ClipboardX size={48} />
// <p className="mt-4 text-lg font-semibold">No agents to review</p>
// </div>
// </div>
// );
// }
// return (
// <div className={`flex flex-col gap-4 ${className}`}>
// <div>
// <h3 className="text-lg font-semibold">Agents to review</h3>
// </div>
// <div className="flex flex-col gap-4">
// {agents.map((agent) => (
// <AdminMarketplaceCard agent={agent} key={agent.id} />
// ))}
// </div>
// </div>
// );
// }

View File

@@ -1,113 +0,0 @@
// "use client";
// import { Card } from "@/components/ui/card";
// import { Button } from "@/components/ui/button";
// import { Badge } from "@/components/ui/badge";
// import { ScrollArea } from "@/components/ui/scroll-area";
// import { approveAgent, rejectAgent } from "./actions";
// import { Agent } from "@/lib/marketplace-api";
// import Link from "next/link";
// import { useState } from "react";
// import { Input } from "@/components/ui/input";
// function AdminMarketplaceCard({ agent }: { agent: Agent }) {
// const [isApproved, setIsApproved] = useState(false);
// const [isRejected, setIsRejected] = useState(false);
// const [comment, setComment] = useState("");
// const approveAgentWithId = approveAgent.bind(
// null,
// agent.id,
// agent.version,
// comment,
// );
// const rejectAgentWithId = rejectAgent.bind(
// null,
// agent.id,
// agent.version,
// comment,
// );
// const handleApprove = async (e: React.FormEvent) => {
// e.preventDefault();
// await approveAgentWithId();
// setIsApproved(true);
// };
// const handleReject = async (e: React.FormEvent) => {
// e.preventDefault();
// await rejectAgentWithId();
// setIsRejected(true);
// };
// return (
// <>
// {!isApproved && !isRejected && (
// <Card key={agent.id} className="m-3 flex h-[300px] flex-col p-4">
// <div className="mb-2 flex items-start justify-between">
// <Link
// href={`/marketplace/${agent.id}`}
// className="text-lg font-semibold hover:underline"
// >
// {agent.name}
// </Link>
// <Badge variant="outline">v{agent.version}</Badge>
// </div>
// <p className="mb-2 text-sm text-gray-500">by {agent.author}</p>
// <ScrollArea className="flex-grow">
// <p className="mb-2 text-sm text-gray-600">{agent.description}</p>
// <div className="mb-2 flex flex-wrap gap-1">
// {agent.categories.map((category) => (
// <Badge key={category} variant="secondary">
// {category}
// </Badge>
// ))}
// </div>
// <div className="flex flex-wrap gap-1">
// {agent.keywords.map((keyword) => (
// <Badge key={keyword} variant="outline">
// {keyword}
// </Badge>
// ))}
// </div>
// </ScrollArea>
// <div className="mb-2 flex justify-between text-xs text-gray-500">
// <span>
// Created: {new Date(agent.createdAt).toLocaleDateString()}
// </span>
// <span>
// Updated: {new Date(agent.updatedAt).toLocaleDateString()}
// </span>
// </div>
// <div className="mb-4 flex justify-between text-sm">
// <span>👁 {agent.views}</span>
// <span>⬇️ {agent.downloads}</span>
// </div>
// <div className="mt-auto space-y-2">
// <div className="flex justify-end space-x-2">
// <Input
// type="text"
// placeholder="Add a comment (optional)"
// value={comment}
// onChange={(e) => setComment(e.target.value)}
// />
// {!isRejected && (
// <form onSubmit={handleReject}>
// <Button variant="outline" type="submit">
// Reject
// </Button>
// </form>
// )}
// {!isApproved && (
// <form onSubmit={handleApprove}>
// <Button type="submit">Approve</Button>
// </form>
// )}
// </div>
// </div>
// </Card>
// )}
// </>
// );
// }
// export default AdminMarketplaceCard;

View File

@@ -1,114 +0,0 @@
// "use client";
// import { Button } from "@/components/ui/button";
// import { Checkbox } from "@/components/ui/checkbox";
// import { DataTable } from "@/components/ui/data-table";
// import { Agent } from "@/lib/marketplace-api";
// import { ColumnDef } from "@tanstack/react-table";
// import { ArrowUpDown } from "lucide-react";
// import { removeFeaturedAgent } from "./actions";
// import { GlobalActions } from "@/components/ui/data-table";
// export const columns: ColumnDef<Agent>[] = [
// {
// id: "select",
// header: ({ table }) => (
// <Checkbox
// checked={
// table.getIsAllPageRowsSelected() ||
// (table.getIsSomePageRowsSelected() && "indeterminate")
// }
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
// aria-label="Select all"
// />
// ),
// cell: ({ row }) => (
// <Checkbox
// checked={row.getIsSelected()}
// onCheckedChange={(value) => row.toggleSelected(!!value)}
// aria-label="Select row"
// />
// ),
// },
// {
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
// >
// Name
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// accessorKey: "name",
// },
// {
// header: "Description",
// accessorKey: "description",
// },
// {
// header: "Categories",
// accessorKey: "categories",
// },
// {
// header: "Keywords",
// accessorKey: "keywords",
// },
// {
// header: "Downloads",
// accessorKey: "downloads",
// },
// {
// header: "Author",
// accessorKey: "author",
// },
// {
// header: "Version",
// accessorKey: "version",
// },
// {
// header: "actions",
// cell: ({ row }) => {
// const handleRemove = async () => {
// await removeFeaturedAgentWithId();
// };
// // const handleEdit = async () => {
// // console.log("edit");
// // };
// const removeFeaturedAgentWithId = removeFeaturedAgent.bind(
// null,
// row.original.id,
// );
// return (
// <div className="flex justify-end gap-2">
// <Button variant="outline" size="sm" onClick={handleRemove}>
// Remove
// </Button>
// {/* <Button variant="outline" size="sm" onClick={handleEdit}>
// Edit
// </Button> */}
// </div>
// );
// },
// },
// ];
// export default function FeaturedAgentsTable({
// agents,
// globalActions,
// }: {
// agents: Agent[];
// globalActions: GlobalActions<Agent>[];
// }) {
// return (
// <DataTable
// columns={columns}
// data={agents}
// filterPlaceholder="Search agents..."
// filterColumn="name"
// globalActions={globalActions}
// />
// );
// }

View File

@@ -1,165 +0,0 @@
// "use server";
// import MarketplaceAPI from "@/lib/marketplace-api";
// import ServerSideMarketplaceAPI from "@/lib/marketplace-api/server-client";
// import { revalidatePath } from "next/cache";
// import * as Sentry from "@sentry/nextjs";
// import { redirect } from "next/navigation";
// export async function checkAuth() {
// const supabase = getServerSupabase();
// if (!supabase) {
// console.error("No supabase client");
// redirect("/login");
// }
// const { data, error } = await supabase.auth.getUser();
// if (error || !data?.user) {
// redirect("/login");
// }
// }
// export async function approveAgent(
// agentId: string,
// version: number,
// comment: string,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "approveAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.approveAgentSubmission(agentId, version, comment);
// console.debug(`Approving agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function rejectAgent(
// agentId: string,
// version: number,
// comment: string,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "rejectAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.rejectAgentSubmission(agentId, version, comment);
// console.debug(`Rejecting agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function getReviewableAgents() {
// return await Sentry.withServerActionInstrumentation(
// "getReviewableAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// return api.getAgentSubmissions();
// },
// );
// }
// export async function getFeaturedAgents(
// page: number = 1,
// pageSize: number = 10,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "getFeaturedAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const featured = await api.getFeaturedAgents(page, pageSize);
// console.debug(`Getting featured agents ${featured.items.length}`);
// return featured;
// },
// );
// }
// export async function getFeaturedAgent(agentId: string) {
// return await Sentry.withServerActionInstrumentation(
// "getFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const featured = await api.getFeaturedAgent(agentId);
// console.debug(`Getting featured agent ${featured.agentId}`);
// return featured;
// },
// );
// }
// export async function addFeaturedAgent(
// agentId: string,
// categories: string[] = ["featured"],
// ) {
// return await Sentry.withServerActionInstrumentation(
// "addFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.addFeaturedAgent(agentId, categories);
// console.debug(`Adding featured agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function removeFeaturedAgent(
// agentId: string,
// categories: string[] = ["featured"],
// ) {
// return await Sentry.withServerActionInstrumentation(
// "removeFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.removeFeaturedAgent(agentId, categories);
// console.debug(`Removing featured agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
// export async function getCategories() {
// return await Sentry.withServerActionInstrumentation(
// "getCategories",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const categories = await api.getCategories();
// console.debug(
// `Getting categories ${categories.unique_categories.length}`,
// );
// return categories;
// },
// );
// }
// export async function getNotFeaturedAgents(
// page: number = 1,
// pageSize: number = 100,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "getNotFeaturedAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const agents = await api.getNotFeaturedAgents(page, pageSize);
// console.debug(`Getting not featured agents ${agents.items.length}`);
// return agents;
// },
// );
// }

View File

@@ -0,0 +1,99 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
StoreSubmission,
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
import { PaginationControls } from "../../ui/pagination-controls";
import { getAdminListingsWithVersions } from "@/app/admin/marketplace/actions";
import { ExpandableRow } from "./expandable-row";
import { SearchAndFilterAdminMarketplace } from "./search-filter-form";
// Helper function to get the latest version by version number
const getLatestVersionByNumber = (
versions: StoreSubmission[],
): StoreSubmission | null => {
if (!versions || versions.length === 0) return null;
return versions.reduce(
(latest, current) =>
(current.version ?? 0) > (latest.version ?? 1) ? current : latest,
versions[0],
);
};
export async function AdminAgentsDataTable({
initialPage = 1,
initialStatus,
initialSearch,
}: {
initialPage?: number;
initialStatus?: SubmissionStatus;
initialSearch?: string;
}) {
// Server-side data fetching
const { listings, pagination } = await getAdminListingsWithVersions(
initialStatus,
initialSearch,
initialPage,
10,
);
return (
<div className="space-y-4">
<SearchAndFilterAdminMarketplace
initialStatus={initialStatus}
initialSearch={initialSearch}
/>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Creator</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listings.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center">
No submissions found
</TableCell>
</TableRow>
) : (
listings.map((listing) => {
const latestVersion = getLatestVersionByNumber(
listing.versions,
);
return (
<ExpandableRow
key={listing.listing_id}
listing={listing}
latestVersion={latestVersion}
/>
);
})
)}
</TableBody>
</Table>
</div>
<PaginationControls
currentPage={initialPage}
totalPages={pagination.total_pages}
/>
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle, XCircle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { StoreSubmission } from "@/lib/autogpt-server-api/types";
import { useRouter } from "next/navigation";
import { approveAgent, rejectAgent } from "@/app/admin/marketplace/actions";
export function ApproveRejectButtons({
version,
}: {
version: StoreSubmission;
}) {
const router = useRouter();
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
const [isRejectDialogOpen, setIsRejectDialogOpen] = useState(false);
const handleApproveSubmit = async (formData: FormData) => {
setIsApproveDialogOpen(false);
try {
await approveAgent(formData);
router.refresh(); // Refresh the current route
} catch (error) {
console.error("Error approving agent:", error);
}
};
const handleRejectSubmit = async (formData: FormData) => {
setIsRejectDialogOpen(false);
try {
await rejectAgent(formData);
router.refresh(); // Refresh the current route
} catch (error) {
console.error("Error rejecting agent:", error);
}
};
return (
<>
<Button
size="sm"
variant="outline"
className="text-green-600 hover:bg-green-50 hover:text-green-700"
onClick={(e) => {
e.stopPropagation();
setIsApproveDialogOpen(true);
}}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
setIsRejectDialogOpen(true);
}}
>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
{/* Approve Dialog */}
<Dialog open={isApproveDialogOpen} onOpenChange={setIsApproveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve Agent</DialogTitle>
<DialogDescription>
Are you sure you want to approve this agent? This will make it
available in the marketplace.
</DialogDescription>
</DialogHeader>
<form action={handleApproveSubmit}>
<input
type="hidden"
name="id"
value={version.store_listing_version_id || ""}
/>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="comments">Comments (Optional)</Label>
<Textarea
id="comments"
name="comments"
placeholder="Add any comments for the agent creator"
defaultValue="Meets all requirements"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsApproveDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit">Approve</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Reject Dialog */}
<Dialog open={isRejectDialogOpen} onOpenChange={setIsRejectDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Agent</DialogTitle>
<DialogDescription>
Please provide feedback on why this agent is being rejected.
</DialogDescription>
</DialogHeader>
<form action={handleRejectSubmit}>
<input
type="hidden"
name="id"
value={version.store_listing_version_id || ""}
/>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="comments">Comments for Creator</Label>
<Textarea
id="comments"
name="comments"
placeholder="Provide feedback for the agent creator"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="internal_comments">Internal Comments</Label>
<Textarea
id="internal_comments"
name="internal_comments"
placeholder="Add any internal notes (not visible to creator)"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsRejectDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" variant="destructive">
Reject
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import {
TableRow,
TableCell,
Table,
TableHeader,
TableHead,
TableBody,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import {
type StoreListingWithVersions,
type StoreSubmission,
SubmissionStatus,
} from "@/lib/autogpt-server-api/types";
import { ApproveRejectButtons } from "./approve-reject-buttons";
// Moved the getStatusBadge function into the client component
const getStatusBadge = (status: SubmissionStatus) => {
switch (status) {
case SubmissionStatus.PENDING:
return <Badge className="bg-amber-500">Pending</Badge>;
case SubmissionStatus.APPROVED:
return <Badge className="bg-green-500">Approved</Badge>;
case SubmissionStatus.REJECTED:
return <Badge className="bg-red-500">Rejected</Badge>;
default:
return <Badge className="bg-gray-500">Draft</Badge>;
}
};
export function ExpandableRow({
listing,
latestVersion,
}: {
listing: StoreListingWithVersions;
latestVersion: StoreSubmission | null;
}) {
const [expanded, setExpanded] = useState(false);
return (
<>
<TableRow className="cursor-pointer hover:bg-muted/50">
<TableCell onClick={() => setExpanded(!expanded)}>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</TableCell>
<TableCell
className="font-medium"
onClick={() => setExpanded(!expanded)}
>
{latestVersion?.name || "Unnamed Agent"}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{listing.creator_email || "Unknown"}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{latestVersion?.sub_heading || "No description"}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{latestVersion?.status && getStatusBadge(latestVersion.status)}
</TableCell>
<TableCell onClick={() => setExpanded(!expanded)}>
{latestVersion?.date_submitted
? formatDistanceToNow(new Date(latestVersion.date_submitted), {
addSuffix: true,
})
: "Unknown"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline">
<ExternalLink className="mr-2 h-4 w-4" />
Builder
</Button>
{latestVersion?.status === SubmissionStatus.PENDING && (
<ApproveRejectButtons version={latestVersion} />
)}
</div>
</TableCell>
</TableRow>
{/* Expanded version history */}
{expanded && (
<TableRow>
<TableCell colSpan={7} className="border-t-0 p-0">
<div className="bg-muted/30 px-4 py-3">
<h4 className="mb-2 text-sm font-semibold">Version History</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
{/* <TableHead>Changes</TableHead> */}
<TableHead>Submitted</TableHead>
<TableHead>Reviewed</TableHead>
<TableHead>External Comments</TableHead>
<TableHead>Internal Comments</TableHead>
<TableHead>Name</TableHead>
<TableHead>Sub Heading</TableHead>
<TableHead>Description</TableHead>
{/* <TableHead>Categories</TableHead> */}
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listing.versions
.sort((a, b) => (b.version ?? 1) - (a.version ?? 0))
.map((version) => (
<TableRow key={version.store_listing_version_id}>
<TableCell>
v{version.version || "?"}
{version.store_listing_version_id ===
listing.active_version_id && (
<Badge className="ml-2 bg-blue-500">Active</Badge>
)}
</TableCell>
<TableCell>{getStatusBadge(version.status)}</TableCell>
{/* <TableCell>
{version.changes_summary || "No summary"}
</TableCell> */}
<TableCell>
{version.date_submitted
? formatDistanceToNow(
new Date(version.date_submitted),
{ addSuffix: true },
)
: "Unknown"}
</TableCell>
<TableCell>
{version.reviewed_at
? formatDistanceToNow(
new Date(version.reviewed_at),
{
addSuffix: true,
},
)
: "Not reviewed"}
</TableCell>
<TableCell className="max-w-xs truncate">
{version.review_comments ? (
<div
className="truncate"
title={version.review_comments}
>
{version.review_comments}
</div>
) : (
<span className="text-gray-400">
No external comments
</span>
)}
</TableCell>
<TableCell className="max-w-xs truncate">
{version.internal_comments ? (
<div
className="truncate text-pink-600"
title={version.internal_comments}
>
{version.internal_comments}
</div>
) : (
<span className="text-gray-400">
No internal comments
</span>
)}
</TableCell>
<TableCell>{version.name}</TableCell>
<TableCell>{version.sub_heading}</TableCell>
<TableCell>{version.description}</TableCell>
{/* <TableCell>{version.categories.join(", ")}</TableCell> */}
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
(window.location.href = `/admin/agents/${version.store_listing_version_id}`)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
Builder
</Button>
{version.status === SubmissionStatus.PENDING && (
<ApproveRejectButtons version={version} />
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableCell>
</TableRow>
)}
</>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SubmissionStatus } from "@/lib/autogpt-server-api/types";
export function SearchAndFilterAdminMarketplace({
initialStatus,
initialSearch,
}: {
initialStatus?: SubmissionStatus;
initialSearch?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Initialize state from URL parameters
const [searchQuery, setSearchQuery] = useState(initialSearch || "");
const [selectedStatus, setSelectedStatus] = useState<string>(
searchParams.get("status") || "ALL",
);
// Update local state when URL parameters change
useEffect(() => {
const status = searchParams.get("status");
setSelectedStatus(status || "ALL");
setSearchQuery(searchParams.get("search") || "");
}, [searchParams]);
const handleSearch = () => {
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set("search", searchQuery);
} else {
params.delete("search");
}
if (selectedStatus !== "ALL") {
params.set("status", selectedStatus);
} else {
params.delete("status");
}
params.set("page", "1"); // Reset to first page on new search
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-2">
<Input
placeholder="Search agents by Name, Creator, or Description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<Select
value={selectedStatus}
onValueChange={(value) => {
setSelectedStatus(value);
const params = new URLSearchParams(searchParams.toString());
if (value === "ALL") {
params.delete("status");
} else {
params.set("status", value);
}
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`);
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={SubmissionStatus.PENDING}>Pending</SelectItem>
<SelectItem value={SubmissionStatus.APPROVED}>Approved</SelectItem>
<SelectItem value={SubmissionStatus.REJECTED}>Rejected</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -246,6 +246,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
<span
className="block truncate pb-1 text-sm font-semibold dark:text-white"
data-id={`block-name-${block.id}`}
data-type={block.uiType}
data-testid={`block-name-${block.id}`}
>
<TextRenderer

View File

@@ -5,7 +5,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { format } from "date-fns";
import { CalendarIcon, Clock } from "lucide-react";
import { CalendarIcon } from "lucide-react";
import { Cross2Icon, Pencil2Icon, PlusIcon } from "@radix-ui/react-icons";
import { beautifyString, cn } from "@/lib/utils";
import {
@@ -196,6 +196,8 @@ const NodeDateTimeInput: FC<{
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
className?: string;
displayName: string;
hideDate?: boolean;
hideTime?: boolean;
}> = ({
selfKey,
schema,
@@ -204,6 +206,8 @@ const NodeDateTimeInput: FC<{
handleInputChange,
className,
displayName,
hideDate = false,
hideTime = false,
}) => {
const date = value ? new Date(value) : new Date();
const [timeInput, setTimeInput] = useState(
@@ -213,16 +217,28 @@ const NodeDateTimeInput: FC<{
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) return;
const [hours, minutes] = timeInput.split(":").map(Number);
newDate.setHours(hours, minutes);
handleInputChange(selfKey, newDate.toISOString());
if (hideTime) {
// Only pass YYYY-MM-DD if time is hidden
handleInputChange(selfKey, format(newDate, "yyyy-MM-dd"));
} else {
// Otherwise pass full date/time, but still incorporate time
const [hours, minutes] = timeInput.split(":").map(Number);
newDate.setHours(hours, minutes);
handleInputChange(selfKey, newDate.toISOString());
}
};
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTimeInput(newTime);
if (value) {
if (!value) return;
if (hideDate) {
// Only pass HH:mm if date is hidden
handleInputChange(selfKey, newTime);
} else {
// Otherwise pass full date/time
const [hours, minutes] = newTime.split(":").map(Number);
const newDate = new Date(value);
newDate.setHours(hours, minutes);
@@ -232,34 +248,131 @@ const NodeDateTimeInput: FC<{
return (
<div className={cn("flex flex-col gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
{hideDate || (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? format(date, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
)}
{hideTime || (
<LocalValuedInput
type="time"
value={timeInput}
onChange={handleTimeChange}
className="w-full"
/>
)}
{error && <span className="error-message">{error}</span>}
</div>
);
};
const NodeFileInput: FC<{
selfKey: string;
schema: BlockIOStringSubSchema;
value?: string;
error?: string;
handleInputChange: NodeObjectInputTreeProps["handleInputChange"];
className?: string;
displayName: string;
}> = ({
selfKey,
schema,
value = "",
error,
handleInputChange,
className,
displayName,
}) => {
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target?.result as string;
handleInputChange(selfKey, base64String);
};
reader.readAsDataURL(file);
},
[selfKey, handleInputChange],
);
const getFileLabel = useCallback((value: string) => {
if (value.startsWith("data:")) {
const matches = value.match(/^data:([^;]+);/);
if (matches?.[1]) {
const mimeParts = matches[1].split("/");
if (mimeParts.length > 1) {
return `${mimeParts[1].toUpperCase()} file`;
}
return `${matches[1]} file`;
}
} else {
const pathParts = value.split(".");
if (pathParts.length > 1) {
const ext = pathParts.pop();
if (ext) return `${ext.toUpperCase()} file`;
}
}
return "File";
}, []);
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="nodrag flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground",
)}
onClick={() =>
document.getElementById(`${selfKey}-upload`)?.click()
}
className="w-full"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? format(date, "PPP") : <span>Pick a date</span>}
{value ? `Change ${displayName}` : `Upload ${displayName}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
<LocalValuedInput
type="time"
value={timeInput}
onChange={handleTimeChange}
className="w-full"
/>
{value && (
<Button
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => handleInputChange(selfKey, "")}
>
<Cross2Icon className="h-4 w-4" />
</Button>
)}
</div>
<input
id={`${selfKey}-upload`}
type="file"
accept="*/*"
onChange={handleFileChange}
className="hidden"
/>
{value && (
<div className="break-all rounded-md border border-gray-300 p-2 dark:border-gray-600">
<small>{getFileLabel(value)}</small>
</div>
)}
</div>
{error && <span className="error-message">{error}</span>}
</div>
);
@@ -410,10 +523,14 @@ export const NodeGenericInputField: FC<{
if (
"format" in propSchema.anyOf[0] &&
propSchema.anyOf[0].format === "date-time"
["date-time", "date", "time"].includes(
propSchema.anyOf[0].format as string,
)
) {
return (
<NodeDateTimeInput
hideDate={propSchema.anyOf[0].format === "time"}
hideTime={propSchema.anyOf[0].format === "date"}
selfKey={propKey}
schema={propSchema.anyOf[0]}
value={currentValue}
@@ -425,6 +542,23 @@ export const NodeGenericInputField: FC<{
);
}
if (
"format" in propSchema.anyOf[0] &&
propSchema.anyOf[0].format === "file"
) {
return (
<NodeFileInput
selfKey={propKey}
schema={propSchema.anyOf[0] as BlockIOStringSubSchema}
value={currentValue}
error={errors[propKey]}
handleInputChange={handleInputChange}
className={className}
displayName={displayName}
/>
);
}
return (
<NodeStringInput
selfKey={propKey}
@@ -597,9 +731,14 @@ export const NodeGenericInputField: FC<{
/>
);
}
if ("format" in propSchema && propSchema.format === "date-time") {
if (
"format" in propSchema &&
["date-time", "date", "time"].includes(propSchema.format as string)
) {
return (
<NodeDateTimeInput
hideDate={propSchema.format === "time"}
hideTime={propSchema.format === "date"}
selfKey={propKey}
schema={propSchema}
value={currentValue}
@@ -610,6 +749,19 @@ export const NodeGenericInputField: FC<{
/>
);
}
if ("format" in propSchema && propSchema.format === "file") {
return (
<NodeFileInput
selfKey={propKey}
schema={propSchema}
value={currentValue}
error={errors[propKey]}
handleInputChange={handleInputChange}
className={className}
displayName={displayName}
/>
);
}
return (
<NodeStringInput
selfKey={propKey}
@@ -1390,15 +1542,10 @@ const NodeBooleanInput: FC<{
value ||= schema.default ?? false;
return (
<div className={className}>
<div className="nodrag flex items-center">
<Switch
defaultChecked={value}
onCheckedChange={(v) => handleInputChange(selfKey, v)}
/>
{displayName && (
<span className="ml-3 dark:text-gray-300">{displayName}</span>
)}
</div>
<Switch
defaultChecked={value}
onCheckedChange={(v) => handleInputChange(selfKey, v)}
/>
{error && <span className="error-message">{error}</span>}
</div>
);

View File

@@ -0,0 +1,48 @@
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
export function PaginationControls({
currentPage,
totalPages,
}: {
currentPage: number;
totalPages: number;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
return `${pathname}?${params.toString()}`;
};
const handlePageChange = (page: number) => {
router.push(createPageUrl(page));
};
return (
<div className="mt-4 flex items-center justify-center space-x-2">
<Button
variant="outline"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
Previous
</Button>
<span className="text-sm">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Next
</Button>
</div>
);
}

View File

@@ -39,6 +39,7 @@ import {
ScheduleID,
StoreAgentDetails,
StoreAgentsResponse,
StoreListingsWithVersionsResponse,
StoreReview,
StoreReviewCreate,
StoreSubmission,
@@ -50,6 +51,8 @@ import {
OttoQuery,
OttoResponse,
UserOnboarding,
ReviewSubmissionRequest,
SubmissionStatus,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
import getServerSupabase from "../supabase/getServerSupabase";
@@ -509,6 +512,30 @@ export default class BackendAPI {
return this._get(url);
}
/////////////////////////////////////////
/////////// Admin API ///////////////////
/////////////////////////////////////////
getAdminListingsWithVersions(params?: {
status?: SubmissionStatus;
search?: string;
page?: number;
page_size?: number;
}): Promise<StoreListingsWithVersionsResponse> {
return this._get("/store/admin/listings", params);
}
reviewSubmissionAdmin(
storeListingVersionId: string,
review: ReviewSubmissionRequest,
): Promise<StoreSubmission> {
return this._request(
"POST",
`/store/admin/submissions/${storeListingVersionId}/review`,
review,
);
}
/////////////////////////////////////////
/////////// V2 LIBRARY API //////////////
/////////////////////////////////////////

View File

@@ -1,3 +1,15 @@
export enum SubmissionStatus {
DRAFT = "DRAFT",
PENDING = "PENDING",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
}
export type ReviewSubmissionRequest = {
store_listing_version_id: string;
is_approved: boolean;
comments: string; // External comments visible to creator
internal_comments?: string; // Admin-only comments
};
export type Category = {
category: string;
description: string;
@@ -500,11 +512,11 @@ export enum BlockUIType {
WEBHOOK = "Webhook",
WEBHOOK_MANUAL = "Webhook (manual)",
AGENT = "Agent",
AI = "AI",
}
export enum SpecialBlockID {
AGENT = "e189baac-8c20-45a1-94a7-55177ea42565",
INPUT = "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
OUTPUT = "363ae599-353e-4804-937e-b2ee3cef3da4",
}
@@ -559,6 +571,11 @@ export type StoreAgentDetails = {
runs: number;
rating: number;
versions: string[];
// Approval and status fields
active_version_id?: string;
has_approved_version?: boolean;
is_available?: boolean;
};
export type Creator = {
@@ -595,9 +612,19 @@ export type StoreSubmission = {
description: string;
image_urls: string[];
date_submitted: string;
status: string;
status: SubmissionStatus;
runs: number;
rating: number;
slug: string;
store_listing_version_id?: string;
version?: number; // Actual version number from the database
// Review information
reviewer_id?: string;
review_comments?: string;
internal_comments?: string; // Admin-only comments
reviewed_at?: string;
changes_summary?: string;
};
export type StoreSubmissionsResponse = {
@@ -615,6 +642,7 @@ export type StoreSubmissionRequest = {
image_urls: string[];
description: string;
categories: string[];
changes_summary?: string;
};
export type ProfileDetails = {
@@ -770,3 +798,43 @@ export interface OttoQuery {
include_graph_data: boolean;
graph_id?: string;
}
export interface StoreListingWithVersions {
listing_id: string;
slug: string;
agent_id: string;
agent_version: number;
active_version_id: string | null;
has_approved_version: boolean;
creator_email: string | null;
latest_version: StoreSubmission | null;
versions: StoreSubmission[];
}
export interface StoreListingsWithVersionsResponse {
listings: StoreListingWithVersions[];
pagination: Pagination;
}
// Admin API Types
export type AdminSubmissionsRequest = {
status?: SubmissionStatus;
search?: string;
page: number;
page_size: number;
};
export type AdminListingHistoryRequest = {
listing_id: string;
page: number;
page_size: number;
};
export type AdminSubmissionDetailsRequest = {
store_listing_version_id: string;
};
export type AdminPendingSubmissionsRequest = {
page: number;
page_size: number;
};

View File

@@ -51,14 +51,16 @@ test.describe("Build", () => { //(1)!
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
const blocksToSkip = await buildPage.getBlocksToSkip();
const blockIdsToSkip = await buildPage.getBlocksToSkip();
const blockTypesToSkip = ["Input", "Output", "Agent", "AI"];
// add all the blocks in order except for the agent executor block
for (const block of blocks) {
if (block.name[0].toLowerCase() >= "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Adding block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
await buildPage.addBlock(block);
}
}
@@ -68,8 +70,8 @@ test.describe("Build", () => { //(1)!
if (block.name[0].toLowerCase() >= "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
console.log("Checking block:", block.name);
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Checking block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
}
@@ -89,14 +91,16 @@ test.describe("Build", () => { //(1)!
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
const blocksToSkip = await buildPage.getBlocksToSkip();
const blockIdsToSkip = await buildPage.getBlocksToSkip();
const blockTypesToSkip = ["Input", "Output", "Agent", "AI"];
// add all the blocks in order except for the agent executor block
for (const block of blocks) {
if (block.name[0].toLowerCase() < "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Adding block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
await buildPage.addBlock(block);
}
}
@@ -106,7 +110,8 @@ test.describe("Build", () => { //(1)!
if (block.name[0].toLowerCase() < "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Checking block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
}
@@ -141,11 +146,13 @@ test.describe("Build", () => { //(1)!
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 1",
description: "Store Value Block 1",
type: "Standard",
};
const block2 = {
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 2",
description: "Store Value Block 2",
type: "Standard",
};
// Add the blocks

View File

@@ -5,6 +5,7 @@ export interface Block {
id: string;
name: string;
description: string;
type: string;
}
export class BuildPage extends BasePage {
@@ -72,10 +73,12 @@ export class BuildPage extends BasePage {
const name = (await nameElement.textContent()) || "";
const description = (await descriptionElement.textContent()) || "";
const type = (await nameElement.getAttribute("data-type")) || "";
return {
id,
name: name.trim(),
type: type.trim(),
description: description.trim(),
};
} catch (elementError) {
@@ -358,6 +361,7 @@ export class BuildPage extends BasePage {
id: "31d1064e-7446-4693-a7d4-65e5ca1180d1",
name: "Add to Dictionary",
description: "Add to Dictionary",
type: "Standard",
};
}
@@ -375,30 +379,7 @@ export class BuildPage extends BasePage {
id: "b1ab9b19-67a6-406d-abf5-2dba76d00c79",
name: "Calculator",
description: "Calculator",
};
}
async getAgentExecutorBlockDetails(): Promise<Block> {
return {
id: "e189baac-8c20-45a1-94a7-55177ea42565",
name: "Agent Executor",
description: "Executes an existing agent inside your agent",
};
}
async getAgentOutputBlockDetails(): Promise<Block> {
return {
id: "363ae599-353e-4804-937e-b2ee3cef3da4",
name: "Agent Output",
description: "This block is used to output the result of an agent.",
};
}
async getAgentInputBlockDetails(): Promise<Block> {
return {
id: "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
name: "Agent Input",
description: "This block is used to provide input to the graph.",
type: "Standard",
};
}
@@ -408,15 +389,7 @@ export class BuildPage extends BasePage {
name: "Github Trigger",
description:
"This block triggers on pull request events and outputs the event type and payload.",
};
}
async getSmartDecisionMakerBlockDetails(): Promise<Block> {
return {
id: "3b191d9f-356f-482d-8238-ba04b6d18381",
name: "Smart Decision Maker",
description:
"This block is used to make a decision based on the input and the available tools.",
type: "Standard",
};
}
@@ -491,13 +464,7 @@ export class BuildPage extends BasePage {
}
async getBlocksToSkip(): Promise<string[]> {
return [
(await this.getAgentExecutorBlockDetails()).id,
(await this.getAgentInputBlockDetails()).id,
(await this.getAgentOutputBlockDetails()).id,
(await this.getGithubTriggerBlockDetails()).id,
(await this.getSmartDecisionMakerBlockDetails()).id,
];
return [(await this.getGithubTriggerBlockDetails()).id];
}
async waitForRunTutorialButton(): Promise<void> {

View File

@@ -1714,10 +1714,10 @@
outvariant "^1.4.3"
strict-event-emitter "^0.5.1"
"@next/env@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.23.tgz#3003b53693cbc476710b856f83e623c8231a6be9"
integrity sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA==
"@next/env@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.25.tgz#936d10b967e103e49a4bcea1e97292d5605278dd"
integrity sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w==
"@next/eslint-plugin-next@15.1.6":
version "15.1.6"
@@ -1726,50 +1726,50 @@
dependencies:
fast-glob "3.3.1"
"@next/swc-darwin-arm64@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz#6d83f03e35e163e8bbeaf5aeaa6bf55eed23d7a1"
integrity sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==
"@next/swc-darwin-arm64@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.25.tgz#7bcccfda0c0ff045c45fbe34c491b7368e373e3d"
integrity sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==
"@next/swc-darwin-x64@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz#e02abc35d5e36ce1550f674f8676999f293ba54f"
integrity sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==
"@next/swc-darwin-x64@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.25.tgz#b489e209d7b405260b73f69a38186ed150fb7a08"
integrity sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==
"@next/swc-linux-arm64-gnu@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz#f13516ad2d665950951b59e7c239574bb8504d63"
integrity sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==
"@next/swc-linux-arm64-gnu@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.25.tgz#ba064fabfdce0190d9859493d8232fffa84ef2e2"
integrity sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==
"@next/swc-linux-arm64-musl@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz#10d05a1c161dc8426d54ccf6d9bbed6953a3252a"
integrity sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==
"@next/swc-linux-arm64-musl@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.25.tgz#bf0018267e4e0fbfa1524750321f8cae855144a3"
integrity sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==
"@next/swc-linux-x64-gnu@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz#7f5856df080f58ba058268b30429a2ab52500536"
integrity sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==
"@next/swc-linux-x64-gnu@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.25.tgz#64f5a6016a7148297ee80542e0fd788418a32472"
integrity sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==
"@next/swc-linux-x64-musl@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz#d494ebdf26421c91be65f9b1d095df0191c956d8"
integrity sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==
"@next/swc-linux-x64-musl@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.25.tgz#58dc636d7c55828478159546f7b95ab1e902301c"
integrity sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==
"@next/swc-win32-arm64-msvc@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz#62786e7ba4822a20b6666e3e03e5a389b0e7eb3b"
integrity sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==
"@next/swc-win32-arm64-msvc@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.25.tgz#93562d447c799bded1e89c1a62d5195a2a8c6c0d"
integrity sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==
"@next/swc-win32-ia32-msvc@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz#ef028af91e1c40a4ebba0d2c47b23c1eeb299594"
integrity sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==
"@next/swc-win32-ia32-msvc@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.25.tgz#ad85a33466be1f41d083211ea21adc0d2c6e6554"
integrity sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==
"@next/swc-win32-x64-msvc@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz#c81838f02f2f16a321b7533890fb63c1edec68e1"
integrity sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==
"@next/swc-win32-x64-msvc@14.2.25":
version "14.2.25"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.25.tgz#3969c66609e683ec63a6a9f320a855f7be686a08"
integrity sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==
"@next/third-parties@^15.2.1":
version "15.2.1"
@@ -9067,12 +9067,12 @@ next-themes@^0.4.5:
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.5.tgz#267178c45798df6adfca0843bfc968269fcb7198"
integrity sha512-E8/gYKBxZknOXBiDk/sRokAvkOw35PTUD4Gxtq1eBhd0r4Dx5S42zU65/q8ozR5rcSG2ZlE1E3+ShlUpC7an+A==
next@^14.2.21:
version "14.2.23"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.23.tgz#37edc9a4d42c135fd97a4092f829e291e2e7c943"
integrity sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==
next@^14.2.25:
version "14.2.25"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.25.tgz#0657551fde6a97f697cf9870e9ccbdaa465c6008"
integrity sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==
dependencies:
"@next/env" "14.2.23"
"@next/env" "14.2.25"
"@swc/helpers" "0.5.5"
busboy "1.6.0"
caniuse-lite "^1.0.30001579"
@@ -9080,15 +9080,15 @@ next@^14.2.21:
postcss "8.4.31"
styled-jsx "5.1.1"
optionalDependencies:
"@next/swc-darwin-arm64" "14.2.23"
"@next/swc-darwin-x64" "14.2.23"
"@next/swc-linux-arm64-gnu" "14.2.23"
"@next/swc-linux-arm64-musl" "14.2.23"
"@next/swc-linux-x64-gnu" "14.2.23"
"@next/swc-linux-x64-musl" "14.2.23"
"@next/swc-win32-arm64-msvc" "14.2.23"
"@next/swc-win32-ia32-msvc" "14.2.23"
"@next/swc-win32-x64-msvc" "14.2.23"
"@next/swc-darwin-arm64" "14.2.25"
"@next/swc-darwin-x64" "14.2.25"
"@next/swc-linux-arm64-gnu" "14.2.25"
"@next/swc-linux-arm64-musl" "14.2.25"
"@next/swc-linux-x64-gnu" "14.2.25"
"@next/swc-linux-x64-musl" "14.2.25"
"@next/swc-win32-arm64-msvc" "14.2.25"
"@next/swc-win32-ia32-msvc" "14.2.25"
"@next/swc-win32-x64-msvc" "14.2.25"
no-case@^3.0.4:
version "3.0.4"