mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into swiftyos/sdk
This commit is contained in:
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -10,6 +10,8 @@ updates:
|
||||
commit-message:
|
||||
prefix: "chore(libs/deps)"
|
||||
prefix-development: "chore(libs/deps-dev)"
|
||||
ignore:
|
||||
- dependency-name: "poetry"
|
||||
groups:
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
@@ -32,6 +34,8 @@ updates:
|
||||
commit-message:
|
||||
prefix: "chore(backend/deps)"
|
||||
prefix-development: "chore(backend/deps-dev)"
|
||||
ignore:
|
||||
- dependency-name: "poetry"
|
||||
groups:
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
@@ -45,7 +49,7 @@ updates:
|
||||
- "patch"
|
||||
|
||||
# frontend (Next.js project)
|
||||
- package-ecosystem: "pnpm"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "autogpt_platform/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
6
.github/workflows/platform-frontend-ci.yml
vendored
6
.github/workflows/platform-frontend-ci.yml
vendored
@@ -103,11 +103,15 @@ jobs:
|
||||
- name: Setup .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build --turbo
|
||||
# uses Turbopack, much faster and safe enough for a test pipeline
|
||||
|
||||
- name: Install Browser '${{ matrix.browser }}'
|
||||
run: pnpm playwright install --with-deps ${{ matrix.browser }}
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test --project=${{ matrix.browser }}
|
||||
run: pnpm test:no-build --project=${{ matrix.browser }}
|
||||
|
||||
- name: Print Final Docker Compose logs
|
||||
if: always()
|
||||
|
||||
@@ -12,6 +12,7 @@ from prisma.types import (
|
||||
AgentGraphWhereInput,
|
||||
AgentNodeCreateInput,
|
||||
AgentNodeLinkCreateInput,
|
||||
StoreListingVersionWhereInput,
|
||||
)
|
||||
from pydantic import create_model
|
||||
from pydantic.fields import computed_field
|
||||
@@ -712,23 +713,24 @@ async def get_graph(
|
||||
include=AGENT_GRAPH_INCLUDE,
|
||||
order={"version": "desc"},
|
||||
)
|
||||
|
||||
# For access, the graph must be owned by the user or listed in the store
|
||||
if graph is None or (
|
||||
graph.userId != user_id
|
||||
and not (
|
||||
await StoreListingVersion.prisma().find_first(
|
||||
where={
|
||||
"agentGraphId": graph_id,
|
||||
"agentGraphVersion": version or graph.version,
|
||||
"isDeleted": False,
|
||||
"submissionStatus": SubmissionStatus.APPROVED,
|
||||
}
|
||||
)
|
||||
)
|
||||
):
|
||||
if graph is None:
|
||||
return None
|
||||
|
||||
if graph.userId != user_id:
|
||||
store_listing_filter: StoreListingVersionWhereInput = {
|
||||
"agentGraphId": graph_id,
|
||||
"isDeleted": False,
|
||||
"submissionStatus": SubmissionStatus.APPROVED,
|
||||
}
|
||||
if version is not None:
|
||||
store_listing_filter["agentGraphVersion"] = version
|
||||
|
||||
# For access, the graph must be owned by the user or listed in the store
|
||||
if not await StoreListingVersion.prisma().find_first(
|
||||
where=store_listing_filter, order={"agentGraphVersion": "desc"}
|
||||
):
|
||||
return None
|
||||
|
||||
if include_subgraphs or for_export:
|
||||
sub_graphs = await get_sub_graphs(graph)
|
||||
return GraphModel.from_db(
|
||||
|
||||
@@ -305,6 +305,13 @@ def _enqueue_next_nodes(
|
||||
)
|
||||
|
||||
def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]:
|
||||
try:
|
||||
return _register_next_executions(node_link)
|
||||
except Exception as e:
|
||||
log_metadata.exception(f"Failed to register next executions: {e}")
|
||||
return []
|
||||
|
||||
def _register_next_executions(node_link: Link) -> list[NodeExecutionEntry]:
|
||||
enqueued_executions = []
|
||||
next_output_name = node_link.source_name
|
||||
next_input_name = node_link.sink_name
|
||||
|
||||
@@ -174,68 +174,195 @@ def _is_cost_filter_match(cost_filter: BlockInput, input_data: BlockInput) -> bo
|
||||
|
||||
# ============ Execution Input Helpers ============ #
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Delimiters
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
LIST_SPLIT = "_$_"
|
||||
DICT_SPLIT = "_#_"
|
||||
OBJC_SPLIT = "_@_"
|
||||
|
||||
_DELIMS = (LIST_SPLIT, DICT_SPLIT, OBJC_SPLIT)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tokenisation utilities
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _next_delim(s: str) -> tuple[str | None, int]:
|
||||
"""
|
||||
Return the *earliest* delimiter appearing in `s` and its index.
|
||||
|
||||
If none present → (None, -1).
|
||||
"""
|
||||
first: str | None = None
|
||||
pos = len(s) # sentinel: larger than any real index
|
||||
for d in _DELIMS:
|
||||
i = s.find(d)
|
||||
if 0 <= i < pos:
|
||||
first, pos = d, i
|
||||
return first, (pos if first else -1)
|
||||
|
||||
|
||||
def _tokenise(path: str) -> list[tuple[str, str]] | None:
|
||||
"""
|
||||
Convert the raw path string (starting with a delimiter) into
|
||||
[ (delimiter, identifier), … ] or None if the syntax is malformed.
|
||||
"""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
while path:
|
||||
# 1. Which delimiter starts this chunk?
|
||||
delim = next((d for d in _DELIMS if path.startswith(d)), None)
|
||||
if delim is None:
|
||||
return None # invalid syntax
|
||||
|
||||
# 2. Slice off the delimiter, then up to the next delimiter (or EOS)
|
||||
path = path[len(delim) :]
|
||||
nxt_delim, pos = _next_delim(path)
|
||||
token, path = (
|
||||
path[: pos if pos != -1 else len(path)],
|
||||
path[pos if pos != -1 else len(path) :],
|
||||
)
|
||||
if token == "":
|
||||
return None # empty identifier is invalid
|
||||
tokens.append((delim, token))
|
||||
return tokens
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Public API – parsing (flattened ➜ concrete)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def parse_execution_output(output: BlockData, name: str) -> Any | None:
|
||||
"""
|
||||
Extracts partial output data by name from a given BlockData.
|
||||
Retrieve a nested value out of `output` using the flattened *name*.
|
||||
|
||||
The function supports extracting data from lists, dictionaries, and objects
|
||||
using specific naming conventions:
|
||||
- For lists: <output_name>_$_<index>
|
||||
- For dictionaries: <output_name>_#_<key>
|
||||
- For objects: <output_name>_@_<attribute>
|
||||
|
||||
Args:
|
||||
output (BlockData): A tuple containing the output name and data.
|
||||
name (str): The name used to extract specific data from the output.
|
||||
|
||||
Returns:
|
||||
Any | None: The extracted data if found, otherwise None.
|
||||
|
||||
Examples:
|
||||
>>> output = ("result", [10, 20, 30])
|
||||
>>> parse_execution_output(output, "result_$_1")
|
||||
20
|
||||
|
||||
>>> output = ("config", {"key1": "value1", "key2": "value2"})
|
||||
>>> parse_execution_output(output, "config_#_key1")
|
||||
'value1'
|
||||
|
||||
>>> class Sample:
|
||||
... attr1 = "value1"
|
||||
... attr2 = "value2"
|
||||
>>> output = ("object", Sample())
|
||||
>>> parse_execution_output(output, "object_@_attr1")
|
||||
'value1'
|
||||
On any failure (wrong name, wrong type, out-of-range, bad path)
|
||||
returns **None**.
|
||||
"""
|
||||
output_name, output_data = output
|
||||
base_name, data = output
|
||||
|
||||
if name == output_name:
|
||||
return output_data
|
||||
# Exact match → whole object
|
||||
if name == base_name:
|
||||
return data
|
||||
|
||||
if name.startswith(f"{output_name}{LIST_SPLIT}"):
|
||||
index = int(name.split(LIST_SPLIT)[1])
|
||||
if not isinstance(output_data, list) or len(output_data) <= index:
|
||||
return None
|
||||
return output_data[int(name.split(LIST_SPLIT)[1])]
|
||||
# Must start with the expected name
|
||||
if not name.startswith(base_name):
|
||||
return None
|
||||
path = name[len(base_name) :]
|
||||
if not path:
|
||||
return None # nothing left to parse
|
||||
|
||||
if name.startswith(f"{output_name}{DICT_SPLIT}"):
|
||||
index = name.split(DICT_SPLIT)[1]
|
||||
if not isinstance(output_data, dict) or index not in output_data:
|
||||
return None
|
||||
return output_data[index]
|
||||
|
||||
if name.startswith(f"{output_name}{OBJC_SPLIT}"):
|
||||
index = name.split(OBJC_SPLIT)[1]
|
||||
if isinstance(output_data, object) and hasattr(output_data, index):
|
||||
return getattr(output_data, index)
|
||||
tokens = _tokenise(path)
|
||||
if tokens is None:
|
||||
return None
|
||||
|
||||
return None
|
||||
cur: Any = data
|
||||
for delim, ident in tokens:
|
||||
if delim == LIST_SPLIT:
|
||||
# list[index]
|
||||
try:
|
||||
idx = int(ident)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(cur, list) or idx >= len(cur):
|
||||
return None
|
||||
cur = cur[idx]
|
||||
|
||||
elif delim == DICT_SPLIT:
|
||||
if not isinstance(cur, dict) or ident not in cur:
|
||||
return None
|
||||
cur = cur[ident]
|
||||
|
||||
elif delim == OBJC_SPLIT:
|
||||
if not hasattr(cur, ident):
|
||||
return None
|
||||
cur = getattr(cur, ident)
|
||||
|
||||
else:
|
||||
return None # unreachable
|
||||
|
||||
return cur
|
||||
|
||||
|
||||
def _assign(container: Any, tokens: list[tuple[str, str]], value: Any) -> Any:
|
||||
"""
|
||||
Recursive helper that *returns* the (possibly new) container with
|
||||
`value` assigned along the remaining `tokens` path.
|
||||
"""
|
||||
if not tokens:
|
||||
return value # leaf reached
|
||||
|
||||
delim, ident = tokens[0]
|
||||
rest = tokens[1:]
|
||||
|
||||
# ---------- list ----------
|
||||
if delim == LIST_SPLIT:
|
||||
try:
|
||||
idx = int(ident)
|
||||
except ValueError:
|
||||
raise ValueError("index must be an integer")
|
||||
|
||||
if container is None:
|
||||
container = []
|
||||
elif not isinstance(container, list):
|
||||
container = list(container) if hasattr(container, "__iter__") else []
|
||||
|
||||
while len(container) <= idx:
|
||||
container.append(None)
|
||||
container[idx] = _assign(container[idx], rest, value)
|
||||
return container
|
||||
|
||||
# ---------- dict ----------
|
||||
if delim == DICT_SPLIT:
|
||||
if container is None:
|
||||
container = {}
|
||||
elif not isinstance(container, dict):
|
||||
container = dict(container) if hasattr(container, "items") else {}
|
||||
container[ident] = _assign(container.get(ident), rest, value)
|
||||
return container
|
||||
|
||||
# ---------- object ----------
|
||||
if delim == OBJC_SPLIT:
|
||||
if container is None or not isinstance(container, MockObject):
|
||||
container = MockObject()
|
||||
setattr(
|
||||
container,
|
||||
ident,
|
||||
_assign(getattr(container, ident, None), rest, value),
|
||||
)
|
||||
return container
|
||||
|
||||
return value # unreachable
|
||||
|
||||
|
||||
def merge_execution_input(data: BlockInput) -> BlockInput:
|
||||
"""
|
||||
Reconstruct nested objects from a *flattened* dict of key → value.
|
||||
|
||||
Raises ValueError on syntactically invalid list indices.
|
||||
"""
|
||||
merged: BlockInput = {}
|
||||
|
||||
for key, value in data.items():
|
||||
# Split off the base name (before the first delimiter, if any)
|
||||
delim, pos = _next_delim(key)
|
||||
if delim is None:
|
||||
merged[key] = value
|
||||
continue
|
||||
|
||||
base, path = key[:pos], key[pos:]
|
||||
tokens = _tokenise(path)
|
||||
if tokens is None:
|
||||
# Invalid key; treat as scalar under the raw name
|
||||
merged[key] = value
|
||||
continue
|
||||
|
||||
merged[base] = _assign(merged.get(base), tokens, value)
|
||||
|
||||
data.update(merged)
|
||||
return data
|
||||
|
||||
|
||||
def validate_exec(
|
||||
@@ -292,77 +419,6 @@ def validate_exec(
|
||||
return data, node_block.name
|
||||
|
||||
|
||||
def merge_execution_input(data: BlockInput) -> BlockInput:
|
||||
"""
|
||||
Merges dynamic input pins into a single list, dictionary, or object based on naming patterns.
|
||||
|
||||
This function processes input keys that follow specific patterns to merge them into a unified structure:
|
||||
- `<input_name>_$_<index>` for list inputs.
|
||||
- `<input_name>_#_<index>` for dictionary inputs.
|
||||
- `<input_name>_@_<index>` for object inputs.
|
||||
|
||||
Args:
|
||||
data (BlockInput): A dictionary containing input keys and their corresponding values.
|
||||
|
||||
Returns:
|
||||
BlockInput: A dictionary with merged inputs.
|
||||
|
||||
Raises:
|
||||
ValueError: If a list index is not an integer.
|
||||
|
||||
Examples:
|
||||
>>> data = {
|
||||
... "list_$_0": "a",
|
||||
... "list_$_1": "b",
|
||||
... "dict_#_key1": "value1",
|
||||
... "dict_#_key2": "value2",
|
||||
... "object_@_attr1": "value1",
|
||||
... "object_@_attr2": "value2"
|
||||
... }
|
||||
>>> merge_execution_input(data)
|
||||
{
|
||||
"list": ["a", "b"],
|
||||
"dict": {"key1": "value1", "key2": "value2"},
|
||||
"object": <MockObject attr1="value1" attr2="value2">
|
||||
}
|
||||
"""
|
||||
|
||||
# Merge all input with <input_name>_$_<index> into a single list.
|
||||
items = list(data.items())
|
||||
|
||||
for key, value in items:
|
||||
if LIST_SPLIT not in key:
|
||||
continue
|
||||
name, index = key.split(LIST_SPLIT)
|
||||
if not index.isdigit():
|
||||
raise ValueError(f"Invalid key: {key}, #{index} index must be an integer.")
|
||||
|
||||
data[name] = data.get(name, [])
|
||||
if int(index) >= len(data[name]):
|
||||
# Pad list with empty string on missing indices.
|
||||
data[name].extend([""] * (int(index) - len(data[name]) + 1))
|
||||
data[name][int(index)] = value
|
||||
|
||||
# Merge all input with <input_name>_#_<index> into a single dict.
|
||||
for key, value in items:
|
||||
if DICT_SPLIT not in key:
|
||||
continue
|
||||
name, index = key.split(DICT_SPLIT)
|
||||
data[name] = data.get(name, {})
|
||||
data[name][index] = value
|
||||
|
||||
# Merge all input with <input_name>_@_<index> into a single object.
|
||||
for key, value in items:
|
||||
if OBJC_SPLIT not in key:
|
||||
continue
|
||||
name, index = key.split(OBJC_SPLIT)
|
||||
if name not in data or not isinstance(data[name], object):
|
||||
data[name] = MockObject()
|
||||
setattr(data[name], index, value)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _validate_node_input_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
|
||||
1
autogpt_platform/backend/poetry.lock
generated
1
autogpt_platform/backend/poetry.lock
generated
@@ -3729,6 +3729,7 @@ files = [
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"},
|
||||
|
||||
@@ -1,55 +1,278 @@
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.executor.utils import merge_execution_input, parse_execution_output
|
||||
from backend.util.mock import MockObject
|
||||
|
||||
|
||||
def test_parse_execution_output():
|
||||
# Test case for list extraction
|
||||
# Test case for basic output
|
||||
output = ("result", "value")
|
||||
assert parse_execution_output(output, "result") == "value"
|
||||
|
||||
# Test case for list output
|
||||
output = ("result", [10, 20, 30])
|
||||
assert parse_execution_output(output, "result_$_1") == 20
|
||||
assert parse_execution_output(output, "result_$_3") is None
|
||||
|
||||
# Test case for dictionary extraction
|
||||
output = ("config", {"key1": "value1", "key2": "value2"})
|
||||
assert parse_execution_output(output, "config_#_key1") == "value1"
|
||||
assert parse_execution_output(output, "config_#_key3") is None
|
||||
# Test case for dict output
|
||||
output = ("result", {"key1": "value1", "key2": "value2"})
|
||||
assert parse_execution_output(output, "result_#_key1") == "value1"
|
||||
|
||||
# Test case for object extraction
|
||||
# Test case for object output
|
||||
class Sample:
|
||||
attr1 = "value1"
|
||||
attr2 = "value2"
|
||||
def __init__(self):
|
||||
self.attr1 = "value1"
|
||||
self.attr2 = "value2"
|
||||
|
||||
output = ("object", Sample())
|
||||
assert parse_execution_output(output, "object_@_attr1") == "value1"
|
||||
assert parse_execution_output(output, "object_@_attr3") is None
|
||||
output = ("result", Sample())
|
||||
assert parse_execution_output(output, "result_@_attr1") == "value1"
|
||||
|
||||
# Test case for direct match
|
||||
output = ("direct", "match")
|
||||
assert parse_execution_output(output, "direct") == "match"
|
||||
assert parse_execution_output(output, "nomatch") is None
|
||||
# Test case for nested list output
|
||||
output = ("result", [[1, 2], [3, 4]])
|
||||
assert parse_execution_output(output, "result_$_0_$_1") == 2
|
||||
assert parse_execution_output(output, "result_$_1_$_0") == 3
|
||||
|
||||
# Test case for list containing dict
|
||||
output = ("result", [{"key1": "value1"}, {"key2": "value2"}])
|
||||
assert parse_execution_output(output, "result_$_0_#_key1") == "value1"
|
||||
assert parse_execution_output(output, "result_$_1_#_key2") == "value2"
|
||||
|
||||
# Test case for dict containing list
|
||||
output = ("result", {"key1": [1, 2], "key2": [3, 4]})
|
||||
assert parse_execution_output(output, "result_#_key1_$_1") == 2
|
||||
assert parse_execution_output(output, "result_#_key2_$_0") == 3
|
||||
|
||||
# Test case for complex nested structure
|
||||
class NestedSample:
|
||||
def __init__(self):
|
||||
self.attr1 = [1, 2]
|
||||
self.attr2 = {"key": "value"}
|
||||
|
||||
output = ("result", [NestedSample(), {"key": [1, 2]}])
|
||||
assert parse_execution_output(output, "result_$_0_@_attr1_$_1") == 2
|
||||
assert parse_execution_output(output, "result_$_0_@_attr2_#_key") == "value"
|
||||
assert parse_execution_output(output, "result_$_1_#_key_$_0") == 1
|
||||
|
||||
# Test case for non-existent paths
|
||||
output = ("result", [1, 2, 3])
|
||||
assert parse_execution_output(output, "result_$_5") is None
|
||||
assert parse_execution_output(output, "result_#_key") is None
|
||||
assert parse_execution_output(output, "result_@_attr") is None
|
||||
assert parse_execution_output(output, "wrong_name") is None
|
||||
|
||||
# Test cases for delimiter processing order
|
||||
# Test case 1: List -> Dict -> List
|
||||
output = ("result", [[{"key": [1, 2]}], [3, 4]])
|
||||
assert parse_execution_output(output, "result_$_0_$_0_#_key_$_1") == 2
|
||||
|
||||
# Test case 2: Dict -> List -> Object
|
||||
class NestedObj:
|
||||
def __init__(self):
|
||||
self.value = "nested"
|
||||
|
||||
output = ("result", {"key": [NestedObj(), 2]})
|
||||
assert parse_execution_output(output, "result_#_key_$_0_@_value") == "nested"
|
||||
|
||||
# Test case 3: Object -> List -> Dict
|
||||
class ParentObj:
|
||||
def __init__(self):
|
||||
self.items = [{"nested": "value"}]
|
||||
|
||||
output = ("result", ParentObj())
|
||||
assert parse_execution_output(output, "result_@_items_$_0_#_nested") == "value"
|
||||
|
||||
# Test case 4: Complex nested structure with all types
|
||||
class ComplexObj:
|
||||
def __init__(self):
|
||||
self.data = [{"items": [{"value": "deep"}]}]
|
||||
|
||||
output = ("result", {"key": [ComplexObj()]})
|
||||
assert (
|
||||
parse_execution_output(
|
||||
output, "result_#_key_$_0_@_data_$_0_#_items_$_0_#_value"
|
||||
)
|
||||
== "deep"
|
||||
)
|
||||
|
||||
# Test case 5: Invalid paths that should return None
|
||||
output = ("result", [{"key": [1, 2]}])
|
||||
assert parse_execution_output(output, "result_$_0_#_wrong_key") is None
|
||||
assert parse_execution_output(output, "result_$_0_#_key_$_5") is None
|
||||
assert parse_execution_output(output, "result_$_0_@_attr") is None
|
||||
|
||||
# Test case 6: Mixed delimiter types in wrong order
|
||||
output = ("result", {"key": [1, 2]})
|
||||
assert (
|
||||
parse_execution_output(output, "result_#_key_$_1_@_attr") is None
|
||||
) # Should fail at @_attr
|
||||
assert (
|
||||
parse_execution_output(output, "result_@_attr_$_0_#_key") is None
|
||||
) # Should fail at @_attr
|
||||
|
||||
|
||||
def test_merge_execution_input():
|
||||
# Test case for merging list inputs
|
||||
data = {"list_$_0": "a", "list_$_1": "b", "list_$_3": "d"}
|
||||
merged_data = merge_execution_input(data)
|
||||
assert merged_data["list"] == ["a", "b", "", "d"]
|
||||
# Test case for basic list extraction
|
||||
data = {
|
||||
"list_$_0": "a",
|
||||
"list_$_1": "b",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "list" in result
|
||||
assert result["list"] == ["a", "b"]
|
||||
|
||||
# Test case for merging dictionary inputs
|
||||
data = {"dict_#_key1": "value1", "dict_#_key2": "value2"}
|
||||
merged_data = merge_execution_input(data)
|
||||
assert merged_data["dict"] == {"key1": "value1", "key2": "value2"}
|
||||
# Test case for basic dict extraction
|
||||
data = {
|
||||
"dict_#_key1": "value1",
|
||||
"dict_#_key2": "value2",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "dict" in result
|
||||
assert result["dict"] == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
# Test case for merging object inputs
|
||||
data = {"object_@_attr1": "value1", "object_@_attr2": "value2"}
|
||||
merged_data = merge_execution_input(data)
|
||||
assert hasattr(merged_data["object"], "attr1")
|
||||
assert hasattr(merged_data["object"], "attr2")
|
||||
assert merged_data["object"].attr1 == "value1"
|
||||
assert merged_data["object"].attr2 == "value2"
|
||||
# Test case for object extraction
|
||||
class Sample:
|
||||
def __init__(self):
|
||||
self.attr1 = None
|
||||
self.attr2 = None
|
||||
|
||||
# Test case for mixed inputs
|
||||
data = {"list_$_0": "a", "dict_#_key1": "value1", "object_@_attr1": "value1"}
|
||||
merged_data = merge_execution_input(data)
|
||||
assert merged_data["list"] == ["a"]
|
||||
assert merged_data["dict"] == {"key1": "value1"}
|
||||
assert hasattr(merged_data["object"], "attr1")
|
||||
assert merged_data["object"].attr1 == "value1"
|
||||
data = {
|
||||
"object_@_attr1": "value1",
|
||||
"object_@_attr2": "value2",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "object" in result
|
||||
assert isinstance(result["object"], MockObject)
|
||||
assert result["object"].attr1 == "value1"
|
||||
assert result["object"].attr2 == "value2"
|
||||
|
||||
# Test case for nested list extraction
|
||||
data = {
|
||||
"nested_list_$_0_$_0": "a",
|
||||
"nested_list_$_0_$_1": "b",
|
||||
"nested_list_$_1_$_0": "c",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "nested_list" in result
|
||||
assert result["nested_list"] == [["a", "b"], ["c"]]
|
||||
|
||||
# Test case for list containing dict
|
||||
data = {
|
||||
"list_with_dict_$_0_#_key1": "value1",
|
||||
"list_with_dict_$_0_#_key2": "value2",
|
||||
"list_with_dict_$_1_#_key3": "value3",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "list_with_dict" in result
|
||||
assert result["list_with_dict"] == [
|
||||
{"key1": "value1", "key2": "value2"},
|
||||
{"key3": "value3"},
|
||||
]
|
||||
|
||||
# Test case for dict containing list
|
||||
data = {
|
||||
"dict_with_list_#_key1_$_0": "value1",
|
||||
"dict_with_list_#_key1_$_1": "value2",
|
||||
"dict_with_list_#_key2_$_0": "value3",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "dict_with_list" in result
|
||||
assert result["dict_with_list"] == {
|
||||
"key1": ["value1", "value2"],
|
||||
"key2": ["value3"],
|
||||
}
|
||||
|
||||
# Test case for complex nested structure
|
||||
data = {
|
||||
"complex_$_0_#_key1_$_0": "value1",
|
||||
"complex_$_0_#_key1_$_1": "value2",
|
||||
"complex_$_0_#_key2_@_attr1": "value3",
|
||||
"complex_$_1_#_key3_$_0": "value4",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "complex" in result
|
||||
assert result["complex"][0]["key1"] == ["value1", "value2"]
|
||||
assert isinstance(result["complex"][0]["key2"], MockObject)
|
||||
assert result["complex"][0]["key2"].attr1 == "value3"
|
||||
assert result["complex"][1]["key3"] == ["value4"]
|
||||
|
||||
# Test case for invalid list index
|
||||
data = {"list_$_invalid": "value"}
|
||||
with pytest.raises(ValueError, match="index must be an integer"):
|
||||
merge_execution_input(data)
|
||||
|
||||
# Test cases for delimiter ordering
|
||||
# Test case 1: List -> Dict -> List
|
||||
data = {
|
||||
"nested_$_0_#_key_$_0": "value1",
|
||||
"nested_$_0_#_key_$_1": "value2",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "nested" in result
|
||||
assert result["nested"][0]["key"] == ["value1", "value2"]
|
||||
|
||||
# Test case 2: Dict -> List -> Object
|
||||
data = {
|
||||
"nested_#_key_$_0_@_attr": "value1",
|
||||
"nested_#_key_$_1_@_attr": "value2",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "nested" in result
|
||||
assert isinstance(result["nested"]["key"][0], MockObject)
|
||||
assert result["nested"]["key"][0].attr == "value1"
|
||||
assert result["nested"]["key"][1].attr == "value2"
|
||||
|
||||
# Test case 3: Object -> List -> Dict
|
||||
data = {
|
||||
"nested_@_items_$_0_#_key": "value1",
|
||||
"nested_@_items_$_1_#_key": "value2",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "nested" in result
|
||||
nested = result["nested"]
|
||||
assert isinstance(nested, MockObject)
|
||||
items = nested.items
|
||||
assert isinstance(items, list)
|
||||
assert items[0]["key"] == "value1"
|
||||
assert items[1]["key"] == "value2"
|
||||
|
||||
# Test case 4: Complex nested structure with all types
|
||||
data = {
|
||||
"deep_#_key_$_0_@_data_$_0_#_items_$_0_#_value": "deep_value",
|
||||
"deep_#_key_$_0_@_data_$_1_#_items_$_0_#_value": "another_value",
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "deep" in result
|
||||
deep_key = result["deep"]["key"][0]
|
||||
assert deep_key is not None
|
||||
data0 = getattr(deep_key, "data", None)
|
||||
assert isinstance(data0, list)
|
||||
# Check items0
|
||||
items0 = None
|
||||
if len(data0) > 0 and isinstance(data0[0], dict) and "items" in data0[0]:
|
||||
items0 = data0[0]["items"]
|
||||
assert isinstance(items0, list)
|
||||
items0 = cast(list, items0)
|
||||
assert len(items0) > 0
|
||||
assert isinstance(items0[0], dict)
|
||||
assert items0[0]["value"] == "deep_value" # type: ignore
|
||||
# Check items1
|
||||
items1 = None
|
||||
if len(data0) > 1 and isinstance(data0[1], dict) and "items" in data0[1]:
|
||||
items1 = data0[1]["items"]
|
||||
assert isinstance(items1, list)
|
||||
items1 = cast(list, items1)
|
||||
assert len(items1) > 0
|
||||
assert isinstance(items1[0], dict)
|
||||
assert items1[0]["value"] == "another_value" # type: ignore
|
||||
|
||||
# Test case 5: Mixed delimiter types in different orders
|
||||
# the last one should replace the type
|
||||
data = {
|
||||
"mixed_$_0_#_key_@_attr": "value1", # List -> Dict -> Object
|
||||
"mixed_#_key_$_0_@_attr": "value2", # Dict -> List -> Object
|
||||
"mixed_@_attr_$_0_#_key": "value3", # Object -> List -> Dict
|
||||
}
|
||||
result = merge_execution_input(data)
|
||||
assert "mixed" in result
|
||||
assert result["mixed"].attr[0]["key"] == "value3"
|
||||
|
||||
@@ -30,3 +30,4 @@ NEXT_PUBLIC_SHOW_BILLING_PAGE=false
|
||||
## Get these from the Cloudflare Turnstile dashboard: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||
## This is the frontend site key
|
||||
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=
|
||||
NEXT_PUBLIC_DISABLE_TURNSTILE=false
|
||||
|
||||
@@ -18,4 +18,5 @@ const config: StorybookConfig = {
|
||||
},
|
||||
staticDirs: ["../public"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import type { Preview } from "@storybook/react";
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
import "../src/app/globals.css";
|
||||
import "../src/components/styles/fonts.css";
|
||||
|
||||
// Initialize MSW
|
||||
initialize();
|
||||
@@ -18,6 +20,13 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
loaders: [mswLoader],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -59,5 +59,4 @@ if (process.env.NODE_ENV === "production") {
|
||||
});
|
||||
}
|
||||
|
||||
// Export the required hook for navigation instrumentation
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
|
||||
@@ -16,10 +16,6 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
output: "standalone",
|
||||
// TODO: Re-enable TypeScript checks once current issues are resolved
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
transpilePackages: ["geist"],
|
||||
};
|
||||
|
||||
@@ -54,8 +50,9 @@ export default isDevelopmentBuild
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/store",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
// No need to hide source maps from generated client bundles
|
||||
// since the source is public anyway :)
|
||||
hideSourceMaps: false,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:test": "NODE_ENV=test && next dev",
|
||||
"dev": "next dev --turbo",
|
||||
"dev:test": "NODE_ENV=test && next dev --turbo",
|
||||
"build": "SKIP_STORYBOOK_TESTS=true next build",
|
||||
"start": "next start",
|
||||
"start:standalone": "cd .next/standalone && node server.js",
|
||||
"lint": "next lint && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "playwright test",
|
||||
"test-ui": "playwright test --ui",
|
||||
"test": "next build --turbo && playwright test",
|
||||
"test-ui": "next build --turbo && playwright test --ui",
|
||||
"test:no-build": "playwright test",
|
||||
"gentests": "playwright codegen http://localhost:3000",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
@@ -46,7 +48,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@sentry/nextjs": "^9",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@supabase/supabase-js": "^2.49.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/jaro-winkler": "^0.2.4",
|
||||
"@xyflow/react": "12.6.4",
|
||||
@@ -60,14 +62,14 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"elliptic": "6.6.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.12.1",
|
||||
"framer-motion": "^12.16.0",
|
||||
"geist": "^1.4.2",
|
||||
"jaro-winkler": "^0.2.8",
|
||||
"launchdarkly-react-client-sdk": "^3.7.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.510.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.513.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "^14.2.26",
|
||||
"next": "^15.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"party-js": "^2.2.0",
|
||||
"react": "^18",
|
||||
@@ -84,7 +86,7 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.25.51"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^3.2.4",
|
||||
@@ -98,9 +100,9 @@
|
||||
"@storybook/nextjs": "^8.5.3",
|
||||
"@storybook/react": "^8.3.5",
|
||||
"@storybook/test": "^8.3.5",
|
||||
"@storybook/test-runner": "^0.21.0",
|
||||
"@storybook/test-runner": "^0.22.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^18",
|
||||
@@ -110,9 +112,9 @@
|
||||
"chromatic": "^11.25.2",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"eslint-plugin-storybook": "^0.11.2",
|
||||
"msw": "^2.7.0",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"eslint-plugin-storybook": "^0.12.0",
|
||||
"msw": "^2.9.0",
|
||||
"msw-storybook-addon": "^2.0.3",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.3.3",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: process.env.CI ? [["html"]] : [["list"], ["html"]],
|
||||
reporter: [["html"], ["line"]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
@@ -74,12 +74,12 @@ export default defineConfig({
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
/* Run your local server before starting the tests */
|
||||
webServer: {
|
||||
command: "pnpm run build && pnpm run start",
|
||||
command: "pnpm start",
|
||||
url: "http://localhost:3000/",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
timeout: 10 * 1000,
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
|
||||
966
autogpt_platform/frontend/pnpm-lock.yaml
generated
966
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,16 @@ import { Suspense } from "react";
|
||||
import type { SubmissionStatus } from "@/lib/autogpt-server-api/types";
|
||||
import { AdminAgentsDataTable } from "@/components/admin/marketplace/admin-agents-data-table";
|
||||
|
||||
type MarketplaceAdminPageSearchParams = {
|
||||
page?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
async function AdminMarketplaceDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
};
|
||||
searchParams: MarketplaceAdminPageSearchParams;
|
||||
}) {
|
||||
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
|
||||
const status = searchParams.status as SubmissionStatus | undefined;
|
||||
@@ -47,16 +49,12 @@ async function AdminMarketplaceDashboard({
|
||||
export default async function AdminMarketplacePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
};
|
||||
searchParams: Promise<MarketplaceAdminPageSearchParams>;
|
||||
}) {
|
||||
"use server";
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedAdminMarketplace = await withAdminAccess(
|
||||
AdminMarketplaceDashboard,
|
||||
);
|
||||
return <ProtectedAdminMarketplace searchParams={searchParams} />;
|
||||
return <ProtectedAdminMarketplace searchParams={await searchParams} />;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import type { CreditTransactionType } from "@/lib/autogpt-server-api";
|
||||
import { withRoleAccess } from "@/lib/withRoleAccess";
|
||||
import { Suspense } from "react";
|
||||
|
||||
type SpendingDashboardPageSearchParams = {
|
||||
page?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
function SpendingDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
};
|
||||
searchParams: SpendingDashboardPageSearchParams;
|
||||
}) {
|
||||
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
|
||||
const search = searchParams.search;
|
||||
@@ -45,14 +47,10 @@ function SpendingDashboard({
|
||||
export default async function SpendingDashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
};
|
||||
searchParams: Promise<SpendingDashboardPageSearchParams>;
|
||||
}) {
|
||||
"use server";
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedSpendingDashboard = await withAdminAccess(SpendingDashboard);
|
||||
return <ProtectedSpendingDashboard searchParams={searchParams} />;
|
||||
return <ProtectedSpendingDashboard searchParams={await searchParams} />;
|
||||
}
|
||||
|
||||
@@ -8,30 +8,6 @@ import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
|
||||
export async function logout() {
|
||||
return await Sentry.withServerActionInstrumentation(
|
||||
"logout",
|
||||
{},
|
||||
async () => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
if (!supabase) {
|
||||
redirect("/error");
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging out", error);
|
||||
return error.message;
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/login");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function shouldShowOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
return (
|
||||
@@ -59,23 +35,21 @@ export async function login(
|
||||
}
|
||||
|
||||
// We are sure that the values are of the correct type because zod validates the form
|
||||
const { data, error } = await supabase.auth.signInWithPassword(values);
|
||||
const { error } = await supabase.auth.signInWithPassword(values);
|
||||
|
||||
if (error) {
|
||||
console.error("Error logging in", error);
|
||||
console.error("Error logging in:", error);
|
||||
return error.message;
|
||||
}
|
||||
|
||||
await api.createUser();
|
||||
|
||||
// Don't onboard if disabled or already onboarded
|
||||
if (await shouldShowOnboarding()) {
|
||||
revalidatePath("/onboarding", "layout");
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
await supabase.auth.setSession(data.session);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/");
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import {
|
||||
AuthCard,
|
||||
@@ -80,6 +80,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
const error = await login(data, turnstile.token as string);
|
||||
await supabase?.auth.refreshSession();
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
setFeedback(error);
|
||||
@@ -89,7 +90,7 @@ export default function LoginPage() {
|
||||
}
|
||||
setFeedback(null);
|
||||
},
|
||||
[form, turnstile],
|
||||
[form, turnstile, supabase],
|
||||
);
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import BackendAPI, {
|
||||
CreatorsResponse,
|
||||
StoreAgentsResponse,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
|
||||
const EMPTY_AGENTS_RESPONSE: StoreAgentsResponse = {
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
const EMPTY_CREATORS_RESPONSE: CreatorsResponse = {
|
||||
creators: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export async function getMarketplaceData(): Promise<{
|
||||
featuredAgents: StoreAgentsResponse;
|
||||
topAgents: StoreAgentsResponse;
|
||||
featuredCreators: CreatorsResponse;
|
||||
}> {
|
||||
const api = new BackendAPI();
|
||||
|
||||
const [featuredAgents, topAgents, featuredCreators] = await Promise.all([
|
||||
api.getStoreAgents({ featured: true }).catch((error) => {
|
||||
console.error("Error fetching featured marketplace agents:", error);
|
||||
return EMPTY_AGENTS_RESPONSE;
|
||||
}),
|
||||
api.getStoreAgents({ sorted_by: "runs" }).catch((error) => {
|
||||
console.error("Error fetching top marketplace agents:", error);
|
||||
return EMPTY_AGENTS_RESPONSE;
|
||||
}),
|
||||
api
|
||||
.getStoreCreators({ featured: true, sorted_by: "num_agents" })
|
||||
.catch((error) => {
|
||||
console.error("Error fetching featured marketplace creators:", error);
|
||||
return EMPTY_CREATORS_RESPONSE;
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
featuredAgents,
|
||||
topAgents,
|
||||
featuredCreators,
|
||||
};
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import getServerUser from "@/lib/supabase/getServerUser";
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type MarketplaceAgentPageParams = { creator: string; slug: string };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
params: _params,
|
||||
}: {
|
||||
params: { creator: string; slug: string };
|
||||
params: Promise<MarketplaceAgentPageParams>;
|
||||
}): Promise<Metadata> {
|
||||
const api = new BackendAPI();
|
||||
const params = await _params;
|
||||
const agent = await api.getStoreAgent(params.creator, params.slug);
|
||||
|
||||
return {
|
||||
@@ -34,11 +37,12 @@ export async function generateMetadata({
|
||||
// }));
|
||||
// }
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
export default async function MarketplaceAgentPage({
|
||||
params: _params,
|
||||
}: {
|
||||
params: { creator: string; slug: string };
|
||||
params: Promise<MarketplaceAgentPageParams>;
|
||||
}) {
|
||||
const params = await _params;
|
||||
const creator_lower = params.creator.toLowerCase();
|
||||
const { user } = await getServerUser();
|
||||
const api = new BackendAPI();
|
||||
|
||||
@@ -9,12 +9,15 @@ import { Separator } from "@/components/ui/separator";
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type MarketplaceCreatorPageParams = { creator: string };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
params: _params,
|
||||
}: {
|
||||
params: { creator: string };
|
||||
params: Promise<MarketplaceCreatorPageParams>;
|
||||
}): Promise<Metadata> {
|
||||
const api = new BackendAPI();
|
||||
const params = await _params;
|
||||
const creator = await api.getStoreCreator(params.creator.toLowerCase());
|
||||
|
||||
return {
|
||||
@@ -32,11 +35,12 @@ export async function generateMetadata({
|
||||
// }
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
params: _params,
|
||||
}: {
|
||||
params: { creator: string };
|
||||
params: Promise<MarketplaceCreatorPageParams>;
|
||||
}) {
|
||||
const api = new BackendAPI();
|
||||
const params = await _params;
|
||||
|
||||
try {
|
||||
const creator = await api.getStoreCreator(params.creator);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { HeroSection } from "@/components/agptui/composite/HeroSection";
|
||||
import { FeaturedSection } from "@/components/agptui/composite/FeaturedSection";
|
||||
import {
|
||||
@@ -12,97 +12,12 @@ import {
|
||||
} from "@/components/agptui/composite/FeaturedCreators";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
StoreAgentsResponse,
|
||||
CreatorsResponse,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
|
||||
import { getMarketplaceData } from "./actions";
|
||||
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function getStoreData() {
|
||||
try {
|
||||
const api = new BackendAPI();
|
||||
|
||||
// Add error handling and default values
|
||||
let featuredAgents: StoreAgentsResponse = {
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
let topAgents: StoreAgentsResponse = {
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
let featuredCreators: CreatorsResponse = {
|
||||
creators: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
[featuredAgents, topAgents, featuredCreators] = await Promise.all([
|
||||
api.getStoreAgents({ featured: true }),
|
||||
api.getStoreAgents({ sorted_by: "runs" }),
|
||||
api.getStoreCreators({ featured: true, sorted_by: "num_agents" }),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error fetching store data:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
featuredAgents,
|
||||
topAgents,
|
||||
featuredCreators,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error in getStoreData:", error);
|
||||
return {
|
||||
featuredAgents: {
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
},
|
||||
topAgents: {
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
},
|
||||
featuredCreators: {
|
||||
creators: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// FIX: Correct metadata
|
||||
export const metadata: Metadata = {
|
||||
title: "Marketplace - AutoGPT Platform",
|
||||
@@ -147,9 +62,9 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Page({}: {}) {
|
||||
// Get data server-side
|
||||
const { featuredAgents, topAgents, featuredCreators } = await getStoreData();
|
||||
export default async function MarketplacePage(): Promise<React.ReactElement> {
|
||||
const { featuredAgents, topAgents, featuredCreators } =
|
||||
await getMarketplaceData();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { use, useCallback, useEffect, useState } from "react";
|
||||
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
|
||||
import { SearchBar } from "@/components/agptui/SearchBar";
|
||||
import { FeaturedCreators } from "@/components/agptui/composite/FeaturedCreators";
|
||||
@@ -9,15 +9,17 @@ import { SearchFilterChips } from "@/components/agptui/SearchFilterChips";
|
||||
import { SortDropdown } from "@/components/agptui/SortDropdown";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
export default function Page({
|
||||
type MarketplaceSearchPageSearchParams = { searchTerm?: string; sort?: string };
|
||||
|
||||
export default function MarketplaceSearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { searchTerm?: string; sort?: string };
|
||||
searchParams: Promise<MarketplaceSearchPageSearchParams>;
|
||||
}) {
|
||||
return (
|
||||
<SearchResults
|
||||
searchTerm={searchParams.searchTerm || ""}
|
||||
sort={searchParams.sort || "trending"}
|
||||
searchTerm={use(searchParams).searchTerm || ""}
|
||||
sort={use(searchParams).sort || "trending"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +30,7 @@ function SearchResults({
|
||||
}: {
|
||||
searchTerm: string;
|
||||
sort: string;
|
||||
}) {
|
||||
}): React.ReactElement {
|
||||
const [showAgents, setShowAgents] = useState(true);
|
||||
const [showCreators, setShowCreators] = useState(true);
|
||||
const [agents, setAgents] = useState<any[]>([]);
|
||||
@@ -80,40 +82,43 @@ function SearchResults({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (sortValue: string) => {
|
||||
let sortBy = "recent";
|
||||
if (sortValue === "runs") {
|
||||
sortBy = "runs";
|
||||
} else if (sortValue === "rating") {
|
||||
sortBy = "rating";
|
||||
}
|
||||
|
||||
const sortedAgents = [...agents].sort((a, b) => {
|
||||
if (sortBy === "runs") {
|
||||
return b.runs - a.runs;
|
||||
} else if (sortBy === "rating") {
|
||||
return b.rating - a.rating;
|
||||
} else {
|
||||
return (
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
const handleSortChange = useCallback(
|
||||
(sortValue: string) => {
|
||||
let sortBy = "recent";
|
||||
if (sortValue === "runs") {
|
||||
sortBy = "runs";
|
||||
} else if (sortValue === "rating") {
|
||||
sortBy = "rating";
|
||||
}
|
||||
});
|
||||
|
||||
const sortedCreators = [...creators].sort((a, b) => {
|
||||
if (sortBy === "runs") {
|
||||
return b.agent_runs - a.agent_runs;
|
||||
} else if (sortBy === "rating") {
|
||||
return b.agent_rating - a.agent_rating;
|
||||
} else {
|
||||
// Creators don't have updated_at, sort by number of agents as fallback
|
||||
return b.num_agents - a.num_agents;
|
||||
}
|
||||
});
|
||||
const sortedAgents = [...agents].sort((a, b) => {
|
||||
if (sortBy === "runs") {
|
||||
return b.runs - a.runs;
|
||||
} else if (sortBy === "rating") {
|
||||
return b.rating - a.rating;
|
||||
} else {
|
||||
return (
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setAgents(sortedAgents);
|
||||
setCreators(sortedCreators);
|
||||
};
|
||||
const sortedCreators = [...creators].sort((a, b) => {
|
||||
if (sortBy === "runs") {
|
||||
return b.agent_runs - a.agent_runs;
|
||||
} else if (sortBy === "rating") {
|
||||
return b.agent_rating - a.agent_rating;
|
||||
} else {
|
||||
// Creators don't have updated_at, sort by number of agents as fallback
|
||||
return b.num_agents - a.num_agents;
|
||||
}
|
||||
});
|
||||
|
||||
setAgents(sortedAgents);
|
||||
setCreators(sortedCreators);
|
||||
},
|
||||
[agents, creators],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
StoreSubmissionsResponse,
|
||||
StoreSubmissionRequest,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
export default function Page({}: {}) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { IconKey, IconUser } from "@/components/ui/icons";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
@@ -26,10 +26,10 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
|
||||
export default function PrivatePage() {
|
||||
export default function UserIntegrationsPage() {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
const router = useRouter();
|
||||
const providers = useContext(CredentialsProvidersContext);
|
||||
@@ -122,15 +122,15 @@ export default function PrivatePage() {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserLoading) return;
|
||||
if (!user || !supabase) router.push("/login");
|
||||
}, [isUserLoading, user, supabase, router]);
|
||||
|
||||
if (isUserLoading) {
|
||||
return <LoadingBox className="h-[80vh]" />;
|
||||
}
|
||||
|
||||
if (!user || !supabase) {
|
||||
router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
const allCredentials = providers
|
||||
? Object.values(providers).flatMap((provider) =>
|
||||
provider.savedCredentials
|
||||
|
||||
@@ -1,43 +1,28 @@
|
||||
import * as React from "react";
|
||||
import React from "react";
|
||||
import { Metadata } from "next/types";
|
||||
import { ProfileInfoForm } from "@/components/agptui/ProfileInfoForm";
|
||||
import { redirect } from "next/navigation";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { CreatorDetails } from "@/lib/autogpt-server-api/types";
|
||||
import { ProfileInfoForm } from "@/components/agptui/ProfileInfoForm";
|
||||
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function getProfileData(api: BackendAPI) {
|
||||
try {
|
||||
const profile = await api.getStoreProfile();
|
||||
return {
|
||||
profile,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching profile:", error);
|
||||
return {
|
||||
profile: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const metadata: Metadata = { title: "Profile - AutoGPT Platform" };
|
||||
|
||||
export default async function Page({}: {}) {
|
||||
export default async function UserProfilePage(): Promise<React.ReactElement> {
|
||||
const api = new BackendAPI();
|
||||
const { profile } = await getProfileData(api);
|
||||
const profile = await api.getStoreProfile().catch((error) => {
|
||||
console.error("Error fetching profile:", error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<p>Please log in to view your profile</p>
|
||||
</div>
|
||||
);
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<ProfileInfoForm profile={profile as CreatorDetails} />
|
||||
<ProfileInfoForm profile={profile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function sendResetEmail(email: string, turnstileToken: string) {
|
||||
{},
|
||||
async () => {
|
||||
const supabase = getServerSupabase();
|
||||
const headersList = headers();
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host");
|
||||
const protocol =
|
||||
process.env.NODE_ENV === "development" ? "http" : "https";
|
||||
@@ -38,8 +38,6 @@ export async function sendResetEmail(email: string, turnstileToken: string) {
|
||||
console.error("Error sending reset email", error);
|
||||
return error.message;
|
||||
}
|
||||
|
||||
redirect("/reset_password");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import LoadingBox from "@/components/ui/loading";
|
||||
import {
|
||||
AuthCard,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { Poppins } from "next/font/google";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { fonts } from "@/components/styles/fonts";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
@@ -11,12 +9,6 @@ import { Providers } from "@/app/providers";
|
||||
import TallyPopupSimple from "@/components/TallyPopup";
|
||||
import { GoogleAnalytics } from "@/components/analytics/google-analytics";
|
||||
|
||||
const poppins = Poppins({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-poppins",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AutoGPT Platform",
|
||||
description: "Your one stop shop to creating AI Agents",
|
||||
@@ -30,7 +22,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${poppins.variable} ${GeistSans.variable} ${GeistMono.variable}`}
|
||||
className={`${fonts.poppins.variable} ${fonts.sans.variable} ${fonts.mono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<GoogleAnalytics
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
|
||||
const ProfileDropdown = () => {
|
||||
const { supabase, user, isUserLoading } = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
if (isUserLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 rounded-full">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={user?.user_metadata["avatar_url"]}
|
||||
alt="User Avatar"
|
||||
/>
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
{user!.role === "admin" && (
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/dashboard")}>
|
||||
Admin Dashboard
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
supabase?.auth.signOut().then(() => router.replace("/login"))
|
||||
}
|
||||
>
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileDropdown;
|
||||
@@ -1,5 +1,5 @@
|
||||
// components/RoleBasedAccess.tsx
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import React from "react";
|
||||
|
||||
interface RoleBasedAccessProps {
|
||||
|
||||
@@ -51,9 +51,6 @@ export const Empty: Story = {
|
||||
description: "",
|
||||
avatar_url: "",
|
||||
links: [],
|
||||
top_categories: [],
|
||||
agent_rating: 0,
|
||||
agent_runs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -71,9 +68,6 @@ export const Filled: Story = {
|
||||
"twitter.com/oliviagrace",
|
||||
"github.com/ograce",
|
||||
],
|
||||
top_categories: ["Entertainment", "Blog", "Content creation"],
|
||||
agent_rating: 4.5,
|
||||
agent_runs: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,14 +7,14 @@ import Image from "next/image";
|
||||
|
||||
import { Button } from "./Button";
|
||||
import { IconPersonFill } from "@/components/ui/icons";
|
||||
import { CreatorDetails, ProfileDetails } from "@/lib/autogpt-server-api/types";
|
||||
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
|
||||
export const ProfileInfoForm = ({ profile }: { profile: ProfileDetails }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [profileData, setProfileData] = useState(profile);
|
||||
const [profileData, setProfileData] = useState<ProfileDetails>(profile);
|
||||
const { supabase } = useSupabase();
|
||||
const api = useBackendAPI();
|
||||
|
||||
@@ -31,10 +31,8 @@ export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
|
||||
};
|
||||
|
||||
if (!isSubmitting) {
|
||||
const returnedProfile = await api.updateStoreProfile(
|
||||
updatedProfile as ProfileDetails,
|
||||
);
|
||||
setProfileData(returnedProfile as CreatorDetails);
|
||||
const returnedProfile = await api.updateStoreProfile(updatedProfile);
|
||||
setProfileData(returnedProfile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating profile:", error);
|
||||
@@ -88,10 +86,8 @@ export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
|
||||
avatar_url: mediaUrl,
|
||||
};
|
||||
|
||||
const returnedProfile = await api.updateStoreProfile(
|
||||
updatedProfile as ProfileDetails,
|
||||
);
|
||||
setProfileData(returnedProfile as CreatorDetails);
|
||||
const returnedProfile = await api.updateStoreProfile(updatedProfile);
|
||||
setProfileData(returnedProfile);
|
||||
} catch (error) {
|
||||
console.error("Error uploading image:", error);
|
||||
}
|
||||
|
||||
@@ -37,12 +37,12 @@ interface ProfilePopoutMenuProps {
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ProfilePopoutMenu: React.FC<ProfilePopoutMenuProps> = ({
|
||||
export function ProfilePopoutMenu({
|
||||
userName,
|
||||
userEmail,
|
||||
avatarSrc,
|
||||
menuItemGroups,
|
||||
}) => {
|
||||
}: ProfilePopoutMenuProps) {
|
||||
const popupId = React.useId();
|
||||
|
||||
const getIcon = (icon: IconType) => {
|
||||
@@ -91,28 +91,28 @@ export const ProfilePopoutMenu: React.FC<ProfilePopoutMenuProps> = ({
|
||||
|
||||
<PopoverContent
|
||||
id={popupId}
|
||||
className="flex h-[380px] w-[300px] flex-col items-start justify-start gap-4 rounded-[26px] bg-zinc-400/70 p-6 shadow backdrop-blur-2xl dark:bg-zinc-800/70"
|
||||
className="flex flex-col items-start justify-start gap-4 rounded-[26px] bg-zinc-400/70 p-4 shadow backdrop-blur-2xl dark:bg-zinc-800/70"
|
||||
>
|
||||
{/* Header with avatar and user info */}
|
||||
<div className="inline-flex items-center justify-start gap-4 self-stretch">
|
||||
<div className="inline-flex items-center justify-start gap-1 self-stretch">
|
||||
<Avatar className="h-[60px] w-[60px]">
|
||||
<AvatarImage src={avatarSrc} alt="" aria-hidden="true" />
|
||||
<AvatarFallback aria-hidden="true">
|
||||
{userName?.charAt(0) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="relative h-[47px] w-[173px]">
|
||||
<div className="absolute left-0 top-0 font-sans text-base font-semibold leading-7 text-white dark:text-neutral-200">
|
||||
<div className="relative flex h-[47px] w-[173px] flex-col items-start justify-center gap-1">
|
||||
<div className="max-w-[10.5rem] truncate font-sans text-base font-semibold leading-none text-white dark:text-neutral-200">
|
||||
{userName}
|
||||
</div>
|
||||
<div className="absolute left-0 top-[23px] font-sans text-base font-normal leading-normal text-white dark:text-neutral-400">
|
||||
<div className="max-w-[10.5rem] truncate font-sans text-base font-normal leading-none text-white dark:text-neutral-400">
|
||||
{userEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="flex w-full flex-col items-start justify-start gap-1.5 rounded-[23px]">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-2 rounded-[23px]">
|
||||
{menuItemGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
@@ -180,4 +180,4 @@ export const ProfilePopoutMenu: React.FC<ProfilePopoutMenuProps> = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { logout } from "@/app/(platform)/login/actions";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { IconLogOut } from "@/components/ui/icons";
|
||||
|
||||
export const ProfilePopoutMenuLogoutButton = () => {
|
||||
const supabase = useSupabase();
|
||||
return (
|
||||
<div
|
||||
className="inline-flex w-full items-center justify-start gap-2.5"
|
||||
onClick={() => logout()}
|
||||
onClick={() => supabase.logOut()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.custom-node input:not([type="checkbox"]),
|
||||
.custom-node input:not([type="checkbox"]):not([type="file"]),
|
||||
.custom-node textarea,
|
||||
.custom-node select,
|
||||
.custom-node [data-id^="date-picker"],
|
||||
.custom-node [data-list-container],
|
||||
.custom-node [data-add-item],
|
||||
.custom-node [data-content-settings]. .array-item-container {
|
||||
.custom-node [data-content-settings] .array-item-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: calc(100% - 2.5rem);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import {
|
||||
APIKeyCredentials,
|
||||
CredentialsDeleteNeedConfirmationResponse,
|
||||
@@ -8,7 +10,6 @@ import {
|
||||
UserPasswordCredentials,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Get keys from CredentialsProviderName type
|
||||
const CREDENTIALS_PROVIDER_NAMES = Object.values(
|
||||
@@ -102,6 +103,7 @@ export default function CredentialsProvider({
|
||||
}) {
|
||||
const [providers, setProviders] =
|
||||
useState<CredentialsProvidersContextType | null>(null);
|
||||
const { isLoggedIn } = useSupabase();
|
||||
const api = useBackendAPI();
|
||||
|
||||
const addCredentials = useCallback(
|
||||
@@ -202,48 +204,50 @@ export default function CredentialsProvider({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.isAuthenticated().then((isAuthenticated) => {
|
||||
if (!isAuthenticated) return;
|
||||
if (!isLoggedIn) {
|
||||
if (isLoggedIn == false) setProviders(null);
|
||||
return;
|
||||
}
|
||||
|
||||
api.listCredentials().then((response) => {
|
||||
const credentialsByProvider = response.reduce(
|
||||
(acc, cred) => {
|
||||
if (!acc[cred.provider]) {
|
||||
acc[cred.provider] = [];
|
||||
}
|
||||
acc[cred.provider].push(cred);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CredentialsProviderName, CredentialsMetaResponse[]>,
|
||||
);
|
||||
api.listCredentials().then((response) => {
|
||||
const credentialsByProvider = response.reduce(
|
||||
(acc, cred) => {
|
||||
if (!acc[cred.provider]) {
|
||||
acc[cred.provider] = [];
|
||||
}
|
||||
acc[cred.provider].push(cred);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<CredentialsProviderName, CredentialsMetaResponse[]>,
|
||||
);
|
||||
|
||||
setProviders((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
CREDENTIALS_PROVIDER_NAMES.map((provider) => [
|
||||
setProviders((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(
|
||||
CREDENTIALS_PROVIDER_NAMES.map((provider) => [
|
||||
provider,
|
||||
{
|
||||
provider,
|
||||
{
|
||||
provider,
|
||||
providerName: providerDisplayNames[provider],
|
||||
savedCredentials: credentialsByProvider[provider] ?? [],
|
||||
oAuthCallback: (code: string, state_token: string) =>
|
||||
oAuthCallback(provider, code, state_token),
|
||||
createAPIKeyCredentials: (
|
||||
credentials: APIKeyCredentialsCreatable,
|
||||
) => createAPIKeyCredentials(provider, credentials),
|
||||
createUserPasswordCredentials: (
|
||||
credentials: UserPasswordCredentialsCreatable,
|
||||
) => createUserPasswordCredentials(provider, credentials),
|
||||
deleteCredentials: (id: string, force: boolean = false) =>
|
||||
deleteCredentials(provider, id, force),
|
||||
} satisfies CredentialsProviderData,
|
||||
]),
|
||||
),
|
||||
}));
|
||||
});
|
||||
providerName: providerDisplayNames[provider],
|
||||
savedCredentials: credentialsByProvider[provider] ?? [],
|
||||
oAuthCallback: (code: string, state_token: string) =>
|
||||
oAuthCallback(provider, code, state_token),
|
||||
createAPIKeyCredentials: (
|
||||
credentials: APIKeyCredentialsCreatable,
|
||||
) => createAPIKeyCredentials(provider, credentials),
|
||||
createUserPasswordCredentials: (
|
||||
credentials: UserPasswordCredentialsCreatable,
|
||||
) => createUserPasswordCredentials(provider, credentials),
|
||||
deleteCredentials: (id: string, force: boolean = false) =>
|
||||
deleteCredentials(provider, id, force),
|
||||
} satisfies CredentialsProviderData,
|
||||
]),
|
||||
),
|
||||
}));
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
isLoggedIn,
|
||||
createAPIKeyCredentials,
|
||||
createUserPasswordCredentials,
|
||||
deleteCredentials,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
import useSupabase from "@/lib/supabase/useSupabase";
|
||||
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
30
autogpt_platform/frontend/src/components/styles/fonts.css
Normal file
30
autogpt_platform/frontend/src/components/styles/fonts.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Google Fonts - Poppins (weights: 400, 500, 600, 700) */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap");
|
||||
|
||||
/* Local Geist Fonts from node_modules */
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2")
|
||||
format("woff2-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "GeistMono";
|
||||
src: url("../../../node_modules/geist/dist/fonts/geist-mono/GeistMono-Variable.woff2")
|
||||
format("woff2-variations");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* CSS Variables matching config from fonts.ts */
|
||||
:root {
|
||||
--font-poppins: "Poppins", sans-serif;
|
||||
--font-geist-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-geist-mono:
|
||||
"GeistMono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
|
||||
Consolas, monospace;
|
||||
}
|
||||
15
autogpt_platform/frontend/src/components/styles/fonts.ts
Normal file
15
autogpt_platform/frontend/src/components/styles/fonts.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Poppins } from "next/font/google";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
|
||||
const poppins = Poppins({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"] as const,
|
||||
variable: "--font-poppins",
|
||||
});
|
||||
|
||||
export const fonts = {
|
||||
poppins,
|
||||
sans: GeistSans,
|
||||
mono: GeistMono,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export default function useSupabase() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isUserLoading, setIsUserLoading] = useState(true);
|
||||
|
||||
const supabase = useMemo(() => {
|
||||
try {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating Supabase client", error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) {
|
||||
setIsUserLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
const response = await supabase.auth.getUser();
|
||||
|
||||
if (response.error) {
|
||||
// Display error only if it's not about missing auth session (user is not logged in)
|
||||
if (response.error.message !== "Auth session missing!") {
|
||||
console.error("Error fetching user", response.error);
|
||||
}
|
||||
setUser(null);
|
||||
} else {
|
||||
setUser(response.data.user);
|
||||
}
|
||||
setIsUserLoading(false);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [supabase]);
|
||||
|
||||
return { supabase, user, isUserLoading };
|
||||
}
|
||||
@@ -47,10 +47,13 @@ export function useTurnstile({
|
||||
useEffect(() => {
|
||||
const behaveAs = getBehaveAs();
|
||||
const hasTurnstileKey = !!TURNSTILE_SITE_KEY;
|
||||
const turnstileDisabled = process.env.NEXT_PUBLIC_DISABLE_TURNSTILE === "true";
|
||||
|
||||
setShouldRender(behaveAs === BehaveAs.CLOUD && hasTurnstileKey);
|
||||
// Only render Turnstile in cloud environment if not explicitly disabled
|
||||
setShouldRender(behaveAs === BehaveAs.CLOUD && hasTurnstileKey && !turnstileDisabled);
|
||||
|
||||
if (behaveAs !== BehaveAs.CLOUD || !hasTurnstileKey) {
|
||||
// Skip verification if disabled, in local development, or no key
|
||||
if (turnstileDisabled || behaveAs !== BehaveAs.CLOUD || !hasTurnstileKey) {
|
||||
setVerified(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -91,6 +91,7 @@ export default class BackendAPI {
|
||||
? createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{ isSingleton: true },
|
||||
)
|
||||
: getServerSupabase();
|
||||
}
|
||||
@@ -98,9 +99,9 @@ export default class BackendAPI {
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
if (!this.supabaseClient) return false;
|
||||
const {
|
||||
data: { user },
|
||||
} = await this.supabaseClient?.auth.getUser();
|
||||
return user != null;
|
||||
data: { session },
|
||||
} = await this.supabaseClient.auth.getSession();
|
||||
return session != null;
|
||||
}
|
||||
|
||||
createUser(): Promise<User> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { UnsafeUnwrappedCookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
|
||||
export default function getServerSupabase() {
|
||||
// Need require here, so Next.js doesn't complain about importing this on client side
|
||||
const { cookies } = require("next/headers");
|
||||
const cookieStore = cookies();
|
||||
const cookieStore = cookies() as UnsafeUnwrappedCookies;
|
||||
|
||||
try {
|
||||
const supabase = createServerClient(
|
||||
|
||||
65
autogpt_platform/frontend/src/lib/supabase/useSupabase.ts
Normal file
65
autogpt_platform/frontend/src/lib/supabase/useSupabase.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { SignOut, User } from "@supabase/supabase-js";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function useSupabase() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isUserLoading, setIsUserLoading] = useState(true);
|
||||
|
||||
const supabase = useMemo(() => {
|
||||
try {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{ isSingleton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating Supabase client", error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabase) {
|
||||
setIsUserLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync up the current state and listen for changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
setIsUserLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [supabase]);
|
||||
|
||||
const logOut = useCallback(
|
||||
async (options?: SignOut) => {
|
||||
if (!supabase) return;
|
||||
|
||||
const { error } = await supabase.auth.signOut({
|
||||
scope: options?.scope ?? "local",
|
||||
});
|
||||
if (error) console.error("Error logging out:", error);
|
||||
|
||||
router.push("/login");
|
||||
},
|
||||
[router, supabase],
|
||||
);
|
||||
|
||||
if (!supabase || isUserLoading) {
|
||||
return { supabase, user: null, isLoggedIn: null, isUserLoading, logOut };
|
||||
}
|
||||
if (!user) {
|
||||
return { supabase, user, isLoggedIn: false, isUserLoading, logOut };
|
||||
}
|
||||
return { supabase, user, isLoggedIn: true, isUserLoading, logOut };
|
||||
}
|
||||
@@ -7,6 +7,11 @@ export async function verifyTurnstileToken(
|
||||
token: string,
|
||||
action?: string,
|
||||
): Promise<boolean> {
|
||||
// Skip verification if explicitly disabled via environment variable
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_TURNSTILE === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip verification in local development
|
||||
const behaveAs = getBehaveAs();
|
||||
if (behaveAs !== BehaveAs.CLOUD) {
|
||||
|
||||
@@ -4,7 +4,10 @@ export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
console.log("Attempting login with:", { email, password }); // Debug log
|
||||
console.log(`ℹ️ Attempting login on ${this.page.url()} with`, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Fill email
|
||||
const emailInput = this.page.getByPlaceholder("m@example.com");
|
||||
@@ -33,23 +36,35 @@ export class LoginPage {
|
||||
});
|
||||
await loginButton.waitFor({ state: "visible" });
|
||||
|
||||
// Start waiting for navigation before clicking
|
||||
const navigationPromise = Promise.race([
|
||||
this.page.waitForURL("/", { timeout: 10_000 }), // Wait for home page
|
||||
this.page.waitForURL("/marketplace", { timeout: 10_000 }), // Wait for home page
|
||||
this.page.waitForURL("/onboarding/**", { timeout: 10_000 }), // Wait for onboarding page
|
||||
]);
|
||||
// Attach navigation logger for debug purposes
|
||||
this.page.on("load", (page) => console.log(`ℹ️ Now at URL: ${page.url()}`));
|
||||
|
||||
console.log("About to click login button"); // Debug log
|
||||
// Start waiting for navigation before clicking
|
||||
const leaveLoginPage = this.page
|
||||
.waitForURL(
|
||||
(url) => /^\/(marketplace|onboarding(\/.*)?)?$/.test(url.pathname),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch((reason) => {
|
||||
console.error(
|
||||
`🚨 Navigation away from /login timed out (current URL: ${this.page.url()}):`,
|
||||
reason,
|
||||
);
|
||||
throw reason;
|
||||
});
|
||||
|
||||
console.log(`🖱️ Clicking login button...`);
|
||||
await loginButton.click();
|
||||
|
||||
console.log("Waiting for navigation"); // Debug log
|
||||
await navigationPromise;
|
||||
console.log("⏳ Waiting for navigation away from /login ...");
|
||||
await leaveLoginPage;
|
||||
console.log(`⌛ Post-login redirected to ${this.page.url()}`);
|
||||
|
||||
await this.page.goto("/marketplace");
|
||||
|
||||
console.log("Navigation complete, waiting for network idle"); // Debug log
|
||||
await new Promise((resolve) => setTimeout(resolve, 200)); // allow time for client-side redirect
|
||||
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
||||
console.log("Login process complete"); // Debug log
|
||||
|
||||
console.log("➡️ Navigating to /marketplace ...");
|
||||
await this.page.goto("/marketplace", { timeout: 10_000 });
|
||||
console.log("✅ Login process complete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"target": "ES2022",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
devServer: {
|
||||
proxy: {
|
||||
"/graphs": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user