mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-19 20:18:22 -05:00
Compare commits
25 Commits
autogpt-pl
...
make-old-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0784f8f6b | ||
|
|
3040f39136 | ||
|
|
515504c604 | ||
|
|
18edeaeaf4 | ||
|
|
44182aff9c | ||
|
|
864c5a7846 | ||
|
|
699fffb1a8 | ||
|
|
f0641c2d26 | ||
|
|
94b6f74c95 | ||
|
|
46aabab3ea | ||
|
|
0a65df5102 | ||
|
|
6fbd208fe3 | ||
|
|
8fc174ca87 | ||
|
|
cacc89790f | ||
|
|
b9113bee02 | ||
|
|
3f65da03e7 | ||
|
|
9e96d11b2d | ||
|
|
4c264b7ae9 | ||
|
|
0adbc0bd05 | ||
|
|
8f3291bc92 | ||
|
|
7a20de880d | ||
|
|
ef8a6d2528 | ||
|
|
fd66be2aaa | ||
|
|
ae2cc97dc4 | ||
|
|
ea521eed26 |
2
.github/workflows/classic-autogpt-ci.yml
vendored
2
.github/workflows/classic-autogpt-ci.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12", "3.13", "3.14"]
|
||||
platform-os: [ubuntu, macos, macos-arm64, windows]
|
||||
runs-on: ${{ matrix.platform-os != 'macos-arm64' && format('{0}-latest', matrix.platform-os) || 'macos-14' }}
|
||||
|
||||
|
||||
13
.github/workflows/classic-autogpts-ci.yml
vendored
13
.github/workflows/classic-autogpts-ci.yml
vendored
@@ -11,9 +11,6 @@ on:
|
||||
- 'classic/original_autogpt/**'
|
||||
- 'classic/forge/**'
|
||||
- 'classic/benchmark/**'
|
||||
- 'classic/run'
|
||||
- 'classic/cli.py'
|
||||
- 'classic/setup.py'
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
branches: [ master, dev, release-* ]
|
||||
@@ -22,9 +19,6 @@ on:
|
||||
- 'classic/original_autogpt/**'
|
||||
- 'classic/forge/**'
|
||||
- 'classic/benchmark/**'
|
||||
- 'classic/run'
|
||||
- 'classic/cli.py'
|
||||
- 'classic/setup.py'
|
||||
- '!**/*.md'
|
||||
|
||||
defaults:
|
||||
@@ -59,10 +53,15 @@ jobs:
|
||||
run: |
|
||||
curl -sSL https://install.python-poetry.org | python -
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./classic/${{ matrix.agent-name }}/
|
||||
run: poetry install
|
||||
|
||||
- name: Run regression tests
|
||||
run: |
|
||||
./run agent start ${{ matrix.agent-name }}
|
||||
cd ${{ matrix.agent-name }}
|
||||
poetry run serve &
|
||||
sleep 10 # Wait for server to start
|
||||
poetry run agbenchmark --mock --test=BasicRetrieval --test=Battleship --test=WebArenaTask_0
|
||||
poetry run agbenchmark --test=WriteFile
|
||||
env:
|
||||
|
||||
11
.github/workflows/classic-benchmark-ci.yml
vendored
11
.github/workflows/classic-benchmark-ci.yml
vendored
@@ -23,7 +23,7 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
min-python-version: '3.10'
|
||||
min-python-version: '3.12'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12", "3.13", "3.14"]
|
||||
platform-os: [ubuntu, macos, macos-arm64, windows]
|
||||
runs-on: ${{ matrix.platform-os != 'macos-arm64' && format('{0}-latest', matrix.platform-os) || 'macos-14' }}
|
||||
defaults:
|
||||
@@ -128,11 +128,16 @@ jobs:
|
||||
run: |
|
||||
curl -sSL https://install.python-poetry.org | python -
|
||||
|
||||
- name: Install agent dependencies
|
||||
working-directory: classic/${{ matrix.agent-name }}
|
||||
run: poetry install
|
||||
|
||||
- name: Run regression tests
|
||||
working-directory: classic
|
||||
run: |
|
||||
./run agent start ${{ matrix.agent-name }}
|
||||
cd ${{ matrix.agent-name }}
|
||||
poetry run python -m forge &
|
||||
sleep 10 # Wait for server to start
|
||||
|
||||
set +e # Ignore non-zero exit codes and continue execution
|
||||
echo "Running the following command: poetry run agbenchmark --maintain --mock"
|
||||
|
||||
2
.github/workflows/classic-forge-ci.yml
vendored
2
.github/workflows/classic-forge-ci.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.12", "3.13", "3.14"]
|
||||
platform-os: [ubuntu, macos, macos-arm64, windows]
|
||||
runs-on: ${{ matrix.platform-os != 'macos-arm64' && format('{0}-latest', matrix.platform-os) || 'macos-14' }}
|
||||
|
||||
|
||||
60
.github/workflows/classic-frontend-ci.yml
vendored
60
.github/workflows/classic-frontend-ci.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Classic - Frontend CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
- 'ci-test*' # This will match any branch that starts with "ci-test"
|
||||
paths:
|
||||
- 'classic/frontend/**'
|
||||
- '.github/workflows/classic-frontend-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'classic/frontend/**'
|
||||
- '.github/workflows/classic-frontend-ci.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUILD_BRANCH: ${{ format('classic-frontend-build/{0}', github.ref_name) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.13.2'
|
||||
|
||||
- name: Build Flutter to Web
|
||||
run: |
|
||||
cd classic/frontend
|
||||
flutter build web --base-href /app/
|
||||
|
||||
# - name: Commit and Push to ${{ env.BUILD_BRANCH }}
|
||||
# if: github.event_name == 'push'
|
||||
# run: |
|
||||
# git config --local user.email "action@github.com"
|
||||
# git config --local user.name "GitHub Action"
|
||||
# git add classic/frontend/build/web
|
||||
# git checkout -B ${{ env.BUILD_BRANCH }}
|
||||
# git commit -m "Update frontend build to ${GITHUB_SHA:0:7}" -a
|
||||
# git push -f origin ${{ env.BUILD_BRANCH }}
|
||||
|
||||
- name: Create PR ${{ env.BUILD_BRANCH }} -> ${{ github.ref_name }}
|
||||
if: github.event_name == 'push'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
add-paths: classic/frontend/build/web
|
||||
base: ${{ github.ref_name }}
|
||||
branch: ${{ env.BUILD_BRANCH }}
|
||||
delete-branch: true
|
||||
title: "Update frontend build in `${{ github.ref_name }}`"
|
||||
body: "This PR updates the frontend build based on commit ${{ github.sha }}."
|
||||
commit-message: "Update frontend build based on commit ${{ github.sha }}"
|
||||
4
.github/workflows/classic-python-checks.yml
vendored
4
.github/workflows/classic-python-checks.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: get-changed-parts
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
min-python-version: "3.10"
|
||||
min-python-version: "3.12"
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
needs: get-changed-parts
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
min-python-version: "3.10"
|
||||
min-python-version: "3.12"
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
classic/original_autogpt/keys.py
|
||||
classic/original_autogpt/*.json
|
||||
auto_gpt_workspace/*
|
||||
.autogpt/
|
||||
*.mpeg
|
||||
.env
|
||||
# Root .env files
|
||||
@@ -177,5 +178,5 @@ autogpt_platform/backend/settings.py
|
||||
|
||||
*.ign.*
|
||||
.test-contents
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
/autogpt_platform/backend/logs
|
||||
|
||||
@@ -39,7 +39,7 @@ import backend.data.user
|
||||
import backend.integrations.webhooks.utils
|
||||
import backend.util.service
|
||||
import backend.util.settings
|
||||
from backend.blocks.llm import DEFAULT_LLM_MODEL
|
||||
from backend.blocks.llm import LlmModel
|
||||
from backend.data.model import Credentials
|
||||
from backend.integrations.providers import ProviderName
|
||||
from backend.monitoring.instrumentation import instrument_fastapi
|
||||
@@ -113,7 +113,7 @@ async def lifespan_context(app: fastapi.FastAPI):
|
||||
|
||||
await backend.data.user.migrate_and_encrypt_user_integrations()
|
||||
await backend.data.graph.fix_llm_provider_credentials()
|
||||
await backend.data.graph.migrate_llm_models(DEFAULT_LLM_MODEL)
|
||||
await backend.data.graph.migrate_llm_models(LlmModel.GPT4O)
|
||||
await backend.integrations.webhooks.utils.migrate_legacy_triggered_graphs()
|
||||
|
||||
with launch_darkly_context():
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from backend.blocks.llm import (
|
||||
DEFAULT_LLM_MODEL,
|
||||
TEST_CREDENTIALS,
|
||||
TEST_CREDENTIALS_INPUT,
|
||||
AIBlockBase,
|
||||
@@ -50,7 +49,7 @@ class AIConditionBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
default=LlmModel.GPT4O,
|
||||
description="The language model to use for evaluating the condition.",
|
||||
advanced=False,
|
||||
)
|
||||
@@ -82,7 +81,7 @@ class AIConditionBlock(AIBlockBase):
|
||||
"condition": "the input is an email address",
|
||||
"yes_value": "Valid email",
|
||||
"no_value": "Not an email",
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"model": LlmModel.GPT4O,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
},
|
||||
test_credentials=TEST_CREDENTIALS,
|
||||
|
||||
@@ -182,10 +182,13 @@ class DataForSeoRelatedKeywordsBlock(Block):
|
||||
if results and len(results) > 0:
|
||||
# results is a list, get the first element
|
||||
first_result = results[0] if isinstance(results, list) else results
|
||||
# Handle missing key, null value, or valid list value
|
||||
if isinstance(first_result, dict):
|
||||
items = first_result.get("items") or []
|
||||
else:
|
||||
items = (
|
||||
first_result.get("items", [])
|
||||
if isinstance(first_result, dict)
|
||||
else []
|
||||
)
|
||||
# Ensure items is never None
|
||||
if items is None:
|
||||
items = []
|
||||
for item in items:
|
||||
# Extract keyword_data from the item
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,9 +92,8 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
||||
O1 = "o1"
|
||||
O1_MINI = "o1-mini"
|
||||
# GPT-5 models
|
||||
GPT5_2 = "gpt-5.2-2025-12-11"
|
||||
GPT5_1 = "gpt-5.1-2025-11-13"
|
||||
GPT5 = "gpt-5-2025-08-07"
|
||||
GPT5_1 = "gpt-5.1-2025-11-13"
|
||||
GPT5_MINI = "gpt-5-mini-2025-08-07"
|
||||
GPT5_NANO = "gpt-5-nano-2025-08-07"
|
||||
GPT5_CHAT = "gpt-5-chat-latest"
|
||||
@@ -195,9 +194,8 @@ MODEL_METADATA = {
|
||||
LlmModel.O1: ModelMetadata("openai", 200000, 100000), # o1-2024-12-17
|
||||
LlmModel.O1_MINI: ModelMetadata("openai", 128000, 65536), # o1-mini-2024-09-12
|
||||
# GPT-5 models
|
||||
LlmModel.GPT5_2: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_1: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_1: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_MINI: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_NANO: ModelMetadata("openai", 400000, 128000),
|
||||
LlmModel.GPT5_CHAT: ModelMetadata("openai", 400000, 16384),
|
||||
@@ -305,8 +303,6 @@ MODEL_METADATA = {
|
||||
LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000),
|
||||
}
|
||||
|
||||
DEFAULT_LLM_MODEL = LlmModel.GPT5_2
|
||||
|
||||
for model in LlmModel:
|
||||
if model not in MODEL_METADATA:
|
||||
raise ValueError(f"Missing MODEL_METADATA metadata for model: {model}")
|
||||
@@ -794,7 +790,7 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
default=LlmModel.GPT4O,
|
||||
description="The language model to use for answering the prompt.",
|
||||
advanced=False,
|
||||
)
|
||||
@@ -859,7 +855,7 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
|
||||
input_schema=AIStructuredResponseGeneratorBlock.Input,
|
||||
output_schema=AIStructuredResponseGeneratorBlock.Output,
|
||||
test_input={
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"model": LlmModel.GPT4O,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
"expected_format": {
|
||||
"key1": "value1",
|
||||
@@ -1225,7 +1221,7 @@ class AITextGeneratorBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
default=LlmModel.GPT4O,
|
||||
description="The language model to use for answering the prompt.",
|
||||
advanced=False,
|
||||
)
|
||||
@@ -1321,7 +1317,7 @@ class AITextSummarizerBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
default=LlmModel.GPT4O,
|
||||
description="The language model to use for summarizing the text.",
|
||||
)
|
||||
focus: str = SchemaField(
|
||||
@@ -1538,7 +1534,7 @@ class AIConversationBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
default=LlmModel.GPT4O,
|
||||
description="The language model to use for the conversation.",
|
||||
)
|
||||
credentials: AICredentials = AICredentialsField()
|
||||
@@ -1576,7 +1572,7 @@ class AIConversationBlock(AIBlockBase):
|
||||
},
|
||||
{"role": "user", "content": "Where was it played?"},
|
||||
],
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"model": LlmModel.GPT4O,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
},
|
||||
test_credentials=TEST_CREDENTIALS,
|
||||
@@ -1639,7 +1635,7 @@ class AIListGeneratorBlock(AIBlockBase):
|
||||
)
|
||||
model: LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=DEFAULT_LLM_MODEL,
|
||||
default=LlmModel.GPT4O,
|
||||
description="The language model to use for generating the list.",
|
||||
advanced=True,
|
||||
)
|
||||
@@ -1696,7 +1692,7 @@ class AIListGeneratorBlock(AIBlockBase):
|
||||
"drawing explorers to uncover its mysteries. Each planet showcases the limitless possibilities of "
|
||||
"fictional worlds."
|
||||
),
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"model": LlmModel.GPT4O,
|
||||
"credentials": TEST_CREDENTIALS_INPUT,
|
||||
"max_retries": 3,
|
||||
"force_json_output": False,
|
||||
|
||||
@@ -226,7 +226,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
)
|
||||
model: llm.LlmModel = SchemaField(
|
||||
title="LLM Model",
|
||||
default=llm.DEFAULT_LLM_MODEL,
|
||||
default=llm.LlmModel.GPT4O,
|
||||
description="The language model to use for answering the prompt.",
|
||||
advanced=False,
|
||||
)
|
||||
|
||||
@@ -196,15 +196,6 @@ class TestXMLParserBlockSecurity:
|
||||
async for _ in block.run(XMLParserBlock.Input(input_xml=large_xml)):
|
||||
pass
|
||||
|
||||
async def test_rejects_text_outside_root(self):
|
||||
"""Ensure parser surfaces readable errors for invalid root text."""
|
||||
block = XMLParserBlock()
|
||||
invalid_xml = "<root><child>value</child></root> trailing"
|
||||
|
||||
with pytest.raises(ValueError, match="text outside the root element"):
|
||||
async for _ in block.run(XMLParserBlock.Input(input_xml=invalid_xml)):
|
||||
pass
|
||||
|
||||
|
||||
class TestStoreMediaFileSecurity:
|
||||
"""Test file storage security limits."""
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestLLMStatsTracking:
|
||||
|
||||
response = await llm.llm_call(
|
||||
credentials=llm.TEST_CREDENTIALS,
|
||||
llm_model=llm.DEFAULT_LLM_MODEL,
|
||||
llm_model=llm.LlmModel.GPT4O,
|
||||
prompt=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
)
|
||||
@@ -65,7 +65,7 @@ class TestLLMStatsTracking:
|
||||
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
|
||||
prompt="Test prompt",
|
||||
expected_format={"key1": "desc1", "key2": "desc2"},
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore # type: ignore
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestLLMStatsTracking:
|
||||
# Run the block
|
||||
input_data = llm.AITextGeneratorBlock.Input(
|
||||
prompt="Generate text",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ class TestLLMStatsTracking:
|
||||
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
|
||||
prompt="Test prompt",
|
||||
expected_format={"key1": "desc1", "key2": "desc2"},
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
retry=2,
|
||||
)
|
||||
@@ -228,7 +228,7 @@ class TestLLMStatsTracking:
|
||||
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text=long_text,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_tokens=100, # Small chunks
|
||||
chunk_overlap=10,
|
||||
@@ -299,7 +299,7 @@ class TestLLMStatsTracking:
|
||||
# Test with very short text (should only need 1 chunk + 1 final summary)
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="This is a short text.",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_tokens=1000, # Large enough to avoid chunking
|
||||
)
|
||||
@@ -346,7 +346,7 @@ class TestLLMStatsTracking:
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
],
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -387,7 +387,7 @@ class TestLLMStatsTracking:
|
||||
# Run the block
|
||||
input_data = llm.AIListGeneratorBlock.Input(
|
||||
focus="test items",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_retries=3,
|
||||
)
|
||||
@@ -469,7 +469,7 @@ class TestLLMStatsTracking:
|
||||
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
|
||||
prompt="Test",
|
||||
expected_format={"result": "desc"},
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -513,7 +513,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
style=llm.SummaryStyle.BULLET_POINTS,
|
||||
)
|
||||
@@ -558,7 +558,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
style=llm.SummaryStyle.BULLET_POINTS,
|
||||
max_tokens=1000,
|
||||
@@ -593,7 +593,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
@@ -623,7 +623,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
max_tokens=1000,
|
||||
)
|
||||
@@ -654,7 +654,7 @@ class TestAITextSummarizerValidation:
|
||||
# Create input data
|
||||
input_data = llm.AITextSummarizerBlock.Input(
|
||||
text="Some text to summarize",
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ async def test_smart_decision_maker_tracks_llm_stats():
|
||||
# Create test input
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Should I continue with this task?",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -335,7 +335,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
retry=2, # Set retry to 2 for testing
|
||||
agent_mode_max_iterations=0,
|
||||
@@ -402,7 +402,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -462,7 +462,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -526,7 +526,7 @@ async def test_smart_decision_maker_parameter_validation():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Search for keywords",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -648,7 +648,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Test prompt",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
retry=2,
|
||||
agent_mode_max_iterations=0,
|
||||
@@ -722,7 +722,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
):
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Simple prompt",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -778,7 +778,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
||||
):
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Another test",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0,
|
||||
)
|
||||
@@ -931,7 +931,7 @@ async def test_smart_decision_maker_agent_mode():
|
||||
# Test agent mode with max_iterations = 3
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Complete this task using tools",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=3, # Enable agent mode with 3 max iterations
|
||||
)
|
||||
@@ -1020,7 +1020,7 @@ async def test_smart_decision_maker_traditional_mode_default():
|
||||
# Test default behavior (traditional mode)
|
||||
input_data = SmartDecisionMakerBlock.Input(
|
||||
prompt="Test prompt",
|
||||
model=llm_module.DEFAULT_LLM_MODEL,
|
||||
model=llm_module.LlmModel.GPT4O,
|
||||
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
|
||||
agent_mode_max_iterations=0, # Traditional mode
|
||||
)
|
||||
|
||||
@@ -373,7 +373,7 @@ async def test_output_yielding_with_dynamic_fields():
|
||||
input_data = block.input_schema(
|
||||
prompt="Create a user dictionary",
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
agent_mode_max_iterations=0, # Use traditional mode to test output yielding
|
||||
)
|
||||
|
||||
@@ -594,7 +594,7 @@ async def test_validation_errors_dont_pollute_conversation():
|
||||
input_data = block.input_schema(
|
||||
prompt="Test prompt",
|
||||
credentials=llm.TEST_CREDENTIALS_INPUT,
|
||||
model=llm.DEFAULT_LLM_MODEL,
|
||||
model=llm.LlmModel.GPT4O,
|
||||
retry=3, # Allow retries
|
||||
agent_mode_max_iterations=1,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from gravitasml.parser import Parser
|
||||
from gravitasml.token import Token, tokenize
|
||||
from gravitasml.token import tokenize
|
||||
|
||||
from backend.data.block import Block, BlockOutput, BlockSchemaInput, BlockSchemaOutput
|
||||
from backend.data.model import SchemaField
|
||||
@@ -25,38 +25,6 @@ class XMLParserBlock(Block):
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_tokens(tokens: list[Token]) -> None:
|
||||
"""Ensure the XML has a single root element and no stray text."""
|
||||
if not tokens:
|
||||
raise ValueError("XML input is empty.")
|
||||
|
||||
depth = 0
|
||||
root_seen = False
|
||||
|
||||
for token in tokens:
|
||||
if token.type == "TAG_OPEN":
|
||||
if depth == 0 and root_seen:
|
||||
raise ValueError("XML must have a single root element.")
|
||||
depth += 1
|
||||
if depth == 1:
|
||||
root_seen = True
|
||||
elif token.type == "TAG_CLOSE":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
raise SyntaxError("Unexpected closing tag in XML input.")
|
||||
elif token.type in {"TEXT", "ESCAPE"}:
|
||||
if depth == 0 and token.value:
|
||||
raise ValueError(
|
||||
"XML contains text outside the root element; "
|
||||
"wrap content in a single root tag."
|
||||
)
|
||||
|
||||
if depth != 0:
|
||||
raise SyntaxError("Unclosed tag detected in XML input.")
|
||||
if not root_seen:
|
||||
raise ValueError("XML must include a root element.")
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
# Security fix: Add size limits to prevent XML bomb attacks
|
||||
MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB limit for XML input
|
||||
@@ -67,9 +35,7 @@ class XMLParserBlock(Block):
|
||||
)
|
||||
|
||||
try:
|
||||
tokens = list(tokenize(input_data.input_xml))
|
||||
self._validate_tokens(tokens)
|
||||
|
||||
tokens = tokenize(input_data.input_xml)
|
||||
parser = Parser(tokens)
|
||||
parsed_result = parser.parse()
|
||||
yield "parsed_xml", parsed_result
|
||||
|
||||
@@ -111,8 +111,6 @@ class TranscribeYoutubeVideoBlock(Block):
|
||||
return parsed_url.path.split("/")[2]
|
||||
if parsed_url.path[:3] == "/v/":
|
||||
return parsed_url.path.split("/")[2]
|
||||
if parsed_url.path.startswith("/shorts/"):
|
||||
return parsed_url.path.split("/")[2]
|
||||
raise ValueError(f"Invalid YouTube URL: {url}")
|
||||
|
||||
def get_transcript(
|
||||
|
||||
@@ -59,13 +59,12 @@ from backend.integrations.credentials_store import (
|
||||
|
||||
MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.O3: 4,
|
||||
LlmModel.O3_MINI: 2,
|
||||
LlmModel.O1: 16,
|
||||
LlmModel.O3_MINI: 2, # $1.10 / $4.40
|
||||
LlmModel.O1: 16, # $15 / $60
|
||||
LlmModel.O1_MINI: 4,
|
||||
# GPT-5 models
|
||||
LlmModel.GPT5_2: 6,
|
||||
LlmModel.GPT5_1: 5,
|
||||
LlmModel.GPT5: 2,
|
||||
LlmModel.GPT5_1: 5,
|
||||
LlmModel.GPT5_MINI: 1,
|
||||
LlmModel.GPT5_NANO: 1,
|
||||
LlmModel.GPT5_CHAT: 5,
|
||||
@@ -88,7 +87,7 @@ MODEL_COST: dict[LlmModel, int] = {
|
||||
LlmModel.AIML_API_LLAMA3_3_70B: 1,
|
||||
LlmModel.AIML_API_META_LLAMA_3_1_70B: 1,
|
||||
LlmModel.AIML_API_LLAMA_3_2_3B: 1,
|
||||
LlmModel.LLAMA3_3_70B: 1,
|
||||
LlmModel.LLAMA3_3_70B: 1, # $0.59 / $0.79
|
||||
LlmModel.LLAMA3_1_8B: 1,
|
||||
LlmModel.OLLAMA_LLAMA3_3: 1,
|
||||
LlmModel.OLLAMA_LLAMA3_2: 1,
|
||||
|
||||
@@ -341,19 +341,6 @@ class UserCreditBase(ABC):
|
||||
|
||||
if result:
|
||||
# UserBalance is already updated by the CTE
|
||||
|
||||
# Clear insufficient funds notification flags when credits are added
|
||||
# so user can receive alerts again if they run out in the future.
|
||||
if transaction.amount > 0 and transaction.type in [
|
||||
CreditTransactionType.GRANT,
|
||||
CreditTransactionType.TOP_UP,
|
||||
]:
|
||||
from backend.executor.manager import (
|
||||
clear_insufficient_funds_notifications,
|
||||
)
|
||||
|
||||
await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
return result[0]["balance"]
|
||||
|
||||
async def _add_transaction(
|
||||
@@ -543,22 +530,6 @@ class UserCreditBase(ABC):
|
||||
if result:
|
||||
new_balance, tx_key = result[0]["balance"], result[0]["transactionKey"]
|
||||
# UserBalance is already updated by the CTE
|
||||
|
||||
# Clear insufficient funds notification flags when credits are added
|
||||
# so user can receive alerts again if they run out in the future.
|
||||
if (
|
||||
amount > 0
|
||||
and is_active
|
||||
and transaction_type
|
||||
in [CreditTransactionType.GRANT, CreditTransactionType.TOP_UP]
|
||||
):
|
||||
# Lazy import to avoid circular dependency with executor.manager
|
||||
from backend.executor.manager import (
|
||||
clear_insufficient_funds_notifications,
|
||||
)
|
||||
|
||||
await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
return new_balance, tx_key
|
||||
|
||||
# If no result, either user doesn't exist or insufficient balance
|
||||
|
||||
@@ -114,40 +114,6 @@ utilization_gauge = Gauge(
|
||||
"Ratio of active graph runs to max graph workers",
|
||||
)
|
||||
|
||||
# Redis key prefix for tracking insufficient funds Discord notifications.
|
||||
# We only send one notification per user per agent until they top up credits.
|
||||
INSUFFICIENT_FUNDS_NOTIFIED_PREFIX = "insufficient_funds_discord_notified"
|
||||
# TTL for the notification flag (30 days) - acts as a fallback cleanup
|
||||
INSUFFICIENT_FUNDS_NOTIFIED_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
async def clear_insufficient_funds_notifications(user_id: str) -> int:
|
||||
"""
|
||||
Clear all insufficient funds notification flags for a user.
|
||||
|
||||
This should be called when a user tops up their credits, allowing
|
||||
Discord notifications to be sent again if they run out of funds.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to clear notifications for.
|
||||
|
||||
Returns:
|
||||
The number of keys that were deleted.
|
||||
"""
|
||||
try:
|
||||
redis_client = await redis.get_redis_async()
|
||||
pattern = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
keys = [key async for key in redis_client.scan_iter(match=pattern)]
|
||||
if keys:
|
||||
return await redis_client.delete(*keys)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to clear insufficient funds notification flags for user "
|
||||
f"{user_id}: {e}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# Thread-local storage for ExecutionProcessor instances
|
||||
_tls = threading.local()
|
||||
@@ -1295,40 +1261,12 @@ class ExecutionProcessor:
|
||||
graph_id: str,
|
||||
e: InsufficientBalanceError,
|
||||
):
|
||||
# Check if we've already sent a notification for this user+agent combo.
|
||||
# We only send one notification per user per agent until they top up credits.
|
||||
redis_key = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id}"
|
||||
try:
|
||||
redis_client = redis.get_redis()
|
||||
# SET NX returns True only if the key was newly set (didn't exist)
|
||||
is_new_notification = redis_client.set(
|
||||
redis_key,
|
||||
"1",
|
||||
nx=True,
|
||||
ex=INSUFFICIENT_FUNDS_NOTIFIED_TTL_SECONDS,
|
||||
)
|
||||
if not is_new_notification:
|
||||
# Already notified for this user+agent, skip all notifications
|
||||
logger.debug(
|
||||
f"Skipping duplicate insufficient funds notification for "
|
||||
f"user={user_id}, graph={graph_id}"
|
||||
)
|
||||
return
|
||||
except Exception as redis_error:
|
||||
# If Redis fails, log and continue to send the notification
|
||||
# (better to occasionally duplicate than to never notify)
|
||||
logger.warning(
|
||||
f"Failed to check/set insufficient funds notification flag in Redis: "
|
||||
f"{redis_error}"
|
||||
)
|
||||
|
||||
shortfall = abs(e.amount) - e.balance
|
||||
metadata = db_client.get_graph_metadata(graph_id)
|
||||
base_url = (
|
||||
settings.config.frontend_base_url or settings.config.platform_base_url
|
||||
)
|
||||
|
||||
# Queue user email notification
|
||||
queue_notification(
|
||||
NotificationEventModel(
|
||||
user_id=user_id,
|
||||
@@ -1342,7 +1280,6 @@ class ExecutionProcessor:
|
||||
)
|
||||
)
|
||||
|
||||
# Send Discord system alert
|
||||
try:
|
||||
user_email = db_client.get_user_email_by_id(user_id)
|
||||
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from prisma.enums import NotificationType
|
||||
|
||||
from backend.data.notifications import ZeroBalanceData
|
||||
from backend.executor.manager import (
|
||||
INSUFFICIENT_FUNDS_NOTIFIED_PREFIX,
|
||||
ExecutionProcessor,
|
||||
clear_insufficient_funds_notifications,
|
||||
)
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
from backend.util.test import SpinTestServer
|
||||
|
||||
|
||||
async def async_iter(items):
|
||||
"""Helper to create an async iterator from a list."""
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_sends_discord_alert_first_time(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that the first insufficient funds notification sends a Discord alert."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id = "test-graph-456"
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72, # $0.72
|
||||
amount=-714, # Attempting to spend $7.14
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.executor.manager.queue_notification"
|
||||
) as mock_queue_notif, patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Setup mocks
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
# Mock Redis to simulate first-time notification (set returns True)
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
mock_redis_client.set.return_value = True # Key was newly set
|
||||
|
||||
# Create mock database client
|
||||
mock_db_client = MagicMock()
|
||||
mock_graph_metadata = MagicMock()
|
||||
mock_graph_metadata.name = "Test Agent"
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_metadata
|
||||
mock_db_client.get_user_email_by_id.return_value = "test@example.com"
|
||||
|
||||
# Test the insufficient funds handler
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify notification was queued
|
||||
mock_queue_notif.assert_called_once()
|
||||
notification_call = mock_queue_notif.call_args[0][0]
|
||||
assert notification_call.type == NotificationType.ZERO_BALANCE
|
||||
assert notification_call.user_id == user_id
|
||||
assert isinstance(notification_call.data, ZeroBalanceData)
|
||||
assert notification_call.data.current_balance == 72
|
||||
|
||||
# Verify Redis was checked with correct key pattern
|
||||
expected_key = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id}"
|
||||
mock_redis_client.set.assert_called_once()
|
||||
call_args = mock_redis_client.set.call_args
|
||||
assert call_args[0][0] == expected_key
|
||||
assert call_args[1]["nx"] is True
|
||||
|
||||
# Verify Discord alert was sent
|
||||
mock_client.discord_system_alert.assert_called_once()
|
||||
discord_message = mock_client.discord_system_alert.call_args[0][0]
|
||||
assert "Insufficient Funds Alert" in discord_message
|
||||
assert "test@example.com" in discord_message
|
||||
assert "Test Agent" in discord_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_skips_duplicate_notifications(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that duplicate insufficient funds notifications skip both email and Discord."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id = "test-graph-456"
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72,
|
||||
amount=-714,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.executor.manager.queue_notification"
|
||||
) as mock_queue_notif, patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Setup mocks
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
# Mock Redis to simulate duplicate notification (set returns False/None)
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
mock_redis_client.set.return_value = None # Key already existed
|
||||
|
||||
# Create mock database client
|
||||
mock_db_client = MagicMock()
|
||||
mock_db_client.get_graph_metadata.return_value = MagicMock(name="Test Agent")
|
||||
|
||||
# Test the insufficient funds handler
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify email notification was NOT queued (deduplication worked)
|
||||
mock_queue_notif.assert_not_called()
|
||||
|
||||
# Verify Discord alert was NOT sent (deduplication worked)
|
||||
mock_client.discord_system_alert.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_different_agents_get_separate_alerts(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that different agents for the same user get separate Discord alerts."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id_1 = "test-graph-111"
|
||||
graph_id_2 = "test-graph-222"
|
||||
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72,
|
||||
amount=-714,
|
||||
)
|
||||
|
||||
with patch("backend.executor.manager.queue_notification"), patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
# Both calls return True (first time for each agent)
|
||||
mock_redis_client.set.return_value = True
|
||||
|
||||
mock_db_client = MagicMock()
|
||||
mock_graph_metadata = MagicMock()
|
||||
mock_graph_metadata.name = "Test Agent"
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_metadata
|
||||
mock_db_client.get_user_email_by_id.return_value = "test@example.com"
|
||||
|
||||
# First agent notification
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id_1,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Second agent notification
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id_2,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify Discord alerts were sent for both agents
|
||||
assert mock_client.discord_system_alert.call_count == 2
|
||||
|
||||
# Verify Redis was called with different keys
|
||||
assert mock_redis_client.set.call_count == 2
|
||||
calls = mock_redis_client.set.call_args_list
|
||||
assert (
|
||||
calls[0][0][0]
|
||||
== f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id_1}"
|
||||
)
|
||||
assert (
|
||||
calls[1][0][0]
|
||||
== f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:{graph_id_2}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_clear_insufficient_funds_notifications(server: SpinTestServer):
|
||||
"""Test that clearing notifications removes all keys for a user."""
|
||||
|
||||
user_id = "test-user-123"
|
||||
|
||||
with patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
mock_redis_client = MagicMock()
|
||||
# get_redis_async is an async function, so we need AsyncMock for it
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
# Mock scan_iter to return some keys as an async iterator
|
||||
mock_keys = [
|
||||
f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-1",
|
||||
f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-2",
|
||||
f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-3",
|
||||
]
|
||||
mock_redis_client.scan_iter.return_value = async_iter(mock_keys)
|
||||
# delete is awaited, so use AsyncMock
|
||||
mock_redis_client.delete = AsyncMock(return_value=3)
|
||||
|
||||
# Clear notifications
|
||||
result = await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
# Verify correct pattern was used
|
||||
expected_pattern = f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
mock_redis_client.scan_iter.assert_called_once_with(match=expected_pattern)
|
||||
|
||||
# Verify delete was called with all keys
|
||||
mock_redis_client.delete.assert_called_once_with(*mock_keys)
|
||||
|
||||
# Verify return value
|
||||
assert result == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_clear_insufficient_funds_notifications_no_keys(server: SpinTestServer):
|
||||
"""Test clearing notifications when there are no keys to clear."""
|
||||
|
||||
user_id = "test-user-no-notifications"
|
||||
|
||||
with patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
mock_redis_client = MagicMock()
|
||||
# get_redis_async is an async function, so we need AsyncMock for it
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
# Mock scan_iter to return no keys as an async iterator
|
||||
mock_redis_client.scan_iter.return_value = async_iter([])
|
||||
|
||||
# Clear notifications
|
||||
result = await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
# Verify delete was not called
|
||||
mock_redis_client.delete.assert_not_called()
|
||||
|
||||
# Verify return value
|
||||
assert result == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_clear_insufficient_funds_notifications_handles_redis_error(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that clearing notifications handles Redis errors gracefully."""
|
||||
|
||||
user_id = "test-user-redis-error"
|
||||
|
||||
with patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
# Mock get_redis_async to raise an error
|
||||
mock_redis_module.get_redis_async = AsyncMock(
|
||||
side_effect=Exception("Redis connection failed")
|
||||
)
|
||||
|
||||
# Clear notifications should not raise, just return 0
|
||||
result = await clear_insufficient_funds_notifications(user_id)
|
||||
|
||||
# Verify it returned 0 (graceful failure)
|
||||
assert result == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_handle_insufficient_funds_continues_on_redis_error(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that both email and Discord notifications are still sent when Redis fails."""
|
||||
|
||||
execution_processor = ExecutionProcessor()
|
||||
user_id = "test-user-123"
|
||||
graph_id = "test-graph-456"
|
||||
error = InsufficientBalanceError(
|
||||
message="Insufficient balance",
|
||||
user_id=user_id,
|
||||
balance=72,
|
||||
amount=-714,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"backend.executor.manager.queue_notification"
|
||||
) as mock_queue_notif, patch(
|
||||
"backend.executor.manager.get_notification_manager_client"
|
||||
) as mock_get_client, patch(
|
||||
"backend.executor.manager.settings"
|
||||
) as mock_settings, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
mock_settings.config.frontend_base_url = "https://test.com"
|
||||
|
||||
# Mock Redis to raise an error
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis.return_value = mock_redis_client
|
||||
mock_redis_client.set.side_effect = Exception("Redis connection error")
|
||||
|
||||
mock_db_client = MagicMock()
|
||||
mock_graph_metadata = MagicMock()
|
||||
mock_graph_metadata.name = "Test Agent"
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_metadata
|
||||
mock_db_client.get_user_email_by_id.return_value = "test@example.com"
|
||||
|
||||
# Test the insufficient funds handler
|
||||
execution_processor._handle_insufficient_funds_notif(
|
||||
db_client=mock_db_client,
|
||||
user_id=user_id,
|
||||
graph_id=graph_id,
|
||||
e=error,
|
||||
)
|
||||
|
||||
# Verify email notification was still queued despite Redis error
|
||||
mock_queue_notif.assert_called_once()
|
||||
|
||||
# Verify Discord alert was still sent despite Redis error
|
||||
mock_client.discord_system_alert.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_clears_notifications_on_grant(server: SpinTestServer):
|
||||
"""Test that _add_transaction clears notification flags when adding GRANT credits."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-grant-clear"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 1000, "transactionKey": "test-tx-key"}]
|
||||
|
||||
# Mock async Redis for notification clearing
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
mock_redis_client.scan_iter.return_value = async_iter(
|
||||
[f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-1"]
|
||||
)
|
||||
mock_redis_client.delete = AsyncMock(return_value=1)
|
||||
|
||||
# Create a concrete instance
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with GRANT type (should clear notifications)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=500, # Positive amount
|
||||
transaction_type=CreditTransactionType.GRANT,
|
||||
is_active=True, # Active transaction
|
||||
)
|
||||
|
||||
# Verify notification clearing was called
|
||||
mock_redis_module.get_redis_async.assert_called_once()
|
||||
mock_redis_client.scan_iter.assert_called_once_with(
|
||||
match=f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_clears_notifications_on_top_up(server: SpinTestServer):
|
||||
"""Test that _add_transaction clears notification flags when adding TOP_UP credits."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-topup-clear"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 2000, "transactionKey": "test-tx-key-2"}]
|
||||
|
||||
# Mock async Redis for notification clearing
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
mock_redis_client.scan_iter.return_value = async_iter([])
|
||||
mock_redis_client.delete = AsyncMock(return_value=0)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with TOP_UP type (should clear notifications)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=1000, # Positive amount
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Verify notification clearing was attempted
|
||||
mock_redis_module.get_redis_async.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_skips_clearing_for_inactive_transaction(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that _add_transaction does NOT clear notifications for inactive transactions."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-inactive"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 500, "transactionKey": "test-tx-key-3"}]
|
||||
|
||||
# Mock async Redis
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with is_active=False (should NOT clear notifications)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=500,
|
||||
transaction_type=CreditTransactionType.TOP_UP,
|
||||
is_active=False, # Inactive - pending Stripe payment
|
||||
)
|
||||
|
||||
# Verify notification clearing was NOT called
|
||||
mock_redis_module.get_redis_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_add_transaction_skips_clearing_for_usage_transaction(
|
||||
server: SpinTestServer,
|
||||
):
|
||||
"""Test that _add_transaction does NOT clear notifications for USAGE transactions."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-usage"
|
||||
|
||||
with patch("backend.data.credit.query_raw_with_schema") as mock_query, patch(
|
||||
"backend.executor.manager.redis"
|
||||
) as mock_redis_module:
|
||||
|
||||
# Mock the query to return a successful transaction
|
||||
mock_query.return_value = [{"balance": 400, "transactionKey": "test-tx-key-4"}]
|
||||
|
||||
# Mock async Redis
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _add_transaction with USAGE type (spending, should NOT clear)
|
||||
await credit_model._add_transaction(
|
||||
user_id=user_id,
|
||||
amount=-100, # Negative - spending credits
|
||||
transaction_type=CreditTransactionType.USAGE,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Verify notification clearing was NOT called
|
||||
mock_redis_module.get_redis_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enable_transaction_clears_notifications(server: SpinTestServer):
|
||||
"""Test that _enable_transaction clears notification flags when enabling a TOP_UP."""
|
||||
from prisma.enums import CreditTransactionType
|
||||
|
||||
from backend.data.credit import UserCredit
|
||||
|
||||
user_id = "test-user-enable"
|
||||
|
||||
with patch("backend.data.credit.CreditTransaction") as mock_credit_tx, patch(
|
||||
"backend.data.credit.query_raw_with_schema"
|
||||
) as mock_query, patch("backend.executor.manager.redis") as mock_redis_module:
|
||||
|
||||
# Mock finding the pending transaction
|
||||
mock_transaction = MagicMock()
|
||||
mock_transaction.amount = 1000
|
||||
mock_transaction.type = CreditTransactionType.TOP_UP
|
||||
mock_credit_tx.prisma.return_value.find_first = AsyncMock(
|
||||
return_value=mock_transaction
|
||||
)
|
||||
|
||||
# Mock the query to return updated balance
|
||||
mock_query.return_value = [{"balance": 1500}]
|
||||
|
||||
# Mock async Redis for notification clearing
|
||||
mock_redis_client = MagicMock()
|
||||
mock_redis_module.get_redis_async = AsyncMock(return_value=mock_redis_client)
|
||||
mock_redis_client.scan_iter.return_value = async_iter(
|
||||
[f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:graph-1"]
|
||||
)
|
||||
mock_redis_client.delete = AsyncMock(return_value=1)
|
||||
|
||||
credit_model = UserCredit()
|
||||
|
||||
# Call _enable_transaction (simulates Stripe checkout completion)
|
||||
from backend.util.json import SafeJson
|
||||
|
||||
await credit_model._enable_transaction(
|
||||
transaction_key="cs_test_123",
|
||||
user_id=user_id,
|
||||
metadata=SafeJson({"payment": "completed"}),
|
||||
)
|
||||
|
||||
# Verify notification clearing was called
|
||||
mock_redis_module.get_redis_async.assert_called_once()
|
||||
mock_redis_client.scan_iter.assert_called_once_with(
|
||||
match=f"{INSUFFICIENT_FUNDS_NOTIFIED_PREFIX}:{user_id}:*"
|
||||
)
|
||||
24
autogpt_platform/backend/poetry.lock
generated
24
autogpt_platform/backend/poetry.lock
generated
@@ -1906,32 +1906,16 @@ httpx = {version = ">=0.26,<0.29", extras = ["http2"]}
|
||||
pydantic = ">=1.10,<3"
|
||||
pyjwt = ">=2.10.1,<3.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "gravitas-md2gdocs"
|
||||
version = "0.1.0"
|
||||
description = "Convert Markdown to Google Docs API requests"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gravitas_md2gdocs-0.1.0-py3-none-any.whl", hash = "sha256:0cb0627779fdd65c1604818af4142eea1b25d055060183363de1bae4d9e46508"},
|
||||
{file = "gravitas_md2gdocs-0.1.0.tar.gz", hash = "sha256:bb3122fe9fa35c528f3f00b785d3f1398d350082d5d03f60f56c895bdcc68033"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["google-auth-oauthlib (>=1.0.0)", "pytest (>=7.0.0)", "pytest-cov (>=4.0.0)", "python-dotenv (>=1.0.0)", "ruff (>=0.1.0)"]
|
||||
google = ["google-api-python-client (>=2.0.0)", "google-auth (>=2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "gravitasml"
|
||||
version = "0.1.4"
|
||||
version = "0.1.3"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gravitasml-0.1.4-py3-none-any.whl", hash = "sha256:671a18b11d3d8a0e270c6a80c72cd058458b18d5ef7560d00010e962ab1bca74"},
|
||||
{file = "gravitasml-0.1.4.tar.gz", hash = "sha256:35d0d9fec7431817482d53d9c976e375557c3e041d1eb6928e809324a8c866e3"},
|
||||
{file = "gravitasml-0.1.3-py3-none-any.whl", hash = "sha256:51ff98b4564b7a61f7796f18d5f2558b919d30b3722579296089645b7bc18b85"},
|
||||
{file = "gravitasml-0.1.3.tar.gz", hash = "sha256:04d240b9fa35878252d57a36032130b6516487468847fcdced1022c032a20f57"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7295,4 +7279,4 @@ cffi = ["cffi (>=1.11)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.14"
|
||||
content-hash = "a93ba0cea3b465cb6ec3e3f258b383b09f84ea352ccfdbfa112902cde5653fc6"
|
||||
content-hash = "13b191b2a1989d3321ff713c66ff6f5f4f3b82d15df4d407e0e5dbf87d7522c4"
|
||||
|
||||
@@ -27,7 +27,7 @@ google-api-python-client = "^2.177.0"
|
||||
google-auth-oauthlib = "^1.2.2"
|
||||
google-cloud-storage = "^3.2.0"
|
||||
googlemaps = "^4.10.0"
|
||||
gravitasml = "^0.1.4"
|
||||
gravitasml = "^0.1.3"
|
||||
groq = "^0.30.0"
|
||||
html2text = "^2024.2.26"
|
||||
jinja2 = "^3.1.6"
|
||||
@@ -82,7 +82,6 @@ firecrawl-py = "^4.3.6"
|
||||
exa-py = "^1.14.20"
|
||||
croniter = "^6.0.0"
|
||||
stagehand = "^0.5.1"
|
||||
gravitas-md2gdocs = "^0.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
aiohappyeyeballs = "^2.6.1"
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from backend.blocks.google.docs import GoogleDocsFormatTextBlock
|
||||
|
||||
|
||||
def _make_mock_docs_service() -> Mock:
|
||||
service = Mock()
|
||||
# Ensure chained call exists: service.documents().batchUpdate(...).execute()
|
||||
service.documents.return_value.batchUpdate.return_value.execute.return_value = {}
|
||||
return service
|
||||
|
||||
|
||||
def test_format_text_parses_shorthand_hex_color():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#FFF",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify request body contains correct rgbColor for white.
|
||||
_, kwargs = service.documents.return_value.batchUpdate.call_args
|
||||
requests = kwargs["body"]["requests"]
|
||||
rgb = requests[0]["updateTextStyle"]["textStyle"]["foregroundColor"]["color"][
|
||||
"rgbColor"
|
||||
]
|
||||
assert rgb == {"red": 1.0, "green": 1.0, "blue": 1.0}
|
||||
|
||||
|
||||
def test_format_text_parses_full_hex_color():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#FF0000",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
_, kwargs = service.documents.return_value.batchUpdate.call_args
|
||||
requests = kwargs["body"]["requests"]
|
||||
rgb = requests[0]["updateTextStyle"]["textStyle"]["foregroundColor"]["color"][
|
||||
"rgbColor"
|
||||
]
|
||||
assert rgb == {"red": 1.0, "green": 0.0, "blue": 0.0}
|
||||
|
||||
|
||||
def test_format_text_ignores_invalid_color_when_other_fields_present():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=True,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#GGG",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "warning" in result
|
||||
|
||||
# Should still apply bold, but should NOT include foregroundColor in textStyle.
|
||||
_, kwargs = service.documents.return_value.batchUpdate.call_args
|
||||
requests = kwargs["body"]["requests"]
|
||||
text_style = requests[0]["updateTextStyle"]["textStyle"]
|
||||
fields = requests[0]["updateTextStyle"]["fields"]
|
||||
|
||||
assert text_style == {"bold": True}
|
||||
assert fields == "bold"
|
||||
|
||||
|
||||
def test_format_text_invalid_color_only_does_not_call_api():
|
||||
block = GoogleDocsFormatTextBlock()
|
||||
service = _make_mock_docs_service()
|
||||
|
||||
result = block._format_text(
|
||||
service,
|
||||
document_id="doc_1",
|
||||
start_index=1,
|
||||
end_index=2,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
font_size=0,
|
||||
foreground_color="#F",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid foreground_color" in result["message"]
|
||||
service.documents.return_value.batchUpdate.assert_not_called()
|
||||
@@ -37,18 +37,6 @@ class TestTranscribeYoutubeVideoBlock:
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dQw4w9WgXcQ"
|
||||
|
||||
def test_extract_video_id_shorts_url(self):
|
||||
"""Test extracting video ID from YouTube Shorts URL."""
|
||||
url = "https://www.youtube.com/shorts/dtUqwMu3e-g"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dtUqwMu3e-g"
|
||||
|
||||
def test_extract_video_id_shorts_url_with_params(self):
|
||||
"""Test extracting video ID from YouTube Shorts URL with query parameters."""
|
||||
url = "https://www.youtube.com/shorts/dtUqwMu3e-g?feature=share"
|
||||
video_id = self.youtube_block.extract_video_id(url)
|
||||
assert video_id == "dtUqwMu3e-g"
|
||||
|
||||
@patch("backend.blocks.youtube.YouTubeTranscriptApi")
|
||||
def test_get_transcript_english_available(self, mock_api_class):
|
||||
"""Test getting transcript when English is available."""
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Cloudflare Workers Script for docs.agpt.co → agpt.co/docs migration
|
||||
*
|
||||
* Deploy this script to handle all redirects with a single JavaScript file.
|
||||
* No rule limits, easy to maintain, handles all edge cases.
|
||||
*/
|
||||
|
||||
// URL mapping for special cases that don't follow patterns
|
||||
const SPECIAL_MAPPINGS = {
|
||||
// Root page
|
||||
'/': '/docs/platform',
|
||||
|
||||
// Special cases that don't follow standard patterns
|
||||
'/platform/d_id/': '/docs/integrations/block-integrations/d-id',
|
||||
'/platform/blocks/blocks/': '/docs/integrations',
|
||||
'/platform/blocks/decoder_block/': '/docs/integrations/block-integrations/text-decoder',
|
||||
'/platform/blocks/http': '/docs/integrations/block-integrations/send-web-request',
|
||||
'/platform/blocks/llm/': '/docs/integrations/block-integrations/ai-and-llm',
|
||||
'/platform/blocks/time_blocks': '/docs/integrations/block-integrations/time-and-date',
|
||||
'/platform/blocks/text_to_speech_block': '/docs/integrations/block-integrations/text-to-speech',
|
||||
'/platform/blocks/ai_shortform_video_block': '/docs/integrations/block-integrations/ai-shortform-video',
|
||||
'/platform/blocks/replicate_flux_advanced': '/docs/integrations/block-integrations/replicate-flux-advanced',
|
||||
'/platform/blocks/flux_kontext': '/docs/integrations/block-integrations/flux-kontext',
|
||||
'/platform/blocks/ai_condition/': '/docs/integrations/block-integrations/ai-condition',
|
||||
'/platform/blocks/email_block': '/docs/integrations/block-integrations/email',
|
||||
'/platform/blocks/google_maps': '/docs/integrations/block-integrations/google-maps',
|
||||
'/platform/blocks/google/gmail': '/docs/integrations/block-integrations/gmail',
|
||||
'/platform/blocks/github/issues/': '/docs/integrations/block-integrations/github-issues',
|
||||
'/platform/blocks/github/repo/': '/docs/integrations/block-integrations/github-repo',
|
||||
'/platform/blocks/github/pull_requests': '/docs/integrations/block-integrations/github-pull-requests',
|
||||
'/platform/blocks/twitter/twitter': '/docs/integrations/block-integrations/twitter',
|
||||
'/classic/setup/': '/docs/classic/setup/setting-up-autogpt-classic',
|
||||
'/code-of-conduct/': '/docs/classic/help-us-improve-autogpt/code-of-conduct',
|
||||
'/contributing/': '/docs/classic/contributing',
|
||||
'/contribute/': '/docs/contribute',
|
||||
'/forge/components/introduction/': '/docs/classic/forge/introduction'
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform path by replacing underscores with hyphens and removing trailing slashes
|
||||
*/
|
||||
function transformPath(path) {
|
||||
return path.replace(/_/g, '-').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle docs.agpt.co redirects
|
||||
*/
|
||||
function handleDocsRedirect(url) {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Check special mappings first
|
||||
if (SPECIAL_MAPPINGS[pathname]) {
|
||||
return `https://agpt.co${SPECIAL_MAPPINGS[pathname]}`;
|
||||
}
|
||||
|
||||
// Pattern-based redirects
|
||||
|
||||
// Platform blocks: /platform/blocks/* → /docs/integrations/block-integrations/*
|
||||
if (pathname.startsWith('/platform/blocks/')) {
|
||||
const blockName = pathname.substring('/platform/blocks/'.length);
|
||||
const transformedName = transformPath(blockName);
|
||||
return `https://agpt.co/docs/integrations/block-integrations/${transformedName}`;
|
||||
}
|
||||
|
||||
// Platform contributing: /platform/contributing/* → /docs/platform/contributing/*
|
||||
if (pathname.startsWith('/platform/contributing/')) {
|
||||
const subPath = pathname.substring('/platform/contributing/'.length);
|
||||
return `https://agpt.co/docs/platform/contributing/${subPath}`;
|
||||
}
|
||||
|
||||
// Platform general: /platform/* → /docs/platform/* (with underscore→hyphen)
|
||||
if (pathname.startsWith('/platform/')) {
|
||||
const subPath = pathname.substring('/platform/'.length);
|
||||
const transformedPath = transformPath(subPath);
|
||||
return `https://agpt.co/docs/platform/${transformedPath}`;
|
||||
}
|
||||
|
||||
// Forge components: /forge/components/* → /docs/classic/forge/introduction/*
|
||||
if (pathname.startsWith('/forge/components/')) {
|
||||
const subPath = pathname.substring('/forge/components/'.length);
|
||||
return `https://agpt.co/docs/classic/forge/introduction/${subPath}`;
|
||||
}
|
||||
|
||||
// Forge general: /forge/* → /docs/classic/forge/*
|
||||
if (pathname.startsWith('/forge/')) {
|
||||
const subPath = pathname.substring('/forge/'.length);
|
||||
return `https://agpt.co/docs/classic/forge/${subPath}`;
|
||||
}
|
||||
|
||||
// Classic: /classic/* → /docs/classic/*
|
||||
if (pathname.startsWith('/classic/')) {
|
||||
const subPath = pathname.substring('/classic/'.length);
|
||||
return `https://agpt.co/docs/classic/${subPath}`;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'https://agpt.co/docs/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Worker function
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle docs.agpt.co requests
|
||||
if (url.hostname === 'docs.agpt.co') {
|
||||
const redirectUrl = handleDocsRedirect(url);
|
||||
|
||||
return new Response(null, {
|
||||
status: 301,
|
||||
headers: {
|
||||
'Location': redirectUrl,
|
||||
'Cache-Control': 'max-age=300' // Cache redirects for 5 minutes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For non-docs requests, pass through or return 404
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
// Test function for local development
|
||||
export function testRedirects() {
|
||||
const testCases = [
|
||||
'https://docs.agpt.co/',
|
||||
'https://docs.agpt.co/platform/getting-started/',
|
||||
'https://docs.agpt.co/platform/advanced_setup/',
|
||||
'https://docs.agpt.co/platform/blocks/basic/',
|
||||
'https://docs.agpt.co/platform/blocks/ai_condition/',
|
||||
'https://docs.agpt.co/classic/setup/',
|
||||
'https://docs.agpt.co/forge/components/agents/',
|
||||
'https://docs.agpt.co/contributing/',
|
||||
'https://docs.agpt.co/unknown-page'
|
||||
];
|
||||
|
||||
console.log('Testing redirects:');
|
||||
testCases.forEach(testUrl => {
|
||||
const url = new URL(testUrl);
|
||||
const result = handleDocsRedirect(url);
|
||||
console.log(`${testUrl} → ${result}`);
|
||||
});
|
||||
}
|
||||
@@ -46,15 +46,14 @@
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@rjsf/core": "6.1.2",
|
||||
"@rjsf/utils": "6.1.2",
|
||||
"@rjsf/validator-ajv8": "6.1.2",
|
||||
"@rjsf/core": "5.24.13",
|
||||
"@rjsf/utils": "5.24.13",
|
||||
"@rjsf/validator-ajv8": "5.24.13",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.78.0",
|
||||
@@ -70,7 +69,6 @@
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
"dexie": "4.2.1",
|
||||
"dotenv": "17.2.3",
|
||||
"elliptic": "6.6.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
|
||||
3814
autogpt_platform/frontend/pnpm-lock.yaml
generated
3814
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { OAuthPopupResultMessage } from "./types";
|
||||
import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// This route is intended to be used as the callback for integration OAuth flows,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
|
||||
| {
|
||||
success: true;
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
);
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/__legacy__/ui/sheet";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
|
||||
export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
|
||||
const hasOutputs = useGraphStore(useShallow((state) => state.hasOutputs));
|
||||
@@ -76,13 +76,9 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={!flowID || !hasOutputs()}
|
||||
>
|
||||
<BookOpenIcon className="size-4" />
|
||||
</Button>
|
||||
<BuilderActionButton disabled={!flowID || !hasOutputs()}>
|
||||
<BookOpenIcon className="size-6" />
|
||||
</BuilderActionButton>
|
||||
</SheetTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ButtonProps } from "@/components/atoms/Button/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleNotchIcon } from "@phosphor-icons/react";
|
||||
|
||||
export const BuilderActionButton = ({
|
||||
children,
|
||||
className,
|
||||
isLoading,
|
||||
...props
|
||||
}: ButtonProps & { isLoading?: boolean }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="icon"
|
||||
size={"small"}
|
||||
className={cn(
|
||||
"relative h-12 w-12 min-w-0 text-lg",
|
||||
"bg-gradient-to-br from-zinc-50 to-zinc-200",
|
||||
"border border-zinc-200",
|
||||
"shadow-[inset_0_3px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_2px_4px_0_rgba(0,0,0,0.4)]",
|
||||
"hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]",
|
||||
"active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
"transition-all duration-150",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!isLoading ? (
|
||||
children
|
||||
) : (
|
||||
<CircleNotchIcon className="size-6 animate-spin" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ShareIcon } from "@phosphor-icons/react";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
|
||||
import { ShareIcon } from "@phosphor-icons/react";
|
||||
import { usePublishToMarketplace } from "./usePublishToMarketplace";
|
||||
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
|
||||
|
||||
export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
const { handlePublishToMarketplace, publishState, handleStateChange } =
|
||||
@@ -16,14 +16,12 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
<BuilderActionButton
|
||||
onClick={handlePublishToMarketplace}
|
||||
disabled={!flowID}
|
||||
>
|
||||
<ShareIcon className="size-4" />
|
||||
</Button>
|
||||
<ShareIcon className="size-6 drop-shadow-sm" />
|
||||
</BuilderActionButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Publish to Marketplace</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -32,7 +30,6 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
|
||||
targetState={publishState}
|
||||
onStateChange={handleStateChange}
|
||||
preSelectedAgentId={flowID || undefined}
|
||||
showTrigger={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useRunGraph } from "./useRunGraph";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
|
||||
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
const {
|
||||
@@ -28,19 +29,21 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={isGraphRunning ? "destructive" : "primary"}
|
||||
<BuilderActionButton
|
||||
className={cn(
|
||||
isGraphRunning &&
|
||||
"border-red-500 bg-gradient-to-br from-red-400 to-red-500 shadow-[inset_0_2px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
|
||||
)}
|
||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
|
||||
loading={isExecutingGraph || isTerminatingGraph || isSaving}
|
||||
isLoading={isExecutingGraph || isTerminatingGraph || isSaving}
|
||||
>
|
||||
{!isGraphRunning ? (
|
||||
<PlayIcon className="size-4" />
|
||||
<PlayIcon className="size-6 drop-shadow-sm" />
|
||||
) : (
|
||||
<StopIcon className="size-4" />
|
||||
<StopIcon className="size-6 drop-shadow-sm" />
|
||||
)}
|
||||
</Button>
|
||||
</BuilderActionButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isGraphRunning ? "Stop agent" : "Run agent"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ClockIcon, PlayIcon } from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
|
||||
import { useRunInputDialog } from "./useRunInputDialog";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useMemo, useState } from "react";
|
||||
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
|
||||
import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
|
||||
import { isCredentialFieldSchema } from "@/components/renderers/input-renderer/fields/CredentialField/helpers";
|
||||
|
||||
export const useRunInputDialog = ({
|
||||
setIsOpen,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useScheduleGraph } from "./useScheduleGraph";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useScheduleGraph } from "./useScheduleGraph";
|
||||
import { BuilderActionButton } from "../BuilderActionButton";
|
||||
|
||||
export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
const {
|
||||
@@ -23,14 +23,12 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
<BuilderActionButton
|
||||
onClick={handleScheduleGraph}
|
||||
disabled={!flowID}
|
||||
>
|
||||
<ClockIcon className="size-4" />
|
||||
</Button>
|
||||
<ClockIcon className="size-6" />
|
||||
</BuilderActionButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Schedule Graph</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { BuilderView } from "./components/BuilderViewTabs/BuilderViewTabs";
|
||||
import { BuilderView } from "./BuilderViewTabs";
|
||||
|
||||
export function useBuilderView() {
|
||||
const isNewFlowEditorEnabled = useGetFlag(Flag.NEW_FLOW_EDITOR);
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ClockCounterClockwiseIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeAgo } from "@/lib/utils/time";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { DraftDiff } from "@/lib/dexie/draft-utils";
|
||||
|
||||
interface DraftRecoveryPopupProps {
|
||||
isInitialLoadComplete: boolean;
|
||||
}
|
||||
|
||||
function formatDiffSummary(diff: DraftDiff | null): string {
|
||||
if (!diff) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Node changes
|
||||
const nodeChanges: string[] = [];
|
||||
if (diff.nodes.added > 0) nodeChanges.push(`+${diff.nodes.added}`);
|
||||
if (diff.nodes.removed > 0) nodeChanges.push(`-${diff.nodes.removed}`);
|
||||
if (diff.nodes.modified > 0) nodeChanges.push(`~${diff.nodes.modified}`);
|
||||
|
||||
if (nodeChanges.length > 0) {
|
||||
parts.push(
|
||||
`${nodeChanges.join("/")} block${diff.nodes.added + diff.nodes.removed + diff.nodes.modified !== 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Edge changes
|
||||
const edgeChanges: string[] = [];
|
||||
if (diff.edges.added > 0) edgeChanges.push(`+${diff.edges.added}`);
|
||||
if (diff.edges.removed > 0) edgeChanges.push(`-${diff.edges.removed}`);
|
||||
if (diff.edges.modified > 0) edgeChanges.push(`~${diff.edges.modified}`);
|
||||
|
||||
if (edgeChanges.length > 0) {
|
||||
parts.push(
|
||||
`${edgeChanges.join("/")} connection${diff.edges.added + diff.edges.removed + diff.edges.modified !== 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function DraftRecoveryPopup({
|
||||
isInitialLoadComplete,
|
||||
}: DraftRecoveryPopupProps) {
|
||||
const {
|
||||
isOpen,
|
||||
popupRef,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
diff,
|
||||
savedAt,
|
||||
onLoad,
|
||||
onDiscard,
|
||||
} = useDraftRecoveryPopup(isInitialLoadComplete);
|
||||
|
||||
const diffSummary = formatDiffSummary(diff);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={popupRef}
|
||||
className={cn("absolute left-1/2 top-4 z-50")}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: "-50%",
|
||||
y: "-150%",
|
||||
scale: 0.5,
|
||||
filter: "blur(20px)",
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: "-50%",
|
||||
y: "0%",
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: "-150%",
|
||||
scale: 0.5,
|
||||
filter: "blur(20px)",
|
||||
transition: { duration: 0.4, type: "spring", bounce: 0.2 },
|
||||
}}
|
||||
transition={{ duration: 0.2, type: "spring", bounce: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xlarge border border-amber-200 bg-amber-50 px-4 py-3 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<ClockCounterClockwiseIcon className="h-5 w-5" weight="fill" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
Unsaved changes found
|
||||
</Text>
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{diffSummary ||
|
||||
`${nodeCount} block${nodeCount !== 1 ? "s" : ""}, ${edgeCount} connection${edgeCount !== 1 ? "s" : ""}`}{" "}
|
||||
• {formatTimeAgo(new Date(savedAt).toISOString())}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onLoad}
|
||||
className="aspect-square min-w-0 p-1.5"
|
||||
>
|
||||
<ClockCounterClockwiseIcon size={20} weight="fill" />
|
||||
<span className="sr-only">Restore changes</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore changes</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={onDiscard}
|
||||
aria-label="Discard changes"
|
||||
className="aspect-square min-w-0 p-1.5"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
<span className="sr-only">Discard changes</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Discard changes</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDraftManager } from "../FlowEditor/Flow/useDraftManager";
|
||||
|
||||
export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => {
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
isRecoveryOpen: isOpen,
|
||||
savedAt,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
diff,
|
||||
loadDraft: onLoad,
|
||||
discardDraft: onDiscard,
|
||||
} = useDraftManager(isInitialLoadComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onDiscard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onDiscard]);
|
||||
return {
|
||||
popupRef,
|
||||
isOpen,
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
diff,
|
||||
savedAt,
|
||||
onLoad,
|
||||
onDiscard,
|
||||
};
|
||||
};
|
||||
@@ -1,27 +1,26 @@
|
||||
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BuilderActions } from "../../BuilderActions/BuilderActions";
|
||||
import { DraftRecoveryPopup } from "../../DraftRecoveryDialog/DraftRecoveryPopup";
|
||||
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
||||
import { ReactFlow, Background } from "@xyflow/react";
|
||||
import NewControlPanel from "../../NewControlPanel/NewControlPanel";
|
||||
import CustomEdge from "../edges/CustomEdge";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { useFlow } from "./useFlow";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo, useEffect, useCallback } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { CustomControls } from "./components/CustomControl";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
import { GraphLoadingBox } from "./components/GraphLoadingBox";
|
||||
import { BuilderActions } from "../../BuilderActions/BuilderActions";
|
||||
import { RunningBackground } from "./components/RunningBackground";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useCopyPaste } from "./useCopyPaste";
|
||||
import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { CustomControls } from "./components/CustomControl";
|
||||
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
|
||||
import { resolveCollisions } from "./helpers/resolve-collision";
|
||||
import { useCopyPaste } from "./useCopyPaste";
|
||||
import { useFlow } from "./useFlow";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
||||
|
||||
export const Flow = () => {
|
||||
const [{ flowID, flowExecutionID }] = useQueryStates({
|
||||
@@ -42,18 +41,14 @@ export const Flow = () => {
|
||||
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
const setNodes = useNodeStore(useShallow((state) => state.setNodes));
|
||||
|
||||
const onNodesChange = useNodeStore(
|
||||
useShallow((state) => state.onNodesChange),
|
||||
);
|
||||
|
||||
const hasWebhookNodes = useNodeStore(
|
||||
useShallow((state) => state.hasWebhookNodes()),
|
||||
);
|
||||
|
||||
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
|
||||
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
|
||||
|
||||
const onNodeDragStop = useCallback(() => {
|
||||
setNodes(
|
||||
resolveCollisions(nodes, {
|
||||
@@ -65,26 +60,29 @@ export const Flow = () => {
|
||||
}, [setNodes, nodes]);
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
// for loading purpose
|
||||
const {
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isFlowContentLoading,
|
||||
isInitialLoadComplete,
|
||||
isLocked,
|
||||
setIsLocked,
|
||||
} = useFlow();
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
|
||||
useFlow();
|
||||
|
||||
// This hook is used for websocket realtime updates.
|
||||
useFlowRealtime();
|
||||
|
||||
// Copy/paste functionality
|
||||
useCopyPaste();
|
||||
const handleCopyPaste = useCopyPaste();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handleCopyPaste(event);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCopyPaste]);
|
||||
const isGraphRunning = useGraphStore(
|
||||
useShallow((state) => state.isGraphRunning),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full dark:bg-slate-900">
|
||||
<div className="relative flex-1">
|
||||
@@ -104,7 +102,6 @@ export const Flow = () => {
|
||||
nodesDraggable={!isLocked}
|
||||
nodesConnectable={!isLocked}
|
||||
elementsSelectable={!isLocked}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
>
|
||||
<Background />
|
||||
<CustomControls setIsLocked={setIsLocked} isLocked={isLocked} />
|
||||
@@ -118,7 +115,6 @@ export const Flow = () => {
|
||||
className="right-2 top-32 p-2"
|
||||
/>
|
||||
)}
|
||||
<DraftRecoveryPopup isInitialLoadComplete={isInitialLoadComplete} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{/* TODO: Need to update it in future - also do not send executionId as prop - rather use useQueryState inside the component */}
|
||||
|
||||
@@ -48,6 +48,8 @@ export const resolveCollisions: CollisionAlgorithm = (
|
||||
const width = (node.width ?? node.measured?.width ?? 0) + margin * 2;
|
||||
const height = (node.height ?? node.measured?.height ?? 0) + margin * 2;
|
||||
|
||||
console.log("width", width);
|
||||
console.log("height", height);
|
||||
const x = node.position.x - margin;
|
||||
const y = node.position.y - margin;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
@@ -151,16 +151,5 @@ export function useCopyPaste() {
|
||||
[getViewport, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handleCopyPaste(event);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCopyPaste]);
|
||||
|
||||
return handleCopyPaste;
|
||||
}
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
|
||||
import {
|
||||
draftService,
|
||||
getTempFlowId,
|
||||
getOrCreateTempFlowId,
|
||||
DraftData,
|
||||
} from "@/services/builder-draft/draft-service";
|
||||
import { BuilderDraft } from "@/lib/dexie/db";
|
||||
import {
|
||||
cleanNodes,
|
||||
cleanEdges,
|
||||
calculateDraftDiff,
|
||||
DraftDiff,
|
||||
} from "@/lib/dexie/draft-utils";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { useGraphStore } from "../../../stores/graphStore";
|
||||
import { useHistoryStore } from "../../../stores/historyStore";
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
||||
const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds
|
||||
|
||||
interface DraftRecoveryState {
|
||||
isOpen: boolean;
|
||||
draft: BuilderDraft | null;
|
||||
diff: DraftDiff | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated hook for draft persistence and recovery
|
||||
* - Auto-saves builder state every 15 seconds
|
||||
* - Saves on beforeunload event
|
||||
* - Checks for and manages unsaved drafts on load
|
||||
*/
|
||||
export function useDraftManager(isInitialLoadComplete: boolean) {
|
||||
const [state, setState] = useState<DraftRecoveryState>({
|
||||
isOpen: false,
|
||||
draft: null,
|
||||
diff: null,
|
||||
});
|
||||
|
||||
const [{ flowID, flowVersion }] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
});
|
||||
|
||||
const lastSavedStateRef = useRef<DraftData | null>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isDirtyRef = useRef(false);
|
||||
const hasCheckedForDraft = useRef(false);
|
||||
|
||||
const getEffectiveFlowId = useCallback((): string => {
|
||||
return flowID || getOrCreateTempFlowId();
|
||||
}, [flowID]);
|
||||
|
||||
const getCurrentState = useCallback((): DraftData => {
|
||||
const nodes = useNodeStore.getState().nodes;
|
||||
const edges = useEdgeStore.getState().edges;
|
||||
const nodeCounter = useNodeStore.getState().nodeCounter;
|
||||
const graphStore = useGraphStore.getState();
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
graphSchemas: {
|
||||
input: graphStore.inputSchema,
|
||||
credentials: graphStore.credentialsInputSchema,
|
||||
output: graphStore.outputSchema,
|
||||
},
|
||||
nodeCounter,
|
||||
flowVersion: flowVersion ?? undefined,
|
||||
};
|
||||
}, [flowVersion]);
|
||||
|
||||
const cleanStateForComparison = useCallback((stateData: DraftData) => {
|
||||
return {
|
||||
nodes: cleanNodes(stateData.nodes),
|
||||
edges: cleanEdges(stateData.edges),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasChanges = useCallback((): boolean => {
|
||||
const currentState = getCurrentState();
|
||||
|
||||
if (!lastSavedStateRef.current) {
|
||||
return currentState.nodes.length > 0;
|
||||
}
|
||||
|
||||
const currentClean = cleanStateForComparison(currentState);
|
||||
const lastClean = cleanStateForComparison(lastSavedStateRef.current);
|
||||
|
||||
return !isEqual(currentClean, lastClean);
|
||||
}, [getCurrentState, cleanStateForComparison]);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
const effectiveFlowId = getEffectiveFlowId();
|
||||
const currentState = getCurrentState();
|
||||
|
||||
if (currentState.nodes.length === 0 && currentState.edges.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasChanges()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await draftService.saveDraft(effectiveFlowId, currentState);
|
||||
lastSavedStateRef.current = currentState;
|
||||
isDirtyRef.current = false;
|
||||
} catch (error) {
|
||||
console.error("[DraftPersistence] Failed to save draft:", error);
|
||||
}
|
||||
}, [getEffectiveFlowId, getCurrentState, hasChanges]);
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
isDirtyRef.current = true;
|
||||
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
saveDraft();
|
||||
}, AUTO_SAVE_INTERVAL_MS);
|
||||
}, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeNodes = useNodeStore.subscribe((storeState, prevState) => {
|
||||
if (storeState.nodes !== prevState.nodes) {
|
||||
scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeEdges = useEdgeStore.subscribe((storeState, prevState) => {
|
||||
if (storeState.edges !== prevState.edges) {
|
||||
scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeNodes();
|
||||
unsubscribeEdges();
|
||||
};
|
||||
}, [scheduleSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (isDirtyRef.current) {
|
||||
const effectiveFlowId = getEffectiveFlowId();
|
||||
const currentState = getCurrentState();
|
||||
|
||||
if (
|
||||
currentState.nodes.length === 0 &&
|
||||
currentState.edges.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
draftService.saveDraft(effectiveFlowId, currentState).catch(() => {
|
||||
// Ignore errors on unload
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [getEffectiveFlowId, getCurrentState]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
if (isDirtyRef.current) {
|
||||
saveDraft();
|
||||
}
|
||||
};
|
||||
}, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
draftService.cleanupExpired().catch((error) => {
|
||||
console.error(
|
||||
"[DraftPersistence] Failed to cleanup expired drafts:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const checkForDraft = useCallback(async () => {
|
||||
const effectiveFlowId = flowID || getTempFlowId();
|
||||
|
||||
if (!effectiveFlowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const draft = await draftService.loadDraft(effectiveFlowId);
|
||||
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNodes = useNodeStore.getState().nodes;
|
||||
const currentEdges = useEdgeStore.getState().edges;
|
||||
|
||||
const isDifferent = draftService.isDraftDifferent(
|
||||
draft,
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
);
|
||||
|
||||
if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) {
|
||||
const diff = calculateDraftDiff(
|
||||
draft.nodes,
|
||||
draft.edges,
|
||||
currentNodes,
|
||||
currentEdges,
|
||||
);
|
||||
setState({
|
||||
isOpen: true,
|
||||
draft,
|
||||
diff,
|
||||
});
|
||||
} else {
|
||||
await draftService.deleteDraft(effectiveFlowId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DraftRecovery] Failed to check for draft:", error);
|
||||
}
|
||||
}, [flowID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoadComplete && !hasCheckedForDraft.current) {
|
||||
hasCheckedForDraft.current = true;
|
||||
checkForDraft();
|
||||
}
|
||||
}, [isInitialLoadComplete, checkForDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
hasCheckedForDraft.current = false;
|
||||
setState({
|
||||
isOpen: false,
|
||||
draft: null,
|
||||
diff: null,
|
||||
});
|
||||
}, [flowID]);
|
||||
|
||||
const loadDraft = useCallback(async () => {
|
||||
if (!state.draft) return;
|
||||
|
||||
const { draft } = state;
|
||||
|
||||
try {
|
||||
useNodeStore.getState().setNodes(draft.nodes);
|
||||
useEdgeStore.getState().setEdges(draft.edges);
|
||||
draft.nodes.forEach((node) => {
|
||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||
});
|
||||
|
||||
if (draft.nodeCounter !== undefined) {
|
||||
useNodeStore.setState({ nodeCounter: draft.nodeCounter });
|
||||
}
|
||||
|
||||
if (draft.graphSchemas) {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphSchemas(
|
||||
draft.graphSchemas.input as Record<string, unknown> | null,
|
||||
draft.graphSchemas.credentials as Record<string, unknown> | null,
|
||||
draft.graphSchemas.output as Record<string, unknown> | null,
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
}, 100);
|
||||
|
||||
await draftService.deleteDraft(draft.id);
|
||||
|
||||
setState({
|
||||
isOpen: false,
|
||||
draft: null,
|
||||
diff: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DraftRecovery] Failed to load draft:", error);
|
||||
}
|
||||
}, [state.draft]);
|
||||
|
||||
const discardDraft = useCallback(async () => {
|
||||
if (!state.draft) {
|
||||
setState({ isOpen: false, draft: null, diff: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await draftService.deleteDraft(state.draft.id);
|
||||
} catch (error) {
|
||||
console.error("[DraftRecovery] Failed to discard draft:", error);
|
||||
}
|
||||
|
||||
setState({ isOpen: false, draft: null, diff: null });
|
||||
}, [state.draft]);
|
||||
|
||||
return {
|
||||
// Recovery popup props
|
||||
isRecoveryOpen: state.isOpen,
|
||||
savedAt: state.draft?.savedAt ?? 0,
|
||||
nodeCount: state.draft?.nodes.length ?? 0,
|
||||
edgeCount: state.draft?.edges.length ?? 0,
|
||||
diff: state.diff,
|
||||
loadDraft,
|
||||
discardDraft,
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
|
||||
export const useFlow = () => {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [hasAutoFramed, setHasAutoFramed] = useState(false);
|
||||
const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false);
|
||||
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
|
||||
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
|
||||
const updateNodeStatus = useNodeStore(
|
||||
@@ -121,14 +120,6 @@ export const useFlow = () => {
|
||||
if (customNodes.length > 0) {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
addNodes(customNodes);
|
||||
|
||||
// Sync hardcoded values with handle IDs.
|
||||
// If a key–value field has a key without a value, the backend omits it from hardcoded values.
|
||||
// But if a handleId exists for that key, it causes inconsistency.
|
||||
// This ensures hardcoded values stay in sync with handle IDs.
|
||||
customNodes.forEach((node) => {
|
||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||
});
|
||||
}
|
||||
}, [customNodes, addNodes]);
|
||||
|
||||
@@ -183,23 +174,11 @@ export const useFlow = () => {
|
||||
if (customNodes.length > 0 && graph?.links) {
|
||||
const timer = setTimeout(() => {
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
// Mark initial load as complete after history is initialized
|
||||
setIsInitialLoadComplete(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [customNodes, graph?.links]);
|
||||
|
||||
// Also mark as complete for new flows (no flowID) after a short delay
|
||||
useEffect(() => {
|
||||
if (!flowID && !isGraphLoading && !isBlocksLoading) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsInitialLoadComplete(true);
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [flowID, isGraphLoading, isBlocksLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
@@ -238,7 +217,6 @@ export const useFlow = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setHasAutoFramed(false);
|
||||
setIsInitialLoadComplete(false);
|
||||
}, [flowID, flowVersion]);
|
||||
|
||||
// Drag and drop block from block menu
|
||||
@@ -275,7 +253,6 @@ export const useFlow = () => {
|
||||
|
||||
return {
|
||||
isFlowContentLoading: isGraphLoading || isBlocksLoading,
|
||||
isInitialLoadComplete,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isLocked,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import {
|
||||
Connection as RFConnection,
|
||||
EdgeChange,
|
||||
applyEdgeChanges,
|
||||
} from "@xyflow/react";
|
||||
import { Connection as RFConnection, EdgeChange } from "@xyflow/react";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { useCallback } from "react";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { CustomEdge } from "./CustomEdge";
|
||||
|
||||
export const useCustomEdge = () => {
|
||||
const edges = useEdgeStore((s) => s.edges);
|
||||
const addEdge = useEdgeStore((s) => s.addEdge);
|
||||
const setEdges = useEdgeStore((s) => s.setEdges);
|
||||
const removeEdge = useEdgeStore((s) => s.removeEdge);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: RFConnection) => {
|
||||
@@ -50,10 +45,14 @@ export const useCustomEdge = () => {
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange<CustomEdge>[]) => {
|
||||
setEdges(applyEdgeChanges(changes, edges));
|
||||
(changes: EdgeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "remove") {
|
||||
removeEdge(change.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
[edges, setEdges],
|
||||
[removeEdge],
|
||||
);
|
||||
|
||||
return { edges, onConnect, onEdgesChange };
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import { CircleIcon } from "@phosphor-icons/react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const InputNodeHandle = ({
|
||||
const NodeHandle = ({
|
||||
handleId,
|
||||
nodeId,
|
||||
isConnected,
|
||||
side,
|
||||
}: {
|
||||
handleId: string;
|
||||
nodeId: string;
|
||||
isConnected: boolean;
|
||||
side: "left" | "right";
|
||||
}) => {
|
||||
const cleanedHandleId = cleanUpHandleId(handleId);
|
||||
const isInputConnected = useEdgeStore((state) =>
|
||||
state.isInputConnected(nodeId ?? "", cleanedHandleId),
|
||||
);
|
||||
|
||||
return (
|
||||
<Handle
|
||||
type={"target"}
|
||||
position={Position.Left}
|
||||
id={cleanedHandleId}
|
||||
className={"-ml-6 mr-2"}
|
||||
type={side === "left" ? "target" : "source"}
|
||||
position={side === "left" ? Position.Left : Position.Right}
|
||||
id={handleId}
|
||||
className={side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2"}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
size={16}
|
||||
weight={isInputConnected ? "fill" : "duotone"}
|
||||
weight={isConnected ? "fill" : "duotone"}
|
||||
className={"text-gray-400 opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
@@ -34,35 +28,4 @@ const InputNodeHandle = ({
|
||||
);
|
||||
};
|
||||
|
||||
const OutputNodeHandle = ({
|
||||
field_name,
|
||||
nodeId,
|
||||
hexColor,
|
||||
}: {
|
||||
field_name: string;
|
||||
nodeId: string;
|
||||
hexColor: string;
|
||||
}) => {
|
||||
const isOutputConnected = useEdgeStore((state) =>
|
||||
state.isOutputConnected(nodeId, field_name),
|
||||
);
|
||||
return (
|
||||
<Handle
|
||||
type={"source"}
|
||||
position={Position.Right}
|
||||
id={field_name}
|
||||
className={"-mr-2 ml-2"}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<CircleIcon
|
||||
size={16}
|
||||
weight={"duotone"}
|
||||
color={isOutputConnected ? hexColor : "gray"}
|
||||
className={cn("text-gray-400 opacity-100")}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
);
|
||||
};
|
||||
|
||||
export { InputNodeHandle, OutputNodeHandle };
|
||||
export default NodeHandle;
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
// Here we are handling single level of nesting, if need more in future then i will update it
|
||||
/**
|
||||
* Handle ID Types for different input structures
|
||||
*
|
||||
* Examples:
|
||||
* SIMPLE: "message"
|
||||
* NESTED: "config.api_key"
|
||||
* ARRAY: "items_$_0", "items_$_1"
|
||||
* KEY_VALUE: "headers_#_Authorization", "params_#_limit"
|
||||
*
|
||||
* Note: All handle IDs are sanitized to remove spaces and special characters.
|
||||
* Spaces become underscores, and special characters are removed.
|
||||
* Example: "user name" becomes "user_name", "email@domain.com" becomes "emaildomaincom"
|
||||
*/
|
||||
export enum HandleIdType {
|
||||
SIMPLE = "SIMPLE",
|
||||
NESTED = "NESTED",
|
||||
ARRAY = "ARRAY",
|
||||
KEY_VALUE = "KEY_VALUE",
|
||||
}
|
||||
|
||||
const fromRjsfId = (id: string): string => {
|
||||
if (!id) return "";
|
||||
const parts = id.split("_");
|
||||
const filtered = parts.filter(
|
||||
(p) => p !== "root" && p !== "properties" && p.length > 0,
|
||||
);
|
||||
return filtered.join("_") || "";
|
||||
};
|
||||
|
||||
const sanitizeForHandleId = (str: string): string => {
|
||||
if (!str) return "";
|
||||
@@ -11,53 +38,51 @@ const sanitizeForHandleId = (str: string): string => {
|
||||
.replace(/^_|_$/g, ""); // Remove leading/trailing underscores
|
||||
};
|
||||
|
||||
const cleanTitleId = (id: string): string => {
|
||||
if (!id) return "";
|
||||
|
||||
if (id.endsWith("_title")) {
|
||||
id = id.slice(0, -6);
|
||||
}
|
||||
const parts = id.split("_");
|
||||
const filtered = parts.filter(
|
||||
(p) => p !== "root" && p !== "properties" && p.length > 0,
|
||||
);
|
||||
const filtered_id = filtered.join("_") || "";
|
||||
return filtered_id;
|
||||
};
|
||||
|
||||
export const generateHandleIdFromTitleId = (
|
||||
export const generateHandleId = (
|
||||
fieldKey: string,
|
||||
{
|
||||
isObjectProperty,
|
||||
isAdditionalProperty,
|
||||
isArrayItem,
|
||||
}: {
|
||||
isArrayItem?: boolean;
|
||||
isObjectProperty?: boolean;
|
||||
isAdditionalProperty?: boolean;
|
||||
} = {
|
||||
isArrayItem: false,
|
||||
isObjectProperty: false,
|
||||
isAdditionalProperty: false,
|
||||
},
|
||||
nestedValues: string[] = [],
|
||||
type: HandleIdType = HandleIdType.SIMPLE,
|
||||
): string => {
|
||||
if (!fieldKey) return "";
|
||||
|
||||
const filteredKey = cleanTitleId(fieldKey);
|
||||
if (isAdditionalProperty || isArrayItem) {
|
||||
return filteredKey;
|
||||
}
|
||||
const cleanedKey = sanitizeForHandleId(filteredKey);
|
||||
fieldKey = fromRjsfId(fieldKey);
|
||||
fieldKey = sanitizeForHandleId(fieldKey);
|
||||
|
||||
if (isObjectProperty) {
|
||||
// "config_api_key" -> "config.api_key"
|
||||
const parts = cleanedKey.split("_");
|
||||
if (parts.length >= 2) {
|
||||
const baseName = parts[0];
|
||||
const propertyName = parts.slice(1).join("_");
|
||||
return `${baseName}.${propertyName}`;
|
||||
}
|
||||
if (type === HandleIdType.SIMPLE || nestedValues.length === 0) {
|
||||
return fieldKey;
|
||||
}
|
||||
|
||||
return cleanedKey;
|
||||
const sanitizedNestedValues = nestedValues.map((value) =>
|
||||
sanitizeForHandleId(value),
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case HandleIdType.NESTED:
|
||||
return [fieldKey, ...sanitizedNestedValues].join(".");
|
||||
|
||||
case HandleIdType.ARRAY:
|
||||
return [fieldKey, ...sanitizedNestedValues].join("_$_");
|
||||
|
||||
case HandleIdType.KEY_VALUE:
|
||||
return [fieldKey, ...sanitizedNestedValues].join("_#_");
|
||||
|
||||
default:
|
||||
return fieldKey;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseKeyValueHandleId = (
|
||||
handleId: string,
|
||||
type: HandleIdType,
|
||||
): string => {
|
||||
if (type === HandleIdType.KEY_VALUE) {
|
||||
return handleId.split("_#_")[1];
|
||||
} else if (type === HandleIdType.ARRAY) {
|
||||
return handleId.split("_$_")[1];
|
||||
} else if (type === HandleIdType.NESTED) {
|
||||
return handleId.split(".")[1];
|
||||
} else if (type === HandleIdType.SIMPLE) {
|
||||
return handleId.split("_")[1];
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutio
|
||||
import { NodeContainer } from "./components/NodeContainer";
|
||||
import { NodeHeader } from "./components/NodeHeader";
|
||||
import { FormCreator } from "../FormCreator";
|
||||
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
|
||||
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
|
||||
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
|
||||
@@ -99,7 +99,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
className={cn(
|
||||
"bg-white px-4",
|
||||
"bg-white pr-6",
|
||||
isWebhook && "pointer-events-none opacity-50",
|
||||
)}
|
||||
showHandles={showHandles}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => {
|
||||
);
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white px-5 py-3.5">
|
||||
<div className="flex items-center justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white px-5 py-3.5">
|
||||
<Text variant="body" className="font-medium text-slate-700">
|
||||
Advanced
|
||||
</Text>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const NodeContainer = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-12 w-[350px] rounded-xlarge ring-1 ring-slate-200/60",
|
||||
"z-12 max-w-[370px] rounded-xlarge ring-1 ring-slate-200/60",
|
||||
selected && "shadow-lg ring-2 ring-slate-200",
|
||||
status && nodeStyleBasedOnStatus[status],
|
||||
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
|
||||
|
||||
@@ -23,9 +23,7 @@ export const NodeHeader = ({
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
const title = (data.metadata?.customized_name as string) || data.title;
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(
|
||||
beautifyString(title).replace("Block", "").trim(),
|
||||
);
|
||||
const [editedTitle, setEditedTitle] = useState(title);
|
||||
|
||||
const handleTitleEdit = () => {
|
||||
updateNodeData(nodeId, {
|
||||
@@ -43,7 +41,7 @@ export const NodeHeader = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-zinc-200 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
|
||||
<div className="flex h-auto flex-col gap-1 rounded-xlarge border-b border-slate-200/50 bg-gradient-to-r from-slate-50/80 to-white/90 px-4 py-4 pt-3">
|
||||
{/* Title row with context menu */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
@@ -70,12 +68,12 @@ export const NodeHeader = ({
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Text variant="large-semibold" className="line-clamp-1">
|
||||
{beautifyString(title).replace("Block", "").trim()}
|
||||
{beautifyString(title)}
|
||||
</Text>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{beautifyString(title).replace("Block", "").trim()}</p>
|
||||
<p>{beautifyString(title)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4">
|
||||
<div className="flex flex-col gap-3 rounded-b-xl border-t border-slate-200/50 px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
||||
Node Output
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormCreator } from "../../FormCreator";
|
||||
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
|
||||
import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { uiSchema } from "./uiSchema";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BlockUIType } from "../../types";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
|
||||
|
||||
export const FormCreator = React.memo(
|
||||
({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
import { OutputNodeHandle } from "../handlers/NodeHandle";
|
||||
import NodeHandle from "../handlers/NodeHandle";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { getTypeDisplayInfo } from "./helpers";
|
||||
import { generateHandleId } from "../handlers/helpers";
|
||||
import { BlockUIType } from "../../types";
|
||||
|
||||
export const OutputHandler = ({
|
||||
@@ -28,73 +29,8 @@ export const OutputHandler = ({
|
||||
const properties = outputSchema?.properties || {};
|
||||
const [isOutputVisible, setIsOutputVisible] = useState(true);
|
||||
|
||||
const showHandles = uiType !== BlockUIType.OUTPUT;
|
||||
|
||||
const renderOutputHandles = (
|
||||
schema: RJSFSchema,
|
||||
keyPrefix: string = "",
|
||||
titlePrefix: string = "",
|
||||
): React.ReactNode[] => {
|
||||
return Object.entries(schema).map(
|
||||
([key, fieldSchema]: [string, RJSFSchema]) => {
|
||||
const fullKey = keyPrefix ? `${keyPrefix}_#_${key}` : key;
|
||||
const fieldTitle = titlePrefix + (fieldSchema?.title || key);
|
||||
|
||||
const isConnected = isOutputConnected(nodeId, fullKey);
|
||||
const shouldShow = isConnected || isOutputVisible;
|
||||
const { displayType, colorClass, hexColor } =
|
||||
getTypeDisplayInfo(fieldSchema);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={fullKey} className="flex flex-col items-end gap-2">
|
||||
<div className="relative flex items-center gap-2">
|
||||
{fieldSchema?.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{fieldSchema?.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Text variant="body" className="text-slate-700">
|
||||
{fieldTitle}
|
||||
</Text>
|
||||
<Text variant="small" as="span" className={colorClass}>
|
||||
({displayType})
|
||||
</Text>
|
||||
|
||||
{showHandles && (
|
||||
<OutputNodeHandle
|
||||
field_name={fullKey}
|
||||
nodeId={nodeId}
|
||||
hexColor={hexColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recursively render nested properties */}
|
||||
{fieldSchema?.properties &&
|
||||
renderOutputHandles(
|
||||
fieldSchema.properties,
|
||||
fullKey,
|
||||
`${fieldTitle}.`,
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-zinc-200 bg-white py-3.5">
|
||||
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white py-3.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mr-4 h-fit min-w-0 p-0 hover:border-transparent hover:bg-transparent"
|
||||
@@ -113,9 +49,50 @@ export const OutputHandler = ({
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{renderOutputHandles(properties)}
|
||||
</div>
|
||||
{
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{Object.entries(properties).map(([key, property]: [string, any]) => {
|
||||
const isConnected = isOutputConnected(nodeId, key);
|
||||
const shouldShow = isConnected || isOutputVisible;
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(property);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={key} className="relative flex items-center gap-2">
|
||||
{property?.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
style={{ marginLeft: 6, cursor: "pointer" }}
|
||||
aria-label="info"
|
||||
tabIndex={0}
|
||||
>
|
||||
<InfoIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{property?.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Text variant="body" className="text-slate-700">
|
||||
{property?.title || key}{" "}
|
||||
</Text>
|
||||
<Text variant="small" as="span" className={colorClass}>
|
||||
({displayType})
|
||||
</Text>
|
||||
|
||||
<NodeHandle
|
||||
handleId={
|
||||
uiType === BlockUIType.AGENT ? key : generateHandleId(key)
|
||||
}
|
||||
isConnected={isConnected}
|
||||
side="right"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,38 +92,14 @@ export const getTypeDisplayInfo = (schema: any) => {
|
||||
if (schema?.type === "string" && schema?.format) {
|
||||
const formatMap: Record<
|
||||
string,
|
||||
{ displayType: string; colorClass: string; hexColor: string }
|
||||
{ displayType: string; colorClass: string }
|
||||
> = {
|
||||
file: {
|
||||
displayType: "file",
|
||||
colorClass: "!text-green-500",
|
||||
hexColor: "#22c55e",
|
||||
},
|
||||
date: {
|
||||
displayType: "date",
|
||||
colorClass: "!text-blue-500",
|
||||
hexColor: "#3b82f6",
|
||||
},
|
||||
time: {
|
||||
displayType: "time",
|
||||
colorClass: "!text-blue-500",
|
||||
hexColor: "#3b82f6",
|
||||
},
|
||||
"date-time": {
|
||||
displayType: "datetime",
|
||||
colorClass: "!text-blue-500",
|
||||
hexColor: "#3b82f6",
|
||||
},
|
||||
"long-text": {
|
||||
displayType: "text",
|
||||
colorClass: "!text-green-500",
|
||||
hexColor: "#22c55e",
|
||||
},
|
||||
"short-text": {
|
||||
displayType: "text",
|
||||
colorClass: "!text-green-500",
|
||||
hexColor: "#22c55e",
|
||||
},
|
||||
file: { displayType: "file", colorClass: "!text-green-500" },
|
||||
date: { displayType: "date", colorClass: "!text-blue-500" },
|
||||
time: { displayType: "time", colorClass: "!text-blue-500" },
|
||||
"date-time": { displayType: "datetime", colorClass: "!text-blue-500" },
|
||||
"long-text": { displayType: "text", colorClass: "!text-green-500" },
|
||||
"short-text": { displayType: "text", colorClass: "!text-green-500" },
|
||||
};
|
||||
|
||||
const formatInfo = formatMap[schema.format];
|
||||
@@ -155,23 +131,10 @@ export const getTypeDisplayInfo = (schema: any) => {
|
||||
any: "!text-gray-500",
|
||||
};
|
||||
|
||||
const hexColorMap: Record<string, string> = {
|
||||
string: "#22c55e",
|
||||
number: "#3b82f6",
|
||||
integer: "#3b82f6",
|
||||
boolean: "#eab308",
|
||||
object: "#a855f7",
|
||||
array: "#6366f1",
|
||||
null: "#6b7280",
|
||||
any: "#6b7280",
|
||||
};
|
||||
|
||||
const colorClass = colorMap[schema?.type] || "!text-gray-500";
|
||||
const hexColor = hexColorMap[schema?.type] || "#6b7280";
|
||||
|
||||
return {
|
||||
displayType,
|
||||
colorClass,
|
||||
hexColor,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ControlPanelButton: React.FC<Props> = ({
|
||||
role={as === "div" ? "button" : undefined}
|
||||
disabled={as === "button" ? disabled : undefined}
|
||||
className={cn(
|
||||
"flex w-auto items-center justify-center whitespace-normal bg-white px-4 py-4 text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
|
||||
"flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
|
||||
selected &&
|
||||
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:cursor-not-allowed",
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
|
||||
export const BlockMenu = () => {
|
||||
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
|
||||
@@ -27,7 +28,7 @@ export const BlockMenu = () => {
|
||||
selected={blockMenuOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
<LegoIcon className="size-5" />
|
||||
<LegoIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useNewControlPanel } from "./useNewControlPanel";
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
// import { ControlPanelButton } from "../ControlPanelButton";
|
||||
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
|
||||
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
|
||||
export type Control = {
|
||||
@@ -56,7 +56,7 @@ export const NewControlPanel = memo(
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-10 z-10 overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
"absolute left-4 top-10 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { FloppyDiskIcon } from "@phosphor-icons/react";
|
||||
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { useNewSaveControl } from "./useNewSaveControl";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||
import { FloppyDiskIcon } from "@phosphor-icons/react";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
export const NewSaveControl = () => {
|
||||
const { form, isSaving, graphVersion, handleSave } = useNewSaveControl();
|
||||
@@ -32,7 +33,7 @@ export const NewSaveControl = () => {
|
||||
selected={saveControlOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
<FloppyDiskIcon className="size-5" />
|
||||
<FloppyDiskIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import React from "react";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { useGraphMenu } from "./useGraphMenu";
|
||||
|
||||
interface GraphSearchMenuProps {
|
||||
@@ -50,7 +50,7 @@ export const GraphSearchMenu: React.FC<GraphSearchMenuProps> = ({
|
||||
selected={blockMenuSelected === "search"}
|
||||
className="rounded-none"
|
||||
>
|
||||
<MagnifyingGlassIcon className="size-5" strokeWidth={2} />
|
||||
<MagnifyingGlassIcon className="h-5 w-6" strokeWidth={2} />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ControlPanelButton } from "./ControlPanelButton";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import { useHistoryStore } from "../../stores/historyStore";
|
||||
import { ControlPanelButton } from "./ControlPanelButton";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -43,7 +43,7 @@ export const UndoRedoButtons = () => {
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canUndo()} onClick={undo}>
|
||||
<ArrowUUpLeftIcon className="size-5" />
|
||||
<ArrowUUpLeftIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Undo</TooltipContent>
|
||||
@@ -52,7 +52,7 @@ export const UndoRedoButtons = () => {
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canRedo()} onClick={redo}>
|
||||
<ArrowUUpRightIcon className="size-5" />
|
||||
<ArrowUUpRightIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Redo</TooltipContent>
|
||||
|
||||
@@ -4,12 +4,19 @@ import {
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Clipboard, Maximize2 } from "lucide-react";
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../../../components/__legacy__/ui/dialog";
|
||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||
import { ScrollArea } from "../../../../../components/__legacy__/ui/scroll-area";
|
||||
import { Separator } from "../../../../../components/__legacy__/ui/separator";
|
||||
@@ -113,155 +120,138 @@ const ExpandableOutputDialog: FC<ExpandableOutputDialogProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 size={20} />
|
||||
Full Output Preview
|
||||
</div>
|
||||
{enableEnhancedOutputHandling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="enhanced-rendering-toggle"
|
||||
className="cursor-pointer select-none text-sm font-normal text-gray-600"
|
||||
>
|
||||
Enhanced Rendering
|
||||
</label>
|
||||
<Switch
|
||||
id="enhanced-rendering-toggle"
|
||||
checked={useEnhancedRenderer}
|
||||
onCheckedChange={setUseEnhancedRenderer}
|
||||
/>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="flex h-[90vh] w-[90vw] max-w-4xl flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 size={20} />
|
||||
Full Output Preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
height: "90vh",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pb-4">
|
||||
<p className="text-sm text-zinc-600">
|
||||
Execution ID: <span className="font-mono text-xs">{execId}</span>
|
||||
<br />
|
||||
Pin:{" "}
|
||||
<span className="font-semibold">{beautifyString(pinName)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{useEnhancedRenderer && outputItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
{enableEnhancedOutputHandling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="enhanced-rendering-toggle"
|
||||
className="cursor-pointer select-none text-sm font-normal text-gray-600"
|
||||
>
|
||||
Enhanced Rendering
|
||||
</label>
|
||||
<Switch
|
||||
id="enhanced-rendering-toggle"
|
||||
checked={useEnhancedRenderer}
|
||||
onCheckedChange={setUseEnhancedRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{data.length > 0 ? (
|
||||
useEnhancedRenderer ? (
|
||||
<div className="space-y-4">
|
||||
{outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Item {index + 1} of {data.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemData =
|
||||
typeof item === "object"
|
||||
? JSON.stringify(item, null, 2)
|
||||
: String(item);
|
||||
navigator.clipboard
|
||||
.writeText(itemData)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Item ${index + 1} copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
Copy Item
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Execution ID: <span className="font-mono text-xs">{execId}</span>
|
||||
<br />
|
||||
Pin:{" "}
|
||||
<span className="font-semibold">{beautifyString(pinName)}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Dialog.Footer className="flex justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.length} item{data.length !== 1 ? "s" : ""} total
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{useEnhancedRenderer && outputItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!useEnhancedRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyData}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
Copy All
|
||||
</Button>
|
||||
)}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{data.length > 0 ? (
|
||||
useEnhancedRenderer ? (
|
||||
<div className="space-y-4">
|
||||
{outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Item {index + 1} of {data.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemData =
|
||||
typeof item === "object"
|
||||
? JSON.stringify(item, null, 2)
|
||||
: String(item);
|
||||
navigator.clipboard
|
||||
.writeText(itemData)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Item ${index + 1} copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
Copy Item
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.length} item{data.length !== 1 ? "s" : ""} total
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!useEnhancedRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyData}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
Copy All
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ConnectionData,
|
||||
CustomNodeData,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
||||
import { NodeTableInput } from "@/components/node-table-input";
|
||||
import {
|
||||
BlockIOArraySubSchema,
|
||||
BlockIOBooleanSubSchema,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import type {
|
||||
CredentialsMetaInput,
|
||||
GraphMeta,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
|
||||
|
||||
interface RunInputDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -64,33 +70,21 @@ export function RunnerInputDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Run your agent"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) doClose();
|
||||
},
|
||||
}}
|
||||
onClose={doClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col p-10">
|
||||
<p className="mt-2 text-sm text-zinc-600">{graph.name}</p>
|
||||
<AgentRunDraftView
|
||||
className="p-0"
|
||||
graph={graph}
|
||||
doRun={doRun ? handleRun : undefined}
|
||||
onRun={doRun ? undefined : doClose}
|
||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||
onCreateSchedule={doCreateSchedule ? undefined : doClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
<Dialog open={isOpen} onOpenChange={doClose}>
|
||||
<DialogContent className="flex w-[90vw] max-w-4xl flex-col p-10">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">Run your agent</DialogTitle>
|
||||
<DialogDescription className="mt-2">{graph.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AgentRunDraftView
|
||||
className="p-0"
|
||||
graph={graph}
|
||||
doRun={doRun ? handleRun : undefined}
|
||||
onRun={doRun ? undefined : doClose}
|
||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||
onCreateSchedule={doCreateSchedule ? undefined : doClose}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
|
||||
import { useGraphStore } from "../stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
draftService,
|
||||
clearTempFlowId,
|
||||
getTempFlowId,
|
||||
} from "@/services/builder-draft/draft-service";
|
||||
|
||||
export type SaveGraphOptions = {
|
||||
showToast?: boolean;
|
||||
@@ -57,19 +52,12 @@ export const useSaveGraph = ({
|
||||
const { mutateAsync: createNewGraph, isPending: isCreating } =
|
||||
usePostV1CreateNewGraph({
|
||||
mutation: {
|
||||
onSuccess: async (response) => {
|
||||
onSuccess: (response) => {
|
||||
const data = response.data as GraphModel;
|
||||
setQueryStates({
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
|
||||
const tempFlowId = getTempFlowId();
|
||||
if (tempFlowId) {
|
||||
await draftService.deleteDraft(tempFlowId);
|
||||
clearTempFlowId();
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
if (showToast) {
|
||||
toast({
|
||||
@@ -94,18 +82,12 @@ export const useSaveGraph = ({
|
||||
const { mutateAsync: updateGraph, isPending: isUpdating } =
|
||||
usePutV1UpdateGraphVersion({
|
||||
mutation: {
|
||||
onSuccess: async (response) => {
|
||||
onSuccess: (response) => {
|
||||
const data = response.data as GraphModel;
|
||||
setQueryStates({
|
||||
flowID: data.id,
|
||||
flowVersion: data.version,
|
||||
});
|
||||
|
||||
// Clear the draft for this flow after successful save
|
||||
if (data.id) {
|
||||
await draftService.deleteDraft(data.id);
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
if (showToast) {
|
||||
toast({
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs";
|
||||
import { useBuilderView } from "./components/BuilderViewTabs/useBuilderViewTabs";
|
||||
import { Flow } from "./components/FlowEditor/Flow/Flow";
|
||||
import { useBuilderView } from "./useBuilderView";
|
||||
|
||||
function BuilderContent() {
|
||||
const query = useSearchParams();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
|
||||
import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
|
||||
import { MarkerType } from "@xyflow/react";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
||||
|
||||
type EdgeStore = {
|
||||
edges: CustomEdge[];
|
||||
@@ -14,8 +13,6 @@ type EdgeStore = {
|
||||
removeEdge: (edgeId: string) => void;
|
||||
upsertMany: (edges: CustomEdge[]) => void;
|
||||
|
||||
removeEdgesByHandlePrefix: (nodeId: string, handlePrefix: string) => void;
|
||||
|
||||
getNodeEdges: (nodeId: string) => CustomEdge[];
|
||||
isInputConnected: (nodeId: string, handle: string) => boolean;
|
||||
isOutputConnected: (nodeId: string, handle: string) => boolean;
|
||||
@@ -82,27 +79,11 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
return { edges: Array.from(byKey.values()) };
|
||||
}),
|
||||
|
||||
removeEdgesByHandlePrefix: (nodeId, handlePrefix) =>
|
||||
set((state) => ({
|
||||
edges: state.edges.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.target === nodeId &&
|
||||
e.targetHandle &&
|
||||
e.targetHandle.startsWith(handlePrefix)
|
||||
),
|
||||
),
|
||||
})),
|
||||
|
||||
getNodeEdges: (nodeId) =>
|
||||
get().edges.filter((e) => e.source === nodeId || e.target === nodeId),
|
||||
|
||||
isInputConnected: (nodeId, handle) => {
|
||||
const cleanedHandle = cleanUpHandleId(handle);
|
||||
return get().edges.some(
|
||||
(e) => e.target === nodeId && e.targetHandle === cleanedHandle,
|
||||
);
|
||||
},
|
||||
isInputConnected: (nodeId, handle) =>
|
||||
get().edges.some((e) => e.target === nodeId && e.targetHandle === handle),
|
||||
|
||||
isOutputConnected: (nodeId, handle) =>
|
||||
get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle),
|
||||
@@ -124,21 +105,20 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
targetNodeId: string,
|
||||
executionResult: NodeExecutionResult,
|
||||
) => {
|
||||
set((state) => {
|
||||
let hasChanges = false;
|
||||
|
||||
const newEdges = state.edges.map((edge) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.map((edge) => {
|
||||
if (edge.target !== targetNodeId) {
|
||||
return edge;
|
||||
}
|
||||
|
||||
const beadData = new Map(edge.data?.beadData ?? new Map());
|
||||
const beadData =
|
||||
edge.data?.beadData ??
|
||||
new Map<string, NodeExecutionResult["status"]>();
|
||||
|
||||
const inputValue = edge.targetHandle
|
||||
? executionResult.input_data[edge.targetHandle]
|
||||
: undefined;
|
||||
|
||||
if (inputValue !== undefined && inputValue !== null) {
|
||||
if (
|
||||
edge.targetHandle &&
|
||||
edge.targetHandle in executionResult.input_data
|
||||
) {
|
||||
beadData.set(executionResult.node_exec_id, executionResult.status);
|
||||
}
|
||||
|
||||
@@ -156,11 +136,6 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
beadUp = beadDown + 1;
|
||||
}
|
||||
|
||||
if (edge.data?.beadUp === beadUp && edge.data?.beadDown === beadDown) {
|
||||
return edge;
|
||||
}
|
||||
|
||||
hasChanges = true;
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
@@ -170,10 +145,8 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
|
||||
beadData,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return hasChanges ? { edges: newEdges } : state;
|
||||
});
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
resetEdgeBeads: () => {
|
||||
|
||||
@@ -13,10 +13,6 @@ import { useHistoryStore } from "./historyStore";
|
||||
import { useEdgeStore } from "./edgeStore";
|
||||
import { BlockUIType } from "../components/types";
|
||||
import { pruneEmptyValues } from "@/lib/utils";
|
||||
import {
|
||||
ensurePathExists,
|
||||
parseHandleIdToPath,
|
||||
} from "@/components/renderers/InputRenderer/helpers";
|
||||
|
||||
// Minimum movement (in pixels) required before logging position change to history
|
||||
// Prevents spamming history with small movements when clicking on inputs inside blocks
|
||||
@@ -66,8 +62,6 @@ type NodeStore = {
|
||||
errors: { [key: string]: string },
|
||||
) => void;
|
||||
clearAllNodeErrors: () => void; // Add this
|
||||
|
||||
syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
@@ -311,35 +305,4 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
syncHardcodedValuesWithHandleIds: (nodeId: string) => {
|
||||
const node = get().nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const handleIds = useEdgeStore.getState().getAllHandleIdsOfANode(nodeId);
|
||||
const additionalHandles = handleIds.filter((h) => h.includes("_#_"));
|
||||
|
||||
if (additionalHandles.length === 0) return;
|
||||
|
||||
const hardcodedValues = JSON.parse(
|
||||
JSON.stringify(node.data.hardcodedValues || {}),
|
||||
);
|
||||
|
||||
let modified = false;
|
||||
|
||||
additionalHandles.forEach((handleId) => {
|
||||
const segments = parseHandleIdToPath(handleId);
|
||||
if (ensurePathExists(hardcodedValues, segments)) {
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, hardcodedValues } } : n,
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -143,7 +143,6 @@ export function CredentialsInput({
|
||||
size="small"
|
||||
onClick={handleActionButtonClick}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
@@ -156,7 +155,6 @@ export function CredentialsInput({
|
||||
size="small"
|
||||
onClick={handleActionButtonClick}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { prefetchGetV2GetAgentByStoreIdQuery } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { Metadata } from "next";
|
||||
import { getServerUser } from "@/lib/supabase/server/getServerUser";
|
||||
import {
|
||||
getV2GetSpecificAgent,
|
||||
prefetchGetV2GetSpecificAgentQuery,
|
||||
prefetchGetV2ListStoreAgentsQuery,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { getServerUser } from "@/lib/supabase/server/getServerUser";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { Metadata } from "next";
|
||||
import { MainAgentPage } from "../../../components/MainAgentPage/MainAgentPage";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { prefetchGetV2GetAgentByStoreIdQuery } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import {
|
||||
getV2GetCreatorDetails,
|
||||
prefetchGetV2GetCreatorDetailsQuery,
|
||||
prefetchGetV2ListStoreAgentsQuery,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { Metadata } from "next";
|
||||
import { MainCreatorPage } from "../../components/MainCreatorPage/MainCreatorPage";
|
||||
import { Metadata } from "next";
|
||||
import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
prefetchGetV2ListStoreAgentsQuery,
|
||||
prefetchGetV2ListStoreCreatorsQuery,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { MainMarkeplacePage } from "./components/MainMarketplacePage/MainMarketplacePage";
|
||||
import { MainMarketplacePageLoading } from "./components/MainMarketplacePageLoading";
|
||||
|
||||
@@ -48,6 +48,11 @@ export const metadata: Metadata = {
|
||||
description: "Find and use AI Agents created by our community",
|
||||
images: ["/images/store-twitter.png"],
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
shortcut: "/favicon-16x16.png",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function MarketplacePage(): Promise<React.ReactElement> {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { providerIcons } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
|
||||
import { providerIcons } from "@/components/renderers/input-renderer/fields/CredentialField/helpers";
|
||||
import { CredentialsProviderName } from "@/lib/autogpt-server-api";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function GlobalError({ error, reset }: Props) {
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
@@ -15,21 +15,9 @@ import { environment } from "@/services/environment";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
const isDev = environment.isDev();
|
||||
const isLocal = environment.isLocal();
|
||||
|
||||
const faviconPath = isDev
|
||||
? "/favicon-dev.ico"
|
||||
: isLocal
|
||||
? "/favicon-local.ico"
|
||||
: "/favicon.ico";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AutoGPT Platform",
|
||||
description: "Your one stop shop to creating AI Agents",
|
||||
icons: {
|
||||
icon: faviconPath,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
@@ -39,6 +27,8 @@ export default async function RootLayout({
|
||||
}>) {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const isDev = environment.isDev();
|
||||
const isLocal = environment.isLocal();
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -47,6 +37,16 @@ export default async function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<link
|
||||
rel="icon"
|
||||
href={
|
||||
isLocal
|
||||
? "/favicon-local.ico"
|
||||
: isDev
|
||||
? "/favicon-dev.ico"
|
||||
: "/favicon.ico"
|
||||
}
|
||||
/>
|
||||
<SetupAnalytics
|
||||
host={host}
|
||||
ga={{
|
||||
|
||||
@@ -49,7 +49,6 @@ export function GoogleDrivePicker(props: Props) {
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleOpenPicker}
|
||||
disabled={props.disabled || isLoading || isAuthInProgress}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cn } from "@/lib/utils";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import React, { useCallback } from "react";
|
||||
import { GoogleDrivePicker } from "./GoogleDrivePicker";
|
||||
import { isValidFile } from "./helpers";
|
||||
|
||||
export interface Props {
|
||||
config: GoogleDrivePickerConfig;
|
||||
@@ -28,15 +27,13 @@ export function GoogleDrivePickerInput({
|
||||
const hasAutoCredentials = !!config.auto_credentials;
|
||||
|
||||
// Strip _credentials_id from value for display purposes
|
||||
// Only show files section when there are valid file objects
|
||||
const currentFiles = React.useMemo(() => {
|
||||
if (isMultiSelect) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isValidFile);
|
||||
}
|
||||
if (!value || !isValidFile(value)) return [];
|
||||
return [value];
|
||||
}, [value, isMultiSelect]);
|
||||
const currentFiles = isMultiSelect
|
||||
? Array.isArray(value)
|
||||
? value
|
||||
: []
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
const handlePicked = useCallback(
|
||||
(files: any[], credentialId?: string) => {
|
||||
@@ -88,27 +85,23 @@ export function GoogleDrivePickerInput({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<div className="mb-4">
|
||||
{/* Picker Button */}
|
||||
<GoogleDrivePicker
|
||||
multiselect={config.multiselect || false}
|
||||
views={config.allowed_views || ["DOCS"]}
|
||||
scopes={
|
||||
config.scopes || ["https://www.googleapis.com/auth/drive.file"]
|
||||
}
|
||||
disabled={false}
|
||||
requirePlatformCredentials={hasAutoCredentials}
|
||||
onPicked={handlePicked}
|
||||
onCanceled={() => {
|
||||
// User canceled - no action needed
|
||||
}}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
{/* Picker Button */}
|
||||
<GoogleDrivePicker
|
||||
multiselect={config.multiselect || false}
|
||||
views={config.allowed_views || ["DOCS"]}
|
||||
scopes={config.scopes || ["https://www.googleapis.com/auth/drive.file"]}
|
||||
disabled={false}
|
||||
requirePlatformCredentials={hasAutoCredentials}
|
||||
onPicked={handlePicked}
|
||||
onCanceled={() => {
|
||||
// User canceled - no action needed
|
||||
}}
|
||||
onError={handleError}
|
||||
/>
|
||||
|
||||
{/* Display Selected Files */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="mb-8 space-y-1">
|
||||
<div className="space-y-1">
|
||||
{currentFiles.map((file: any, idx: number) => (
|
||||
<div
|
||||
key={file.id || idx}
|
||||
|
||||
@@ -119,14 +119,3 @@ export function getCredentialsSchema(scopes: string[]) {
|
||||
secret: true,
|
||||
} satisfies BlockIOCredentialsSubSchema;
|
||||
}
|
||||
|
||||
export function isValidFile(
|
||||
file: unknown,
|
||||
): file is { id?: string; name?: string } {
|
||||
return (
|
||||
typeof file === "object" &&
|
||||
file !== null &&
|
||||
(typeof (file as { id?: unknown }).id === "string" ||
|
||||
typeof (file as { name?: unknown }).name === "string")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export function PublishAgentModal({
|
||||
onStateChange,
|
||||
preSelectedAgentId,
|
||||
preSelectedAgentVersion,
|
||||
showTrigger = true,
|
||||
}: Props) {
|
||||
const {
|
||||
// State
|
||||
@@ -122,11 +121,9 @@ export function PublishAgentModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showTrigger && (
|
||||
<Dialog.Trigger>
|
||||
{trigger || <Button size="small">Publish Agent</Button>}
|
||||
</Dialog.Trigger>
|
||||
)}
|
||||
<Dialog.Trigger>
|
||||
{trigger || <Button size="small">Publish Agent</Button>}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div data-testid="publish-agent-modal">{renderContent()}</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface Props {
|
||||
onStateChange?: (state: PublishState) => void;
|
||||
preSelectedAgentId?: string;
|
||||
preSelectedAgentVersion?: number;
|
||||
showTrigger?: boolean;
|
||||
}
|
||||
|
||||
export function usePublishAgentModal({
|
||||
|
||||
@@ -1,157 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
|
||||
import { PreviewBanner } from "@/components/layout/Navbar/components/PreviewBanner/PreviewBanner";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { environment } from "@/services/environment";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
|
||||
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
|
||||
import { LoginButton } from "./components/LoginButton";
|
||||
import { MobileNavBar } from "./components/MobileNavbar/MobileNavBar";
|
||||
import { NavbarLink } from "./components/NavbarLink";
|
||||
import { NavbarLoading } from "./components/NavbarLoading";
|
||||
import { Wallet } from "./components/Wallet/Wallet";
|
||||
import { getAccountMenuItems, loggedInLinks, loggedOutLinks } from "./helpers";
|
||||
|
||||
export function Navbar() {
|
||||
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
||||
const breakpoint = useBreakpoint();
|
||||
const isSmallScreen = breakpoint === "sm" || breakpoint === "base";
|
||||
const dynamicMenuItems = getAccountMenuItems(user?.role);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
import { NavbarView } from "./components/NavbarView";
|
||||
import { getNavbarAccountData } from "./data";
|
||||
|
||||
export async function Navbar() {
|
||||
const { isLoggedIn } = await getNavbarAccountData();
|
||||
const previewBranchName = environment.getPreviewStealingDev();
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useGetV2GetUserProfile(
|
||||
{
|
||||
query: {
|
||||
select: okData,
|
||||
enabled: isLoggedIn && !!user,
|
||||
// Include user ID in query key to ensure cache invalidation when user changes
|
||||
queryKey: ["/api/store/profile", user?.id],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isLoadingProfile = isProfileLoading || isUserLoading;
|
||||
|
||||
const shouldShowPreviewBanner = Boolean(isLoggedIn && previewBranchName);
|
||||
|
||||
const actualLoggedInLinks =
|
||||
isChatEnabled === true
|
||||
? loggedInLinks.concat([{ name: "Chat", href: "/chat" }])
|
||||
: loggedInLinks;
|
||||
|
||||
if (isUserLoading) {
|
||||
return <NavbarLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-40 w-full">
|
||||
{shouldShowPreviewBanner && previewBranchName ? (
|
||||
<PreviewBanner branchName={previewBranchName} />
|
||||
) : null}
|
||||
<nav className="border-zinc-[#EFEFF0] inline-flex h-[60px] w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]">
|
||||
{/* Left section */}
|
||||
{!isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center gap-5">
|
||||
{isLoggedIn
|
||||
? actualLoggedInLinks.map((link) => (
|
||||
<NavbarLink
|
||||
key={link.name}
|
||||
name={link.name}
|
||||
href={link.href}
|
||||
/>
|
||||
))
|
||||
: loggedOutLinks.map((link) => (
|
||||
<NavbarLink
|
||||
key={link.name}
|
||||
name={link.name}
|
||||
href={link.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Centered logo */}
|
||||
<div className="static h-auto w-[4.5rem] md:absolute md:left-1/2 md:top-1/2 md:w-[5.5rem] md:-translate-x-1/2 md:-translate-y-1/2">
|
||||
<IconAutoGPTLogo className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
{isLoggedIn && !isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<AgentActivityDropdown />
|
||||
{profile && <Wallet key={profile.username} />}
|
||||
<AccountMenu
|
||||
userName={profile?.username}
|
||||
userEmail={profile?.name}
|
||||
avatarSrc={profile?.avatar_url ?? ""}
|
||||
menuItemGroups={dynamicMenuItems}
|
||||
isLoading={isLoadingProfile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : !isLoggedIn ? (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<LoginButton />
|
||||
</div>
|
||||
) : null}
|
||||
{/* <ThemeToggle /> */}
|
||||
</nav>
|
||||
</div>
|
||||
{/* Mobile Navbar - Adjust positioning */}
|
||||
<>
|
||||
{isLoggedIn && isSmallScreen ? (
|
||||
<div className="fixed right-0 top-2 z-50 flex items-center gap-0">
|
||||
<Wallet />
|
||||
<MobileNavBar
|
||||
userName={profile?.username}
|
||||
menuItemGroups={[
|
||||
{
|
||||
groupName: "Navigation",
|
||||
items: actualLoggedInLinks
|
||||
.map((link) => {
|
||||
if (link.name === "Chat" && !isChatEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon:
|
||||
link.name === "Marketplace"
|
||||
? IconType.Marketplace
|
||||
: link.name === "Library"
|
||||
? IconType.Library
|
||||
: link.name === "Build"
|
||||
? IconType.Builder
|
||||
: link.name === "Chat"
|
||||
? IconType.Chat
|
||||
: link.name === "Monitor"
|
||||
? IconType.Library
|
||||
: IconType.LayoutDashboard,
|
||||
text: link.name,
|
||||
href: link.href,
|
||||
};
|
||||
})
|
||||
.filter((item) => item !== null) as Array<{
|
||||
icon: IconType;
|
||||
text: string;
|
||||
href: string;
|
||||
}>,
|
||||
},
|
||||
...dynamicMenuItems,
|
||||
]}
|
||||
userEmail={profile?.name}
|
||||
avatarSrc={profile?.avatar_url ?? ""}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</>
|
||||
<NavbarView isLoggedIn={isLoggedIn} previewBranchName={previewBranchName} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,42 +6,45 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AccountLogoutOption() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
function handleLogout() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await supabase.logOut();
|
||||
router.replace("/login");
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
toast({
|
||||
title: "Error logging out",
|
||||
description:
|
||||
"Something went wrong when logging out. Please try again. If the problem persists, please contact support.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
async function handleLogout() {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await supabase.logOut();
|
||||
router.push("/login");
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
toast({
|
||||
title: "Error logging out",
|
||||
description:
|
||||
"Something went wrong when logging out. Please try again. If the problem persists, please contact support.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoggingOut(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-start gap-2.5",
|
||||
isPending && "justify-center opacity-50",
|
||||
isLoggingOut && "justify-center opacity-50",
|
||||
)}
|
||||
onClick={handleLogout}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isPending ? (
|
||||
{isLoggingOut ? (
|
||||
<LoadingSpinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -5,15 +5,16 @@ export function NavbarLoading() {
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 hidden h-16 items-center rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 p-3 backdrop-blur-[26px] md:inline-flex">
|
||||
<div className="flex flex-1 items-center gap-6">
|
||||
<Skeleton className="h-6 w-24 bg-zinc-200/60" />
|
||||
<Skeleton className="h-6 w-24 bg-zinc-200/60" />
|
||||
<Skeleton className="h-6 w-16 bg-zinc-200/60" />
|
||||
<Skeleton className="h-4 w-20 bg-white/20" />
|
||||
<Skeleton className="h-4 w-16 bg-white/20" />
|
||||
<Skeleton className="h-4 w-12 bg-white/20" />
|
||||
</div>
|
||||
<div className="absolute left-1/2 top-1/2 h-10 w-[88.87px] -translate-x-1/2 -translate-y-1/2">
|
||||
<IconAutoGPTLogo className="h-full w-full" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full bg-zinc-200/60" />
|
||||
<Skeleton className="h-8 w-8 rounded-full bg-white/20" />
|
||||
<Skeleton className="h-8 w-8 rounded-full bg-white/20" />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
|
||||
import { PreviewBanner } from "@/components/layout/Navbar/components/PreviewBanner/PreviewBanner";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useMemo } from "react";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { getAccountMenuItems, loggedInLinks, loggedOutLinks } from "../helpers";
|
||||
import { AccountMenu } from "./AccountMenu/AccountMenu";
|
||||
import { AgentActivityDropdown } from "./AgentActivityDropdown/AgentActivityDropdown";
|
||||
import { LoginButton } from "./LoginButton";
|
||||
import { MobileNavBar } from "./MobileNavbar/MobileNavBar";
|
||||
import { NavbarLink } from "./NavbarLink";
|
||||
import { Wallet } from "./Wallet/Wallet";
|
||||
interface NavbarViewProps {
|
||||
isLoggedIn: boolean;
|
||||
previewBranchName?: string | null;
|
||||
}
|
||||
|
||||
export function NavbarView({ isLoggedIn, previewBranchName }: NavbarViewProps) {
|
||||
const { user } = useSupabase();
|
||||
const breakpoint = useBreakpoint();
|
||||
const isSmallScreen = breakpoint === "sm" || breakpoint === "base";
|
||||
const dynamicMenuItems = getAccountMenuItems(user?.role);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useGetV2GetUserProfile(
|
||||
{
|
||||
query: {
|
||||
select: okData,
|
||||
enabled: isLoggedIn && !!user,
|
||||
// Include user ID in query key to ensure cache invalidation when user changes
|
||||
queryKey: ["/api/store/profile", user?.id],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { isUserLoading } = useSupabase();
|
||||
const isLoadingProfile = isProfileLoading || isUserLoading;
|
||||
|
||||
const linksWithChat = useMemo(() => {
|
||||
const chatLink = { name: "Chat", href: "/chat" };
|
||||
return isChatEnabled ? [...loggedInLinks, chatLink] : loggedInLinks;
|
||||
}, [isChatEnabled]);
|
||||
|
||||
const shouldShowPreviewBanner = Boolean(isLoggedIn && previewBranchName);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-40 w-full">
|
||||
{shouldShowPreviewBanner && previewBranchName ? (
|
||||
<PreviewBanner branchName={previewBranchName} />
|
||||
) : null}
|
||||
<nav className="border-zinc-[#EFEFF0] inline-flex h-[60px] w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]">
|
||||
{/* Left section */}
|
||||
{!isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center gap-5">
|
||||
{isLoggedIn
|
||||
? linksWithChat.map((link) => (
|
||||
<NavbarLink
|
||||
key={link.name}
|
||||
name={link.name}
|
||||
href={link.href}
|
||||
/>
|
||||
))
|
||||
: loggedOutLinks.map((link) => (
|
||||
<NavbarLink
|
||||
key={link.name}
|
||||
name={link.name}
|
||||
href={link.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Centered logo */}
|
||||
<div className="static h-auto w-[4.5rem] md:absolute md:left-1/2 md:top-1/2 md:w-[5.5rem] md:-translate-x-1/2 md:-translate-y-1/2">
|
||||
<IconAutoGPTLogo className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
{isLoggedIn && !isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<AgentActivityDropdown />
|
||||
{profile && <Wallet key={profile.username} />}
|
||||
<AccountMenu
|
||||
userName={profile?.username}
|
||||
userEmail={profile?.name}
|
||||
avatarSrc={profile?.avatar_url ?? ""}
|
||||
menuItemGroups={dynamicMenuItems}
|
||||
isLoading={isLoadingProfile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : !isLoggedIn ? (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<LoginButton />
|
||||
</div>
|
||||
) : null}
|
||||
{/* <ThemeToggle /> */}
|
||||
</nav>
|
||||
</div>
|
||||
{/* Mobile Navbar - Adjust positioning */}
|
||||
<>
|
||||
{isLoggedIn && isSmallScreen ? (
|
||||
<div className="fixed right-0 top-2 z-50 flex items-center gap-0">
|
||||
<Wallet />
|
||||
<MobileNavBar
|
||||
userName={profile?.username}
|
||||
menuItemGroups={[
|
||||
{
|
||||
groupName: "Navigation",
|
||||
items: linksWithChat.map((link) => ({
|
||||
icon:
|
||||
link.name === "Marketplace"
|
||||
? IconType.Marketplace
|
||||
: link.name === "Library"
|
||||
? IconType.Library
|
||||
: link.name === "Build"
|
||||
? IconType.Builder
|
||||
: link.name === "Chat"
|
||||
? IconType.Chat
|
||||
: link.name === "Monitor"
|
||||
? IconType.Library
|
||||
: IconType.LayoutDashboard,
|
||||
text: link.name,
|
||||
href: link.href,
|
||||
})),
|
||||
},
|
||||
...dynamicMenuItems,
|
||||
]}
|
||||
userEmail={profile?.name}
|
||||
avatarSrc={profile?.avatar_url ?? ""}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { prefetchGetV2GetUserProfileQuery } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { getServerUser } from "@/lib/supabase/server/getServerUser";
|
||||
|
||||
export async function getNavbarAccountData() {
|
||||
const { user } = await getServerUser();
|
||||
const isLoggedIn = Boolean(user);
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return {
|
||||
profile: null,
|
||||
isLoggedIn,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await prefetchGetV2GetUserProfileQuery(queryClient);
|
||||
} catch (error) {
|
||||
console.error("Error fetching profile:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
import React, { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||
import {
|
||||
BlockIOTableSubSchema,
|
||||
TableCellValue,
|
||||
TableRow,
|
||||
TableCellValue,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "../../../../../components/atoms/Button/Button";
|
||||
import { Input } from "../../../../../components/atoms/Input/Input";
|
||||
import { Input } from "./atoms/Input/Input";
|
||||
import { Button } from "./atoms/Button/Button";
|
||||
|
||||
interface NodeTableInputProps {
|
||||
/** Unique identifier for the node in the builder graph */
|
||||
@@ -1,86 +0,0 @@
|
||||
import { FieldProps, getUiOptions, getWidget } from "@rjsf/utils";
|
||||
import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useAnyOfField } from "./useAnyOfField";
|
||||
import { getHandleId, updateUiOption } from "../../helpers";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { ANY_OF_FLAG } from "../../constants";
|
||||
|
||||
export const AnyOfField = (props: FieldProps) => {
|
||||
const { registry, schema } = props;
|
||||
const { fields } = registry;
|
||||
const { SchemaField: _SchemaField } = fields;
|
||||
const { nodeId } = registry.formContext;
|
||||
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
|
||||
const uiOptions = getUiOptions(props.uiSchema, props.globalUiOptions);
|
||||
|
||||
const Widget = getWidget({ type: "string" }, "select", registry.widgets);
|
||||
|
||||
const {
|
||||
handleOptionChange,
|
||||
enumOptions,
|
||||
selectedOption,
|
||||
optionSchema,
|
||||
field_id,
|
||||
} = useAnyOfField(props);
|
||||
|
||||
const handleId = getHandleId({
|
||||
uiOptions,
|
||||
id: field_id + ANY_OF_FLAG,
|
||||
schema: schema,
|
||||
});
|
||||
|
||||
const updatedUiSchema = updateUiOption(props.uiSchema, {
|
||||
handleId: handleId,
|
||||
label: false,
|
||||
fromAnyOf: true,
|
||||
});
|
||||
|
||||
const isHandleConnected = isInputConnected(nodeId, handleId);
|
||||
|
||||
const optionsSchemaField =
|
||||
(optionSchema && optionSchema.type !== "null" && (
|
||||
<_SchemaField
|
||||
{...props}
|
||||
schema={optionSchema}
|
||||
uiSchema={updatedUiSchema}
|
||||
/>
|
||||
)) ||
|
||||
null;
|
||||
|
||||
const selector = (
|
||||
<Widget
|
||||
id={field_id}
|
||||
name={`${props.name}${schema.oneOf ? "__oneof_select" : "__anyof_select"}`}
|
||||
schema={{ type: "number", default: 0 }}
|
||||
onChange={handleOptionChange}
|
||||
onBlur={props.onBlur}
|
||||
onFocus={props.onFocus}
|
||||
disabled={props.disabled || isEmpty(enumOptions)}
|
||||
multiple={false}
|
||||
value={selectedOption >= 0 ? selectedOption : undefined}
|
||||
options={{ enumOptions }}
|
||||
registry={registry}
|
||||
placeholder={props.placeholder}
|
||||
autocomplete={props.autocomplete}
|
||||
className="-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium"
|
||||
autofocus={props.autofocus}
|
||||
label=""
|
||||
hideLabel={true}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnyOfFieldTitle
|
||||
{...props}
|
||||
selector={selector}
|
||||
uiSchema={updatedUiSchema}
|
||||
/>
|
||||
{!isHandleConnected && optionsSchemaField}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
descriptionId,
|
||||
FieldProps,
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
titleId,
|
||||
} from "@rjsf/utils";
|
||||
import { shouldShowTypeSelector } from "../helpers";
|
||||
import { useIsArrayItem } from "../../array/context/array-item-context";
|
||||
import { cleanUpHandleId } from "../../../helpers";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { isOptionalType } from "../../../utils/schema-utils";
|
||||
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface customFieldProps extends FieldProps {
|
||||
selector: JSX.Element;
|
||||
}
|
||||
|
||||
export const AnyOfFieldTitle = (props: customFieldProps) => {
|
||||
const { uiSchema, schema, required, name, registry, fieldPathId, selector } =
|
||||
props;
|
||||
const { isInputConnected } = useEdgeStore();
|
||||
const { nodeId } = registry.formContext;
|
||||
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const TitleFieldTemplate = getTemplate(
|
||||
"TitleFieldTemplate",
|
||||
registry,
|
||||
uiOptions,
|
||||
);
|
||||
const DescriptionFieldTemplate = getTemplate(
|
||||
"DescriptionFieldTemplate",
|
||||
registry,
|
||||
uiOptions,
|
||||
);
|
||||
|
||||
const title_id = titleId(fieldPathId ?? "");
|
||||
const description_id = descriptionId(fieldPathId ?? "");
|
||||
|
||||
const isArrayItem = useIsArrayItem();
|
||||
|
||||
const handleId = cleanUpHandleId(uiOptions.handleId);
|
||||
const isHandleConnected = isInputConnected(nodeId, handleId);
|
||||
|
||||
const { isOptional, type } = isOptionalType(schema); // If we have something like int | null = we will treat it as optional int
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(type);
|
||||
|
||||
const shouldShowSelector =
|
||||
shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected;
|
||||
const shoudlShowType = isHandleConnected || (isOptional && type);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TitleFieldTemplate
|
||||
id={title_id}
|
||||
title={schema.title || name || ""}
|
||||
required={required}
|
||||
schema={schema}
|
||||
registry={registry}
|
||||
uiSchema={uiSchema}
|
||||
/>
|
||||
{shoudlShowType && (
|
||||
<Text variant="small" className={cn("text-zinc-700", colorClass)}>
|
||||
{isOptional ? `(${displayType})` : "(any)"}
|
||||
</Text>
|
||||
)}
|
||||
{shouldShowSelector && selector}
|
||||
<DescriptionFieldTemplate
|
||||
id={description_id}
|
||||
description={schema.description || ""}
|
||||
schema={schema}
|
||||
registry={registry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { RJSFSchema, StrictRJSFSchema } from "@rjsf/utils";
|
||||
|
||||
const TYPE_PRIORITY = [
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"array",
|
||||
"object",
|
||||
] as const;
|
||||
|
||||
export function getDefaultTypeIndex(options: StrictRJSFSchema[]): number {
|
||||
for (const preferredType of TYPE_PRIORITY) {
|
||||
const index = options.findIndex((opt) => opt.type === preferredType);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
|
||||
const nonNullIndex = options.findIndex((opt) => opt.type !== "null");
|
||||
return nonNullIndex >= 0 ? nonNullIndex : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a type selector should be shown for an anyOf schema
|
||||
* Returns false for simple optional types (type | null)
|
||||
* Returns true for complex anyOf (3+ types or multiple non-null types)
|
||||
*/
|
||||
export function shouldShowTypeSelector(
|
||||
schema: RJSFSchema | undefined,
|
||||
): boolean {
|
||||
const anyOf = schema?.anyOf;
|
||||
if (!anyOf || !Array.isArray(anyOf) || anyOf.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (anyOf.length === 2 && anyOf.some((opt: any) => opt.type === "null")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return anyOf.length >= 3;
|
||||
}
|
||||
|
||||
export function isSimpleOptional(schema: RJSFSchema | undefined): boolean {
|
||||
const anyOf = schema?.anyOf;
|
||||
return (
|
||||
Array.isArray(anyOf) &&
|
||||
anyOf.length === 2 &&
|
||||
anyOf.some((opt: any) => opt.type === "null")
|
||||
);
|
||||
}
|
||||
|
||||
export function getOptionalType(
|
||||
schema: RJSFSchema | undefined,
|
||||
): string | undefined {
|
||||
if (!isSimpleOptional(schema)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const anyOf = schema?.anyOf;
|
||||
const nonNullOption = anyOf?.find((opt: any) => opt.type !== "null");
|
||||
return nonNullOption ? (nonNullOption as any).type : undefined;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { FieldProps, getFirstMatchingOption, mergeSchemas } from "@rjsf/utils";
|
||||
import { useRef, useState } from "react";
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import { getDefaultTypeIndex } from "./helpers";
|
||||
import { cleanUpHandleId } from "../../helpers";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
|
||||
export const useAnyOfField = (props: FieldProps) => {
|
||||
const { registry, schema, options, onChange, formData } = props;
|
||||
const { schemaUtils } = registry;
|
||||
|
||||
const getInitialOption = () => {
|
||||
if (formData !== undefined && formData !== null) {
|
||||
const option = getFirstMatchingOption(
|
||||
validator,
|
||||
formData,
|
||||
options,
|
||||
schema,
|
||||
);
|
||||
return option !== undefined ? option : getDefaultTypeIndex(options);
|
||||
}
|
||||
return getDefaultTypeIndex(options);
|
||||
};
|
||||
|
||||
const [selectedOption, setSelectedOption] =
|
||||
useState<number>(getInitialOption());
|
||||
const retrievedOptions = useRef<any[]>(
|
||||
options.map((opt: any) => schemaUtils.retrieveSchema(opt, formData)),
|
||||
);
|
||||
|
||||
const option =
|
||||
selectedOption >= 0
|
||||
? retrievedOptions.current[selectedOption] || null
|
||||
: null;
|
||||
let optionSchema: any | undefined | null;
|
||||
|
||||
// adding top level required to each option schema
|
||||
if (option) {
|
||||
const { required } = schema;
|
||||
optionSchema = required
|
||||
? (mergeSchemas({ required }, option) as any)
|
||||
: option;
|
||||
}
|
||||
|
||||
const field_id = props.fieldPathId.$id;
|
||||
|
||||
const handleOptionChange = (option?: string) => {
|
||||
const intOption = option !== undefined ? parseInt(option, 10) : -1;
|
||||
if (intOption === selectedOption) return;
|
||||
|
||||
const newOption =
|
||||
intOption >= 0 ? retrievedOptions.current[intOption] : undefined;
|
||||
const oldOption =
|
||||
selectedOption >= 0
|
||||
? retrievedOptions.current[selectedOption]
|
||||
: undefined;
|
||||
|
||||
// When we change the option, we need to clean the form data
|
||||
let newFormData = schemaUtils.sanitizeDataForNewSchema(
|
||||
newOption,
|
||||
oldOption,
|
||||
formData,
|
||||
);
|
||||
|
||||
const handlePrefix = cleanUpHandleId(field_id);
|
||||
console.log("handlePrefix", handlePrefix);
|
||||
useEdgeStore
|
||||
.getState()
|
||||
.removeEdgesByHandlePrefix(registry.formContext.nodeId, handlePrefix);
|
||||
|
||||
// We have cleaned the form data, now we need to get the default form state of new selected option
|
||||
if (newOption) {
|
||||
newFormData = schemaUtils.getDefaultFormState(
|
||||
newOption,
|
||||
newFormData,
|
||||
"excludeObjectChildren",
|
||||
) as any;
|
||||
}
|
||||
|
||||
setSelectedOption(intOption);
|
||||
onChange(newFormData, props.fieldPathId.path, undefined, field_id);
|
||||
};
|
||||
|
||||
const enumOptions = retrievedOptions.current.map((option, index) => ({
|
||||
value: index,
|
||||
label: option.type,
|
||||
}));
|
||||
|
||||
return {
|
||||
handleOptionChange,
|
||||
enumOptions,
|
||||
selectedOption,
|
||||
optionSchema,
|
||||
field_id,
|
||||
};
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
ArrayFieldItemTemplateProps,
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
} from "@rjsf/utils";
|
||||
|
||||
export default function ArrayFieldItemTemplate(
|
||||
props: ArrayFieldItemTemplateProps,
|
||||
) {
|
||||
const { children, buttonsProps, hasToolbar, uiSchema, registry } = props;
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const ArrayFieldItemButtonsTemplate = getTemplate(
|
||||
"ArrayFieldItemButtonsTemplate",
|
||||
registry,
|
||||
uiOptions,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center">
|
||||
<div className="shrink grow">
|
||||
<div className="shrink grow">{children}</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
{hasToolbar && (
|
||||
<div className="-mt-4 mb-2 flex gap-2">
|
||||
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import {
|
||||
ArrayFieldTemplateProps,
|
||||
buttonId,
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
} from "@rjsf/utils";
|
||||
import { getHandleId, updateUiOption } from "../../helpers";
|
||||
|
||||
export default function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
|
||||
const {
|
||||
canAdd,
|
||||
disabled,
|
||||
fieldPathId,
|
||||
uiSchema,
|
||||
items,
|
||||
optionalDataControl,
|
||||
onAddClick,
|
||||
readonly,
|
||||
registry,
|
||||
required,
|
||||
schema,
|
||||
title,
|
||||
} = props;
|
||||
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
|
||||
const ArrayFieldDescriptionTemplate = getTemplate(
|
||||
"ArrayFieldDescriptionTemplate",
|
||||
registry,
|
||||
uiOptions,
|
||||
);
|
||||
const ArrayFieldTitleTemplate = getTemplate(
|
||||
"ArrayFieldTitleTemplate",
|
||||
registry,
|
||||
uiOptions,
|
||||
);
|
||||
const showOptionalDataControlInTitle = !readonly && !disabled;
|
||||
|
||||
const {
|
||||
ButtonTemplates: { AddButton },
|
||||
} = registry.templates;
|
||||
|
||||
const { fromAnyOf } = uiOptions;
|
||||
|
||||
const handleId = getHandleId({
|
||||
uiOptions,
|
||||
id: fieldPathId.$id,
|
||||
schema: schema,
|
||||
});
|
||||
const updatedUiSchema = updateUiOption(uiSchema, {
|
||||
handleId: handleId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="m-0 flex p-0">
|
||||
<div className="m-0 w-full space-y-4 p-0">
|
||||
{!fromAnyOf && (
|
||||
<div className="flex items-center">
|
||||
<ArrayFieldTitleTemplate
|
||||
fieldPathId={fieldPathId}
|
||||
title={uiOptions.title || title}
|
||||
schema={schema}
|
||||
uiSchema={updatedUiSchema}
|
||||
required={required}
|
||||
registry={registry}
|
||||
optionalDataControl={
|
||||
showOptionalDataControlInTitle
|
||||
? optionalDataControl
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ArrayFieldDescriptionTemplate
|
||||
fieldPathId={fieldPathId}
|
||||
description={uiOptions.description || schema.description}
|
||||
schema={schema}
|
||||
uiSchema={updatedUiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={`array-item-list-${fieldPathId.$id}`}
|
||||
className="m-0 mb-2 w-full p-0"
|
||||
>
|
||||
{!showOptionalDataControlInTitle ? optionalDataControl : undefined}
|
||||
{items}
|
||||
{canAdd && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<AddButton
|
||||
id={buttonId(fieldPathId, "add")}
|
||||
className="rjsf-array-item-add"
|
||||
onClick={onAddClick}
|
||||
disabled={disabled || readonly}
|
||||
uiSchema={updatedUiSchema}
|
||||
registry={registry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { FieldProps, getUiOptions } from "@rjsf/utils";
|
||||
import { getHandleId, updateUiOption } from "../../helpers";
|
||||
import { ARRAY_ITEM_FLAG } from "../../constants";
|
||||
|
||||
const ArraySchemaField = (props: FieldProps) => {
|
||||
const { index, registry, fieldPathId } = props;
|
||||
const { SchemaField } = registry.fields;
|
||||
|
||||
const uiOptions = getUiOptions(props.uiSchema);
|
||||
|
||||
const handleId = getHandleId({
|
||||
uiOptions,
|
||||
id: fieldPathId.$id,
|
||||
schema: props.schema,
|
||||
});
|
||||
const updatedUiSchema = updateUiOption(props.uiSchema, {
|
||||
handleId: handleId + ARRAY_ITEM_FLAG,
|
||||
});
|
||||
|
||||
return (
|
||||
<SchemaField
|
||||
{...props}
|
||||
uiSchema={updatedUiSchema}
|
||||
title={"_item-" + index.toString()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArraySchemaField;
|
||||
@@ -1,33 +0,0 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
interface ArrayItemContextValue {
|
||||
isArrayItem: boolean;
|
||||
arrayItemHandleId: string;
|
||||
}
|
||||
|
||||
const ArrayItemContext = createContext<ArrayItemContextValue>({
|
||||
isArrayItem: false,
|
||||
arrayItemHandleId: "",
|
||||
});
|
||||
|
||||
export const ArrayItemProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
arrayItemHandleId: string;
|
||||
}> = ({ children, arrayItemHandleId }) => {
|
||||
return (
|
||||
<ArrayItemContext.Provider value={{ isArrayItem: true, arrayItemHandleId }}>
|
||||
{children}
|
||||
</ArrayItemContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useIsArrayItem = (): boolean => {
|
||||
// here this will be true if field is inside an array
|
||||
const context = useContext(ArrayItemContext);
|
||||
return context.isArrayItem;
|
||||
};
|
||||
|
||||
export const useArrayItemHandleId = (): string => {
|
||||
const context = useContext(ArrayItemContext);
|
||||
return context.arrayItemHandleId;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const generateArrayItemHandleId = (id: string) => {
|
||||
return `array-item-${id}`;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export { default as ArrayFieldTemplate } from "./ArrayFieldTemplate";
|
||||
export { default as ArrayFieldItemTemplate } from "./ArrayFieldItemTemplate";
|
||||
export { default as ArraySchemaField } from "./ArraySchemaField";
|
||||
export {
|
||||
ArrayItemProvider,
|
||||
useIsArrayItem,
|
||||
} from "./context/array-item-context";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user