mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into add-iffy-moderation
This commit is contained in:
@@ -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(
|
||||
|
||||
542
autogpt_platform/backend/backend/blocks/io.py
Normal file
542
autogpt_platform/backend/backend/blocks/io.py
Normal 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),
|
||||
],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from backend.app import run_processes
|
||||
from backend.executor import DatabaseManager, ExecutionManager
|
||||
from backend.executor import ExecutionManager
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
97
autogpt_platform/backend/backend/executor/utils.py
Normal file
97
autogpt_platform/backend/backend/executor/utils.py
Normal 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()
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
// );
|
||||
// };
|
||||
@@ -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>
|
||||
// );
|
||||
// }
|
||||
@@ -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>
|
||||
// );
|
||||
// }
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
@@ -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;
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 //////////////
|
||||
/////////////////////////////////////////
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user