mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-24 03:00:28 -05:00
Compare commits
8 Commits
fix/agent-
...
docs/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c01eb4fc8 | ||
|
|
ef42b17e3b | ||
|
|
a18ffd0b21 | ||
|
|
e40c8c70ce | ||
|
|
9cdcd6793f | ||
|
|
2d7431bde6 | ||
|
|
e934df3c0c | ||
|
|
8d557d33e1 |
@@ -27,7 +27,6 @@ class ChatConfig(BaseSettings):
|
|||||||
session_ttl: int = Field(default=43200, description="Session TTL in seconds")
|
session_ttl: int = Field(default=43200, description="Session TTL in seconds")
|
||||||
|
|
||||||
# Streaming Configuration
|
# Streaming Configuration
|
||||||
stream_timeout: int = Field(default=300, description="Stream timeout in seconds")
|
|
||||||
max_retries: int = Field(
|
max_retries: int = Field(
|
||||||
default=3,
|
default=3,
|
||||||
description="Max retries for fallback path (SDK handles retries internally)",
|
description="Max retries for fallback path (SDK handles retries internally)",
|
||||||
@@ -39,8 +38,10 @@ class ChatConfig(BaseSettings):
|
|||||||
|
|
||||||
# Long-running operation configuration
|
# Long-running operation configuration
|
||||||
long_running_operation_ttl: int = Field(
|
long_running_operation_ttl: int = Field(
|
||||||
default=600,
|
default=3600,
|
||||||
description="TTL in seconds for long-running operation tracking in Redis (safety net if pod dies)",
|
description="TTL in seconds for long-running operation deduplication lock "
|
||||||
|
"(1 hour, matches stream_ttl). Prevents duplicate operations if pod dies. "
|
||||||
|
"For longer operations, the stream_registry heartbeat keeps them alive.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream registry configuration for SSE reconnection
|
# Stream registry configuration for SSE reconnection
|
||||||
@@ -48,6 +49,11 @@ class ChatConfig(BaseSettings):
|
|||||||
default=3600,
|
default=3600,
|
||||||
description="TTL in seconds for stream data in Redis (1 hour)",
|
description="TTL in seconds for stream data in Redis (1 hour)",
|
||||||
)
|
)
|
||||||
|
stream_lock_ttl: int = Field(
|
||||||
|
default=120,
|
||||||
|
description="TTL in seconds for stream lock (2 minutes). Short timeout allows "
|
||||||
|
"reconnection after refresh/crash without long waits.",
|
||||||
|
)
|
||||||
stream_max_length: int = Field(
|
stream_max_length: int = Field(
|
||||||
default=10000,
|
default=10000,
|
||||||
description="Maximum number of messages to store per stream",
|
description="Maximum number of messages to store per stream",
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
|
from prisma.errors import UniqueViolationError
|
||||||
from prisma.models import ChatMessage as PrismaChatMessage
|
from prisma.models import ChatMessage as PrismaChatMessage
|
||||||
from prisma.models import ChatSession as PrismaChatSession
|
from prisma.models import ChatSession as PrismaChatSession
|
||||||
from prisma.types import (
|
from prisma.types import (
|
||||||
@@ -92,10 +93,9 @@ async def add_chat_message(
|
|||||||
function_call: dict[str, Any] | None = None,
|
function_call: dict[str, Any] | None = None,
|
||||||
) -> ChatMessage:
|
) -> ChatMessage:
|
||||||
"""Add a message to a chat session."""
|
"""Add a message to a chat session."""
|
||||||
# Build input dict dynamically rather than using ChatMessageCreateInput directly
|
# Build ChatMessageCreateInput with only non-None values
|
||||||
# because Prisma's TypedDict validation rejects optional fields set to None.
|
# (Prisma TypedDict rejects optional fields set to None)
|
||||||
# We only include fields that have values, then cast at the end.
|
data: ChatMessageCreateInput = {
|
||||||
data: dict[str, Any] = {
|
|
||||||
"Session": {"connect": {"id": session_id}},
|
"Session": {"connect": {"id": session_id}},
|
||||||
"role": role,
|
"role": role,
|
||||||
"sequence": sequence,
|
"sequence": sequence,
|
||||||
@@ -123,7 +123,7 @@ async def add_chat_message(
|
|||||||
where={"id": session_id},
|
where={"id": session_id},
|
||||||
data={"updatedAt": datetime.now(UTC)},
|
data={"updatedAt": datetime.now(UTC)},
|
||||||
),
|
),
|
||||||
PrismaChatMessage.prisma().create(data=cast(ChatMessageCreateInput, data)),
|
PrismaChatMessage.prisma().create(data=data),
|
||||||
)
|
)
|
||||||
return ChatMessage.from_db(message)
|
return ChatMessage.from_db(message)
|
||||||
|
|
||||||
@@ -132,58 +132,93 @@ async def add_chat_messages_batch(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
start_sequence: int,
|
start_sequence: int,
|
||||||
) -> list[ChatMessage]:
|
) -> int:
|
||||||
"""Add multiple messages to a chat session in a batch.
|
"""Add multiple messages to a chat session in a batch.
|
||||||
|
|
||||||
Uses a transaction for atomicity - if any message creation fails,
|
Uses collision detection with retry: tries to create messages starting
|
||||||
the entire batch is rolled back.
|
at start_sequence. If a unique constraint violation occurs (e.g., the
|
||||||
|
streaming loop and long-running callback race), queries the latest
|
||||||
|
sequence and retries with the correct offset. This avoids unnecessary
|
||||||
|
upserts and DB queries in the common case (no collision).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next sequence number for the next message to be inserted. This equals
|
||||||
|
start_sequence + len(messages) and allows callers to update their
|
||||||
|
counters even when collision detection adjusts start_sequence.
|
||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return []
|
# No messages to add - return current count
|
||||||
|
return start_sequence
|
||||||
|
|
||||||
created_messages = []
|
max_retries = 5
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
# Single timestamp for all messages and session update
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
async with db.transaction() as tx:
|
async with db.transaction() as tx:
|
||||||
for i, msg in enumerate(messages):
|
# Build all message data
|
||||||
# Build input dict dynamically rather than using ChatMessageCreateInput
|
messages_data = []
|
||||||
# directly because Prisma's TypedDict validation rejects optional fields
|
for i, msg in enumerate(messages):
|
||||||
# set to None. We only include fields that have values, then cast.
|
# Build ChatMessageCreateInput with only non-None values
|
||||||
data: dict[str, Any] = {
|
# (Prisma TypedDict rejects optional fields set to None)
|
||||||
"Session": {"connect": {"id": session_id}},
|
# Note: create_many doesn't support nested creates, use sessionId directly
|
||||||
"role": msg["role"],
|
data: ChatMessageCreateInput = {
|
||||||
"sequence": start_sequence + i,
|
"sessionId": session_id,
|
||||||
}
|
"role": msg["role"],
|
||||||
|
"sequence": start_sequence + i,
|
||||||
|
"createdAt": now,
|
||||||
|
}
|
||||||
|
|
||||||
# Add optional string fields
|
# Add optional string fields
|
||||||
if msg.get("content") is not None:
|
if msg.get("content") is not None:
|
||||||
data["content"] = msg["content"]
|
data["content"] = msg["content"]
|
||||||
if msg.get("name") is not None:
|
if msg.get("name") is not None:
|
||||||
data["name"] = msg["name"]
|
data["name"] = msg["name"]
|
||||||
if msg.get("tool_call_id") is not None:
|
if msg.get("tool_call_id") is not None:
|
||||||
data["toolCallId"] = msg["tool_call_id"]
|
data["toolCallId"] = msg["tool_call_id"]
|
||||||
if msg.get("refusal") is not None:
|
if msg.get("refusal") is not None:
|
||||||
data["refusal"] = msg["refusal"]
|
data["refusal"] = msg["refusal"]
|
||||||
|
|
||||||
# Add optional JSON fields only when they have values
|
# Add optional JSON fields only when they have values
|
||||||
if msg.get("tool_calls") is not None:
|
if msg.get("tool_calls") is not None:
|
||||||
data["toolCalls"] = SafeJson(msg["tool_calls"])
|
data["toolCalls"] = SafeJson(msg["tool_calls"])
|
||||||
if msg.get("function_call") is not None:
|
if msg.get("function_call") is not None:
|
||||||
data["functionCall"] = SafeJson(msg["function_call"])
|
data["functionCall"] = SafeJson(msg["function_call"])
|
||||||
|
|
||||||
created = await PrismaChatMessage.prisma(tx).create(
|
messages_data.append(data)
|
||||||
data=cast(ChatMessageCreateInput, data)
|
|
||||||
)
|
|
||||||
created_messages.append(created)
|
|
||||||
|
|
||||||
# Update session's updatedAt timestamp within the same transaction.
|
# Run create_many and session update in parallel within transaction
|
||||||
# Note: Token usage (total_prompt_tokens, total_completion_tokens) is updated
|
# Both use the same timestamp for consistency
|
||||||
# separately via update_chat_session() after streaming completes.
|
await asyncio.gather(
|
||||||
await PrismaChatSession.prisma(tx).update(
|
PrismaChatMessage.prisma(tx).create_many(data=messages_data),
|
||||||
where={"id": session_id},
|
PrismaChatSession.prisma(tx).update(
|
||||||
data={"updatedAt": datetime.now(UTC)},
|
where={"id": session_id},
|
||||||
)
|
data={"updatedAt": now},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return [ChatMessage.from_db(m) for m in created_messages]
|
# Return next sequence number for counter sync
|
||||||
|
return start_sequence + len(messages)
|
||||||
|
|
||||||
|
except UniqueViolationError:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
# Collision detected - query MAX(sequence)+1 and retry with correct offset
|
||||||
|
logger.info(
|
||||||
|
f"Collision detected for session {session_id} at sequence "
|
||||||
|
f"{start_sequence}, querying DB for latest sequence"
|
||||||
|
)
|
||||||
|
start_sequence = await get_next_sequence(session_id)
|
||||||
|
logger.info(
|
||||||
|
f"Retrying batch insert with start_sequence={start_sequence}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Max retries exceeded - propagate error
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Should never reach here due to raise in exception handler
|
||||||
|
raise RuntimeError(f"Failed to insert messages after {max_retries} attempts")
|
||||||
|
|
||||||
|
|
||||||
async def get_user_chat_sessions(
|
async def get_user_chat_sessions(
|
||||||
@@ -237,10 +272,20 @@ async def delete_chat_session(session_id: str, user_id: str | None = None) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_session_message_count(session_id: str) -> int:
|
async def get_next_sequence(session_id: str) -> int:
|
||||||
"""Get the number of messages in a chat session."""
|
"""Get the next sequence number for a new message in this session.
|
||||||
count = await PrismaChatMessage.prisma().count(where={"sessionId": session_id})
|
|
||||||
return count
|
Uses MAX(sequence) + 1 for robustness. Returns 0 if no messages exist.
|
||||||
|
More robust than COUNT(*) because it's immune to deleted messages.
|
||||||
|
|
||||||
|
Optimized to select only the sequence column using raw SQL.
|
||||||
|
The unique index on (sessionId, sequence) makes this query fast.
|
||||||
|
"""
|
||||||
|
results = await db.query_raw_with_schema(
|
||||||
|
'SELECT "sequence" FROM {schema_prefix}"ChatMessage" WHERE "sessionId" = $1 ORDER BY "sequence" DESC LIMIT 1',
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
return 0 if not results else results[0]["sequence"] + 1
|
||||||
|
|
||||||
|
|
||||||
async def update_tool_message_content(
|
async def update_tool_message_content(
|
||||||
|
|||||||
@@ -266,7 +266,11 @@ class CoPilotProcessor:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
log.info("Task cancelled")
|
log.info("Task cancelled")
|
||||||
await stream_registry.mark_task_completed(entry.task_id, status="failed")
|
await stream_registry.mark_task_completed(
|
||||||
|
entry.task_id,
|
||||||
|
status="failed",
|
||||||
|
error_message="Task was cancelled",
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -434,8 +434,6 @@ async def _get_session_from_db(session_id: str) -> ChatSession | None:
|
|||||||
|
|
||||||
async def upsert_chat_session(
|
async def upsert_chat_session(
|
||||||
session: ChatSession,
|
session: ChatSession,
|
||||||
*,
|
|
||||||
existing_message_count: int | None = None,
|
|
||||||
) -> ChatSession:
|
) -> ChatSession:
|
||||||
"""Update a chat session in both cache and database.
|
"""Update a chat session in both cache and database.
|
||||||
|
|
||||||
@@ -443,12 +441,6 @@ async def upsert_chat_session(
|
|||||||
operations (e.g., background title update and main stream handler)
|
operations (e.g., background title update and main stream handler)
|
||||||
attempt to upsert the same session simultaneously.
|
attempt to upsert the same session simultaneously.
|
||||||
|
|
||||||
Args:
|
|
||||||
existing_message_count: If provided, skip the DB query to count
|
|
||||||
existing messages. The caller is responsible for tracking this
|
|
||||||
accurately. Useful for incremental saves in a streaming loop
|
|
||||||
where the caller already knows how many messages are persisted.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DatabaseError: If the database write fails. The cache is still updated
|
DatabaseError: If the database write fails. The cache is still updated
|
||||||
as a best-effort optimization, but the error is propagated to ensure
|
as a best-effort optimization, but the error is propagated to ensure
|
||||||
@@ -459,11 +451,8 @@ async def upsert_chat_session(
|
|||||||
lock = await _get_session_lock(session.session_id)
|
lock = await _get_session_lock(session.session_id)
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
# Get existing message count from DB for incremental saves
|
# Always query DB for existing message count to ensure consistency
|
||||||
if existing_message_count is None:
|
existing_message_count = await chat_db().get_next_sequence(session.session_id)
|
||||||
existing_message_count = await chat_db().get_chat_session_message_count(
|
|
||||||
session.session_id
|
|
||||||
)
|
|
||||||
|
|
||||||
db_error: Exception | None = None
|
db_error: Exception | None = None
|
||||||
|
|
||||||
@@ -587,9 +576,7 @@ async def append_and_save_message(session_id: str, message: ChatMessage) -> Chat
|
|||||||
raise ValueError(f"Session {session_id} not found")
|
raise ValueError(f"Session {session_id} not found")
|
||||||
|
|
||||||
session.messages.append(message)
|
session.messages.append(message)
|
||||||
existing_message_count = await chat_db().get_chat_session_message_count(
|
existing_message_count = await chat_db().get_next_sequence(session_id)
|
||||||
session_id
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await _save_session_to_db(session, existing_message_count)
|
await _save_session_to_db(session, existing_message_count)
|
||||||
|
|||||||
@@ -331,3 +331,96 @@ def test_to_openai_messages_merges_split_assistants():
|
|||||||
tc_list = merged.get("tool_calls")
|
tc_list = merged.get("tool_calls")
|
||||||
assert tc_list is not None and len(list(tc_list)) == 1
|
assert tc_list is not None and len(list(tc_list)) == 1
|
||||||
assert list(tc_list)[0]["id"] == "tc1"
|
assert list(tc_list)[0]["id"] == "tc1"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Concurrent save collision detection #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_concurrent_saves_collision_detection(setup_test_user, test_user_id):
|
||||||
|
"""Test that concurrent saves from streaming loop and callback handle collisions correctly.
|
||||||
|
|
||||||
|
Simulates the race condition where:
|
||||||
|
1. Streaming loop starts with saved_msg_count=5
|
||||||
|
2. Long-running callback appends message #5 and saves
|
||||||
|
3. Streaming loop tries to save with stale count=5
|
||||||
|
|
||||||
|
The collision detection should handle this gracefully.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Create a session with initial messages
|
||||||
|
session = ChatSession.new(user_id=test_user_id)
|
||||||
|
for i in range(3):
|
||||||
|
session.messages.append(
|
||||||
|
ChatMessage(
|
||||||
|
role="user" if i % 2 == 0 else "assistant", content=f"Message {i}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save initial messages
|
||||||
|
session = await upsert_chat_session(session)
|
||||||
|
|
||||||
|
# Simulate streaming loop and callback saving concurrently
|
||||||
|
async def streaming_loop_save():
|
||||||
|
"""Simulates streaming loop saving messages."""
|
||||||
|
# Add 2 messages
|
||||||
|
session.messages.append(ChatMessage(role="user", content="Streaming message 1"))
|
||||||
|
session.messages.append(
|
||||||
|
ChatMessage(role="assistant", content="Streaming message 2")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a bit to let callback potentially save first
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
# Save (will query DB for existing count)
|
||||||
|
return await upsert_chat_session(session)
|
||||||
|
|
||||||
|
async def callback_save():
|
||||||
|
"""Simulates long-running callback saving a message."""
|
||||||
|
# Add 1 message
|
||||||
|
session.messages.append(
|
||||||
|
ChatMessage(role="tool", content="Callback result", tool_call_id="tc1")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save immediately (will query DB for existing count)
|
||||||
|
return await upsert_chat_session(session)
|
||||||
|
|
||||||
|
# Run both saves concurrently - one will hit collision detection
|
||||||
|
results = await asyncio.gather(streaming_loop_save(), callback_save())
|
||||||
|
|
||||||
|
# Both should succeed
|
||||||
|
assert all(r is not None for r in results)
|
||||||
|
|
||||||
|
# Reload session from DB to verify
|
||||||
|
from backend.data.redis_client import get_redis_async
|
||||||
|
|
||||||
|
redis_key = f"chat:session:{session.session_id}"
|
||||||
|
async_redis = await get_redis_async()
|
||||||
|
await async_redis.delete(redis_key) # Clear cache to force DB load
|
||||||
|
|
||||||
|
loaded_session = await get_chat_session(session.session_id, test_user_id)
|
||||||
|
assert loaded_session is not None
|
||||||
|
|
||||||
|
# Should have all 6 messages (3 initial + 2 streaming + 1 callback)
|
||||||
|
assert len(loaded_session.messages) == 6
|
||||||
|
|
||||||
|
# Verify no duplicate sequences
|
||||||
|
sequences = []
|
||||||
|
for i, msg in enumerate(loaded_session.messages):
|
||||||
|
# Messages should have sequential sequence numbers starting from 0
|
||||||
|
sequences.append(i)
|
||||||
|
|
||||||
|
# All sequences should be unique and sequential
|
||||||
|
assert sequences == list(range(6))
|
||||||
|
|
||||||
|
# Verify message content is preserved
|
||||||
|
contents = [m.content for m in loaded_session.messages]
|
||||||
|
assert "Message 0" in contents
|
||||||
|
assert "Message 1" in contents
|
||||||
|
assert "Message 2" in contents
|
||||||
|
assert "Streaming message 1" in contents
|
||||||
|
assert "Streaming message 2" in contents
|
||||||
|
assert "Callback result" in contents
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from backend.data.redis_client import get_redis_async
|
||||||
|
from backend.executor.cluster_lock import AsyncClusterLock
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
|
|
||||||
from .. import stream_registry
|
from .. import stream_registry
|
||||||
@@ -61,6 +63,7 @@ from .transcript import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
config = ChatConfig()
|
config = ChatConfig()
|
||||||
|
|
||||||
|
|
||||||
# Set to hold background tasks to prevent garbage collection
|
# Set to hold background tasks to prevent garbage collection
|
||||||
_background_tasks: set[asyncio.Task[Any]] = set()
|
_background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
|
|
||||||
@@ -132,8 +135,12 @@ is delivered to the user via a background stream.
|
|||||||
All tasks must run in the foreground.
|
All tasks must run in the foreground.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
STREAM_LOCK_PREFIX = "copilot:stream:lock:"
|
||||||
|
|
||||||
def _build_long_running_callback(user_id: str | None) -> LongRunningCallback:
|
|
||||||
|
def _build_long_running_callback(
|
||||||
|
user_id: str | None,
|
||||||
|
) -> LongRunningCallback:
|
||||||
"""Build a callback that delegates long-running tools to the non-SDK infrastructure.
|
"""Build a callback that delegates long-running tools to the non-SDK infrastructure.
|
||||||
|
|
||||||
Long-running tools (create_agent, edit_agent, etc.) are delegated to the
|
Long-running tools (create_agent, edit_agent, etc.) are delegated to the
|
||||||
@@ -142,6 +149,9 @@ def _build_long_running_callback(user_id: str | None) -> LongRunningCallback:
|
|||||||
page refreshes / pod restarts, and the frontend shows the proper loading
|
page refreshes / pod restarts, and the frontend shows the proper loading
|
||||||
widget with progress updates.
|
widget with progress updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID for the session
|
||||||
|
|
||||||
The returned callback matches the ``LongRunningCallback`` signature:
|
The returned callback matches the ``LongRunningCallback`` signature:
|
||||||
``(tool_name, args, session) -> MCP response dict``.
|
``(tool_name, args, session) -> MCP response dict``.
|
||||||
"""
|
"""
|
||||||
@@ -207,7 +217,8 @@ def _build_long_running_callback(user_id: str | None) -> LongRunningCallback:
|
|||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
)
|
)
|
||||||
session.messages.append(pending_message)
|
session.messages.append(pending_message)
|
||||||
await upsert_chat_session(session)
|
# Collision detection happens in add_chat_messages_batch (db.py)
|
||||||
|
session = await upsert_chat_session(session)
|
||||||
|
|
||||||
# --- Spawn background task (reuses non-SDK infrastructure) ---
|
# --- Spawn background task (reuses non-SDK infrastructure) ---
|
||||||
bg_task = asyncio.create_task(
|
bg_task = asyncio.create_task(
|
||||||
@@ -527,6 +538,9 @@ async def stream_chat_completion_sdk(
|
|||||||
f"Session {session_id} not found. Please create a new session first."
|
f"Session {session_id} not found. Please create a new session first."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Type narrowing: session is guaranteed ChatSession after the check above
|
||||||
|
session = cast(ChatSession, session)
|
||||||
|
|
||||||
# Append the new message to the session if it's not already there
|
# Append the new message to the session if it's not already there
|
||||||
new_message_role = "user" if is_user_message else "assistant"
|
new_message_role = "user" if is_user_message else "assistant"
|
||||||
if message and (
|
if message and (
|
||||||
@@ -564,6 +578,29 @@ async def stream_chat_completion_sdk(
|
|||||||
system_prompt += _SDK_TOOL_SUPPLEMENT
|
system_prompt += _SDK_TOOL_SUPPLEMENT
|
||||||
message_id = str(uuid.uuid4())
|
message_id = str(uuid.uuid4())
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
|
stream_id = task_id # Use task_id as unique stream identifier
|
||||||
|
|
||||||
|
# Acquire stream lock to prevent concurrent streams to the same session
|
||||||
|
lock = AsyncClusterLock(
|
||||||
|
redis=await get_redis_async(),
|
||||||
|
key=f"{STREAM_LOCK_PREFIX}{session_id}",
|
||||||
|
owner_id=stream_id,
|
||||||
|
timeout=config.stream_lock_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
lock_owner = await lock.try_acquire()
|
||||||
|
if lock_owner != stream_id:
|
||||||
|
# Another stream is active
|
||||||
|
logger.warning(
|
||||||
|
f"[SDK] Session {session_id} already has an active stream: {lock_owner}"
|
||||||
|
)
|
||||||
|
yield StreamError(
|
||||||
|
errorText="Another stream is already active for this session. "
|
||||||
|
"Please wait or stop it.",
|
||||||
|
code="stream_already_active",
|
||||||
|
)
|
||||||
|
yield StreamFinish()
|
||||||
|
return
|
||||||
|
|
||||||
yield StreamStart(messageId=message_id, taskId=task_id)
|
yield StreamStart(messageId=message_id, taskId=task_id)
|
||||||
|
|
||||||
@@ -715,9 +752,6 @@ async def stream_chat_completion_sdk(
|
|||||||
accumulated_tool_calls: list[dict[str, Any]] = []
|
accumulated_tool_calls: list[dict[str, Any]] = []
|
||||||
has_appended_assistant = False
|
has_appended_assistant = False
|
||||||
has_tool_results = False
|
has_tool_results = False
|
||||||
# Track persisted message count to skip DB count queries
|
|
||||||
# on incremental saves. Initial save happened at line 545.
|
|
||||||
saved_msg_count = len(session.messages)
|
|
||||||
|
|
||||||
# Use an explicit async iterator with non-cancelling heartbeats.
|
# Use an explicit async iterator with non-cancelling heartbeats.
|
||||||
# CRITICAL: we must NOT cancel __anext__() mid-flight — doing so
|
# CRITICAL: we must NOT cancel __anext__() mid-flight — doing so
|
||||||
@@ -744,6 +778,8 @@ async def stream_chat_completion_sdk(
|
|||||||
|
|
||||||
if not done:
|
if not done:
|
||||||
# Timeout — emit heartbeat but keep the task alive
|
# Timeout — emit heartbeat but keep the task alive
|
||||||
|
# Also refresh lock TTL to keep it alive
|
||||||
|
await lock.refresh()
|
||||||
yield StreamHeartbeat()
|
yield StreamHeartbeat()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -893,13 +929,10 @@ async def stream_chat_completion_sdk(
|
|||||||
has_appended_assistant = True
|
has_appended_assistant = True
|
||||||
# Save before tool execution starts so the
|
# Save before tool execution starts so the
|
||||||
# pending tool call is visible on refresh /
|
# pending tool call is visible on refresh /
|
||||||
# other devices.
|
# other devices. Collision detection happens
|
||||||
|
# in add_chat_messages_batch (db.py).
|
||||||
try:
|
try:
|
||||||
await upsert_chat_session(
|
session = await upsert_chat_session(session)
|
||||||
session,
|
|
||||||
existing_message_count=saved_msg_count,
|
|
||||||
)
|
|
||||||
saved_msg_count = len(session.messages)
|
|
||||||
except Exception as save_err:
|
except Exception as save_err:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[SDK] [%s] Incremental save " "failed: %s",
|
"[SDK] [%s] Incremental save " "failed: %s",
|
||||||
@@ -922,12 +955,9 @@ async def stream_chat_completion_sdk(
|
|||||||
has_tool_results = True
|
has_tool_results = True
|
||||||
# Save after tool completes so the result is
|
# Save after tool completes so the result is
|
||||||
# visible on refresh / other devices.
|
# visible on refresh / other devices.
|
||||||
|
# Collision detection happens in add_chat_messages_batch (db.py).
|
||||||
try:
|
try:
|
||||||
await upsert_chat_session(
|
session = await upsert_chat_session(session)
|
||||||
session,
|
|
||||||
existing_message_count=saved_msg_count,
|
|
||||||
)
|
|
||||||
saved_msg_count = len(session.messages)
|
|
||||||
except Exception as save_err:
|
except Exception as save_err:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[SDK] [%s] Incremental save " "failed: %s",
|
"[SDK] [%s] Incremental save " "failed: %s",
|
||||||
@@ -1059,7 +1089,7 @@ async def stream_chat_completion_sdk(
|
|||||||
"to use the OpenAI-compatible fallback."
|
"to use the OpenAI-compatible fallback."
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.shield(upsert_chat_session(session))
|
session = cast(ChatSession, await asyncio.shield(upsert_chat_session(session)))
|
||||||
logger.info(
|
logger.info(
|
||||||
"[SDK] [%s] Session saved with %d messages",
|
"[SDK] [%s] Session saved with %d messages",
|
||||||
session_id[:12],
|
session_id[:12],
|
||||||
@@ -1076,10 +1106,11 @@ async def stream_chat_completion_sdk(
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SDK] Error: {e}", exc_info=True)
|
logger.error(f"[SDK] Error: {e}", exc_info=True)
|
||||||
try:
|
if session:
|
||||||
await asyncio.shield(upsert_chat_session(session))
|
try:
|
||||||
except Exception as save_err:
|
await asyncio.shield(upsert_chat_session(session))
|
||||||
logger.error(f"[SDK] Failed to save session on error: {save_err}")
|
except Exception as save_err:
|
||||||
|
logger.error(f"[SDK] Failed to save session on error: {save_err}")
|
||||||
yield StreamError(
|
yield StreamError(
|
||||||
errorText="An error occurred. Please try again.",
|
errorText="An error occurred. Please try again.",
|
||||||
code="sdk_error",
|
code="sdk_error",
|
||||||
@@ -1101,7 +1132,7 @@ async def stream_chat_completion_sdk(
|
|||||||
if not raw_transcript and use_resume and resume_file:
|
if not raw_transcript and use_resume and resume_file:
|
||||||
raw_transcript = read_transcript_file(resume_file)
|
raw_transcript = read_transcript_file(resume_file)
|
||||||
|
|
||||||
if raw_transcript:
|
if raw_transcript and session is not None:
|
||||||
await asyncio.shield(
|
await asyncio.shield(
|
||||||
_try_upload_transcript(
|
_try_upload_transcript(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -1121,6 +1152,9 @@ async def stream_chat_completion_sdk(
|
|||||||
if sdk_cwd:
|
if sdk_cwd:
|
||||||
_cleanup_sdk_tool_results(sdk_cwd)
|
_cleanup_sdk_tool_results(sdk_cwd)
|
||||||
|
|
||||||
|
# Release stream lock to allow new streams for this session
|
||||||
|
await lock.release()
|
||||||
|
|
||||||
|
|
||||||
async def _try_upload_transcript(
|
async def _try_upload_transcript(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -352,7 +352,8 @@ async def assign_user_to_session(
|
|||||||
if not session:
|
if not session:
|
||||||
raise NotFoundError(f"Session {session_id} not found")
|
raise NotFoundError(f"Session {session_id} not found")
|
||||||
session.user_id = user_id
|
session.user_id = user_id
|
||||||
return await upsert_chat_session(session)
|
session = await upsert_chat_session(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
async def stream_chat_completion(
|
async def stream_chat_completion(
|
||||||
@@ -1563,7 +1564,11 @@ async def _yield_tool_call(
|
|||||||
await _mark_operation_completed(tool_call_id)
|
await _mark_operation_completed(tool_call_id)
|
||||||
# Mark stream registry task as failed if it was created
|
# Mark stream registry task as failed if it was created
|
||||||
try:
|
try:
|
||||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
await stream_registry.mark_task_completed(
|
||||||
|
task_id,
|
||||||
|
status="failed",
|
||||||
|
error_message=f"Failed to setup tool {tool_name}: {e}",
|
||||||
|
)
|
||||||
except Exception as mark_err:
|
except Exception as mark_err:
|
||||||
logger.warning(f"Failed to mark task {task_id} as failed: {mark_err}")
|
logger.warning(f"Failed to mark task {task_id} as failed: {mark_err}")
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1731,7 +1736,11 @@ async def _execute_long_running_tool_with_streaming(
|
|||||||
session = await get_chat_session(session_id, user_id)
|
session = await get_chat_session(session_id, user_id)
|
||||||
if not session:
|
if not session:
|
||||||
logger.error(f"Session {session_id} not found for background tool")
|
logger.error(f"Session {session_id} not found for background tool")
|
||||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
await stream_registry.mark_task_completed(
|
||||||
|
task_id,
|
||||||
|
status="failed",
|
||||||
|
error_message=f"Session {session_id} not found",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Pass operation_id and task_id to the tool for async processing
|
# Pass operation_id and task_id to the tool for async processing
|
||||||
|
|||||||
@@ -644,6 +644,8 @@ async def _stream_listener(
|
|||||||
async def mark_task_completed(
|
async def mark_task_completed(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
status: Literal["completed", "failed"] = "completed",
|
status: Literal["completed", "failed"] = "completed",
|
||||||
|
*,
|
||||||
|
error_message: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Mark a task as completed and publish finish event.
|
"""Mark a task as completed and publish finish event.
|
||||||
|
|
||||||
@@ -654,6 +656,10 @@ async def mark_task_completed(
|
|||||||
Args:
|
Args:
|
||||||
task_id: Task ID to mark as completed
|
task_id: Task ID to mark as completed
|
||||||
status: Final status ("completed" or "failed")
|
status: Final status ("completed" or "failed")
|
||||||
|
error_message: If provided and status="failed", publish a StreamError
|
||||||
|
before StreamFinish so connected clients see why the task ended.
|
||||||
|
If not provided, no StreamError is published (caller should publish
|
||||||
|
manually if needed to avoid duplicates).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if task was newly marked completed, False if already completed/failed
|
True if task was newly marked completed, False if already completed/failed
|
||||||
@@ -669,6 +675,17 @@ async def mark_task_completed(
|
|||||||
logger.debug(f"Task {task_id} already completed/failed, skipping")
|
logger.debug(f"Task {task_id} already completed/failed, skipping")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Publish error event before finish so connected clients know WHY the
|
||||||
|
# task ended. Only publish if caller provided an explicit error message
|
||||||
|
# to avoid duplicates with code paths that manually publish StreamError.
|
||||||
|
# This is best-effort — if it fails, the StreamFinish still ensures
|
||||||
|
# listeners clean up.
|
||||||
|
if status == "failed" and error_message:
|
||||||
|
try:
|
||||||
|
await publish_chunk(task_id, StreamError(errorText=error_message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to publish error event for task {task_id}: {e}")
|
||||||
|
|
||||||
# THEN publish finish event (best-effort - listeners can detect via status polling)
|
# THEN publish finish event (best-effort - listeners can detect via status polling)
|
||||||
try:
|
try:
|
||||||
await publish_chunk(task_id, StreamFinish())
|
await publish_chunk(task_id, StreamFinish())
|
||||||
@@ -821,27 +838,6 @@ async def get_active_task_for_session(
|
|||||||
if task_user_id and user_id != task_user_id:
|
if task_user_id and user_id != task_user_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Auto-expire stale tasks that exceeded stream_timeout
|
|
||||||
created_at_str = meta.get("created_at", "")
|
|
||||||
if created_at_str:
|
|
||||||
try:
|
|
||||||
created_at = datetime.fromisoformat(created_at_str)
|
|
||||||
age_seconds = (
|
|
||||||
datetime.now(timezone.utc) - created_at
|
|
||||||
).total_seconds()
|
|
||||||
if age_seconds > config.stream_timeout:
|
|
||||||
logger.warning(
|
|
||||||
f"[TASK_LOOKUP] Auto-expiring stale task {task_id[:8]}... "
|
|
||||||
f"(age={age_seconds:.0f}s > timeout={config.stream_timeout}s)"
|
|
||||||
)
|
|
||||||
await mark_task_completed(task_id, "failed")
|
|
||||||
continue
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
logger.warning(
|
|
||||||
f"[TASK_LOOKUP] Failed to parse created_at "
|
|
||||||
f"for task {task_id[:8]}...: {exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[TASK_LOOKUP] Found running task {task_id[:8]}... for session {session_id[:8]}..."
|
f"[TASK_LOOKUP] Found running task {task_id[:8]}... for session {session_id[:8]}..."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ class DatabaseManager(AppService):
|
|||||||
get_user_chat_sessions = _(chat_db.get_user_chat_sessions)
|
get_user_chat_sessions = _(chat_db.get_user_chat_sessions)
|
||||||
get_user_session_count = _(chat_db.get_user_session_count)
|
get_user_session_count = _(chat_db.get_user_session_count)
|
||||||
delete_chat_session = _(chat_db.delete_chat_session)
|
delete_chat_session = _(chat_db.delete_chat_session)
|
||||||
get_chat_session_message_count = _(chat_db.get_chat_session_message_count)
|
get_next_sequence = _(chat_db.get_next_sequence)
|
||||||
update_tool_message_content = _(chat_db.update_tool_message_content)
|
update_tool_message_content = _(chat_db.update_tool_message_content)
|
||||||
|
|
||||||
|
|
||||||
@@ -473,5 +473,5 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
|||||||
get_user_chat_sessions = d.get_user_chat_sessions
|
get_user_chat_sessions = d.get_user_chat_sessions
|
||||||
get_user_session_count = d.get_user_session_count
|
get_user_session_count = d.get_user_session_count
|
||||||
delete_chat_session = d.delete_chat_session
|
delete_chat_session = d.delete_chat_session
|
||||||
get_chat_session_message_count = d.get_chat_session_message_count
|
get_next_sequence = d.get_next_sequence
|
||||||
update_tool_message_content = d.update_tool_message_content
|
update_tool_message_content = d.update_tool_message_content
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Redis-based distributed locking for cluster coordination."""
|
"""Redis-based distributed locking for cluster coordination."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
|
from redis.asyncio import Redis as AsyncRedis
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -126,3 +128,124 @@ class ClusterLock:
|
|||||||
|
|
||||||
with self._refresh_lock:
|
with self._refresh_lock:
|
||||||
self._last_refresh = 0.0
|
self._last_refresh = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncClusterLock:
|
||||||
|
"""Async Redis-based distributed lock for preventing duplicate execution."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, redis: "AsyncRedis", key: str, owner_id: str, timeout: int = 300
|
||||||
|
):
|
||||||
|
self.redis = redis
|
||||||
|
self.key = key
|
||||||
|
self.owner_id = owner_id
|
||||||
|
self.timeout = timeout
|
||||||
|
self._last_refresh = 0.0
|
||||||
|
self._refresh_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def try_acquire(self) -> str | None:
|
||||||
|
"""Try to acquire the lock.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- owner_id (self.owner_id) if successfully acquired
|
||||||
|
- different owner_id if someone else holds the lock
|
||||||
|
- None if Redis is unavailable or other error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = await self.redis.set(
|
||||||
|
self.key, self.owner_id, nx=True, ex=self.timeout
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = time.time()
|
||||||
|
return self.owner_id # Successfully acquired
|
||||||
|
|
||||||
|
# Failed to acquire, get current owner
|
||||||
|
current_value = await self.redis.get(self.key)
|
||||||
|
if current_value:
|
||||||
|
current_owner = (
|
||||||
|
current_value.decode("utf-8")
|
||||||
|
if isinstance(current_value, bytes)
|
||||||
|
else str(current_value)
|
||||||
|
)
|
||||||
|
return current_owner
|
||||||
|
|
||||||
|
# Key doesn't exist but we failed to set it - race condition or Redis issue
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AsyncClusterLock.try_acquire failed for key {self.key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def refresh(self) -> bool:
|
||||||
|
"""Refresh lock TTL if we still own it.
|
||||||
|
|
||||||
|
Rate limited to at most once every timeout/10 seconds (minimum 1 second).
|
||||||
|
During rate limiting, still verifies lock existence but skips TTL extension.
|
||||||
|
Setting _last_refresh to 0 bypasses rate limiting for testing.
|
||||||
|
|
||||||
|
Async-safe: uses asyncio.Lock to protect _last_refresh access.
|
||||||
|
"""
|
||||||
|
# Calculate refresh interval: max(timeout // 10, 1)
|
||||||
|
refresh_interval = max(self.timeout // 10, 1)
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Check if we're within the rate limit period (async-safe read)
|
||||||
|
# _last_refresh == 0 forces a refresh (bypasses rate limiting for testing)
|
||||||
|
async with self._refresh_lock:
|
||||||
|
last_refresh = self._last_refresh
|
||||||
|
is_rate_limited = (
|
||||||
|
last_refresh > 0 and (current_time - last_refresh) < refresh_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Always verify lock existence, even during rate limiting
|
||||||
|
current_value = await self.redis.get(self.key)
|
||||||
|
if not current_value:
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored_owner = (
|
||||||
|
current_value.decode("utf-8")
|
||||||
|
if isinstance(current_value, bytes)
|
||||||
|
else str(current_value)
|
||||||
|
)
|
||||||
|
if stored_owner != self.owner_id:
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If rate limited, return True but don't update TTL or timestamp
|
||||||
|
if is_rate_limited:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Perform actual refresh
|
||||||
|
if await self.redis.expire(self.key, self.timeout):
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = current_time
|
||||||
|
return True
|
||||||
|
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AsyncClusterLock.refresh failed for key {self.key}: {e}")
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def release(self):
|
||||||
|
"""Release the lock."""
|
||||||
|
async with self._refresh_lock:
|
||||||
|
if self._last_refresh == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.redis.delete(self.key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with self._refresh_lock:
|
||||||
|
self._last_refresh = 0.0
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -11,6 +11,11 @@ import {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import {
|
||||||
|
CredentialsProvidersContext,
|
||||||
|
type CredentialsProviderData,
|
||||||
|
type CredentialsProvidersContextType,
|
||||||
|
} from "@/providers/agent-credentials/credentials-provider";
|
||||||
import { CopilotChatActionsProvider } from "../components/CopilotChatActionsProvider/CopilotChatActionsProvider";
|
import { CopilotChatActionsProvider } from "../components/CopilotChatActionsProvider/CopilotChatActionsProvider";
|
||||||
import { CreateAgentTool } from "../tools/CreateAgent/CreateAgent";
|
import { CreateAgentTool } from "../tools/CreateAgent/CreateAgent";
|
||||||
import { EditAgentTool } from "../tools/EditAgent/EditAgent";
|
import { EditAgentTool } from "../tools/EditAgent/EditAgent";
|
||||||
@@ -97,6 +102,65 @@ function uid() {
|
|||||||
return `sg-${++_id}`;
|
return `sg-${++_id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock credential providers for setup-requirements demos
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const noop = () => Promise.reject(new Error("Styleguide mock"));
|
||||||
|
|
||||||
|
function makeMockProvider(
|
||||||
|
provider: string,
|
||||||
|
providerName: string,
|
||||||
|
savedCredentials: CredentialsProviderData["savedCredentials"] = [],
|
||||||
|
): CredentialsProviderData {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
providerName,
|
||||||
|
savedCredentials,
|
||||||
|
isSystemProvider: false,
|
||||||
|
oAuthCallback: noop as CredentialsProviderData["oAuthCallback"],
|
||||||
|
mcpOAuthCallback: noop as CredentialsProviderData["mcpOAuthCallback"],
|
||||||
|
createAPIKeyCredentials:
|
||||||
|
noop as CredentialsProviderData["createAPIKeyCredentials"],
|
||||||
|
createUserPasswordCredentials:
|
||||||
|
noop as CredentialsProviderData["createUserPasswordCredentials"],
|
||||||
|
createHostScopedCredentials:
|
||||||
|
noop as CredentialsProviderData["createHostScopedCredentials"],
|
||||||
|
deleteCredentials: noop as CredentialsProviderData["deleteCredentials"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider context where the user already has saved credentials
|
||||||
|
* so the credential picker shows a selection list.
|
||||||
|
*/
|
||||||
|
const MOCK_PROVIDERS_WITH_CREDENTIALS: CredentialsProvidersContextType = {
|
||||||
|
google: makeMockProvider("google", "Google", [
|
||||||
|
{
|
||||||
|
id: "cred-google-1",
|
||||||
|
provider: "google",
|
||||||
|
type: "oauth2",
|
||||||
|
title: "work@company.com",
|
||||||
|
scopes: ["email", "calendar"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cred-google-2",
|
||||||
|
provider: "google",
|
||||||
|
type: "oauth2",
|
||||||
|
title: "personal@gmail.com",
|
||||||
|
scopes: ["email", "calendar"],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider context where the user has NO saved credentials,
|
||||||
|
* so the credential picker shows an "add new" flow.
|
||||||
|
*/
|
||||||
|
const MOCK_PROVIDERS_WITHOUT_CREDENTIALS: CredentialsProvidersContextType = {
|
||||||
|
openweathermap: makeMockProvider("openweathermap", "OpenWeatherMap"),
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Page
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -554,45 +618,80 @@ export default function StyleguidePage() {
|
|||||||
/>
|
/>
|
||||||
</SubSection>
|
</SubSection>
|
||||||
|
|
||||||
<SubSection label="Output available (setup requirements)">
|
<SubSection label="Setup requirements — no credentials (add new)">
|
||||||
<RunBlockTool
|
<CredentialsProvidersContext.Provider
|
||||||
part={{
|
value={MOCK_PROVIDERS_WITHOUT_CREDENTIALS}
|
||||||
type: "tool-run_block",
|
>
|
||||||
toolCallId: uid(),
|
<RunBlockTool
|
||||||
state: "output-available",
|
part={{
|
||||||
input: { block_id: "weather-block-123" },
|
type: "tool-run_block",
|
||||||
output: {
|
toolCallId: uid(),
|
||||||
type: ResponseType.setup_requirements,
|
state: "output-available",
|
||||||
message:
|
input: { block_id: "weather-block-123" },
|
||||||
"This block requires API credentials to run. Please configure them below.",
|
output: {
|
||||||
setup_info: {
|
type: ResponseType.setup_requirements,
|
||||||
agent_name: "Weather Agent",
|
message:
|
||||||
requirements: {
|
"This block requires API credentials to run. Please configure them below.",
|
||||||
inputs: [
|
setup_info: {
|
||||||
{
|
agent_id: "agent-weather-1",
|
||||||
name: "city",
|
agent_name: "Weather Agent",
|
||||||
title: "City",
|
requirements: {
|
||||||
type: "string",
|
inputs: [
|
||||||
required: true,
|
{
|
||||||
description: "The city to get weather for",
|
name: "city",
|
||||||
},
|
title: "City",
|
||||||
],
|
type: "string",
|
||||||
},
|
required: true,
|
||||||
user_readiness: {
|
description: "The city to get weather for",
|
||||||
missing_credentials: {
|
},
|
||||||
openweathermap: {
|
],
|
||||||
provider: "openweathermap",
|
},
|
||||||
credentials_type: "api_key",
|
user_readiness: {
|
||||||
title: "OpenWeatherMap API Key",
|
missing_credentials: {
|
||||||
description:
|
openweathermap_key: {
|
||||||
"Required to access weather data. Get your key at openweathermap.org",
|
provider: "openweathermap",
|
||||||
|
types: ["api_key"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</CredentialsProvidersContext.Provider>
|
||||||
|
</SubSection>
|
||||||
|
|
||||||
|
<SubSection label="Setup requirements — has credentials (pick from list)">
|
||||||
|
<CredentialsProvidersContext.Provider
|
||||||
|
value={MOCK_PROVIDERS_WITH_CREDENTIALS}
|
||||||
|
>
|
||||||
|
<RunBlockTool
|
||||||
|
part={{
|
||||||
|
type: "tool-run_block",
|
||||||
|
toolCallId: uid(),
|
||||||
|
state: "output-available",
|
||||||
|
input: { block_id: "calendar-block-456" },
|
||||||
|
output: {
|
||||||
|
type: ResponseType.setup_requirements,
|
||||||
|
message:
|
||||||
|
"This block requires Google credentials. Pick an account below or connect a new one.",
|
||||||
|
setup_info: {
|
||||||
|
agent_id: "agent-calendar-1",
|
||||||
|
agent_name: "Calendar Agent",
|
||||||
|
user_readiness: {
|
||||||
|
missing_credentials: {
|
||||||
|
google_oauth: {
|
||||||
|
provider: "google",
|
||||||
|
types: ["oauth2"],
|
||||||
|
scopes: ["email", "calendar"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CredentialsProvidersContext.Provider>
|
||||||
</SubSection>
|
</SubSection>
|
||||||
|
|
||||||
<SubSection label="Output available (error)">
|
<SubSection label="Output available (error)">
|
||||||
@@ -849,34 +948,71 @@ export default function StyleguidePage() {
|
|||||||
/>
|
/>
|
||||||
</SubSection>
|
</SubSection>
|
||||||
|
|
||||||
<SubSection label="Output available (setup requirements)">
|
<SubSection label="Setup requirements — no credentials (add new)">
|
||||||
<RunAgentTool
|
<CredentialsProvidersContext.Provider
|
||||||
part={{
|
value={MOCK_PROVIDERS_WITHOUT_CREDENTIALS}
|
||||||
type: "tool-run_agent",
|
>
|
||||||
toolCallId: uid(),
|
<RunAgentTool
|
||||||
state: "output-available",
|
part={{
|
||||||
input: { username_agent_slug: "creator/my-agent" },
|
type: "tool-run_agent",
|
||||||
output: {
|
toolCallId: uid(),
|
||||||
type: ResponseType.setup_requirements,
|
state: "output-available",
|
||||||
message: "This agent requires additional setup.",
|
input: { username_agent_slug: "creator/weather-agent" },
|
||||||
setup_info: {
|
output: {
|
||||||
agent_name: "YouTube Summarizer",
|
type: ResponseType.setup_requirements,
|
||||||
requirements: {},
|
message:
|
||||||
user_readiness: {
|
"This agent requires an API key. Add your credentials below.",
|
||||||
missing_credentials: {
|
setup_info: {
|
||||||
youtube_api: {
|
agent_id: "agent-weather-1",
|
||||||
provider: "youtube",
|
agent_name: "Weather Agent",
|
||||||
credentials_type: "api_key",
|
requirements: {},
|
||||||
title: "YouTube Data API Key",
|
user_readiness: {
|
||||||
description:
|
missing_credentials: {
|
||||||
"Required to access YouTube video data.",
|
openweathermap_key: {
|
||||||
|
provider: "openweathermap",
|
||||||
|
types: ["api_key"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</CredentialsProvidersContext.Provider>
|
||||||
|
</SubSection>
|
||||||
|
|
||||||
|
<SubSection label="Setup requirements — has credentials (pick from list)">
|
||||||
|
<CredentialsProvidersContext.Provider
|
||||||
|
value={MOCK_PROVIDERS_WITH_CREDENTIALS}
|
||||||
|
>
|
||||||
|
<RunAgentTool
|
||||||
|
part={{
|
||||||
|
type: "tool-run_agent",
|
||||||
|
toolCallId: uid(),
|
||||||
|
state: "output-available",
|
||||||
|
input: { username_agent_slug: "creator/calendar-agent" },
|
||||||
|
output: {
|
||||||
|
type: ResponseType.setup_requirements,
|
||||||
|
message:
|
||||||
|
"This agent needs Google credentials. Pick an account or connect a new one.",
|
||||||
|
setup_info: {
|
||||||
|
agent_id: "agent-calendar-1",
|
||||||
|
agent_name: "Google Calendar Agent",
|
||||||
|
requirements: {},
|
||||||
|
user_readiness: {
|
||||||
|
missing_credentials: {
|
||||||
|
google_oauth: {
|
||||||
|
provider: "google",
|
||||||
|
types: ["oauth2"],
|
||||||
|
scopes: ["email", "calendar"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CredentialsProvidersContext.Provider>
|
||||||
</SubSection>
|
</SubSection>
|
||||||
|
|
||||||
<SubSection label="Output available (need login)">
|
<SubSection label="Output available (need login)">
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
ContentCardDescription,
|
ContentCardDescription,
|
||||||
ContentCodeBlock,
|
ContentCodeBlock,
|
||||||
ContentGrid,
|
ContentGrid,
|
||||||
ContentHint,
|
|
||||||
ContentMessage,
|
ContentMessage,
|
||||||
} from "../../components/ToolAccordion/AccordionContent";
|
} from "../../components/ToolAccordion/AccordionContent";
|
||||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||||
@@ -24,8 +23,8 @@ import {
|
|||||||
ClarificationQuestionsCard,
|
ClarificationQuestionsCard,
|
||||||
ClarifyingQuestion,
|
ClarifyingQuestion,
|
||||||
} from "./components/ClarificationQuestionsCard";
|
} from "./components/ClarificationQuestionsCard";
|
||||||
import sparklesImg from "./components/MiniGame/assets/sparkles.png";
|
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
|
||||||
import { MiniGame } from "./components/MiniGame/MiniGame";
|
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||||
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
|
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
|
||||||
import {
|
import {
|
||||||
AccordionIcon,
|
AccordionIcon,
|
||||||
@@ -93,9 +92,7 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
icon,
|
icon,
|
||||||
title:
|
title: output.message || "Agent creation started",
|
||||||
"Creating agent, this may take a few minutes. Play while you wait.",
|
|
||||||
expanded: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -169,15 +166,22 @@ export function CreateAgentTool({ part }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isStreaming && (
|
||||||
|
<ToolAccordion
|
||||||
|
icon={<AccordionIcon />}
|
||||||
|
title="Creating agent, this may take a few minutes. Play while you wait."
|
||||||
|
expanded
|
||||||
|
>
|
||||||
|
<ContentGrid>
|
||||||
|
<MiniGame />
|
||||||
|
</ContentGrid>
|
||||||
|
</ToolAccordion>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasExpandableContent && output && (
|
{hasExpandableContent && output && (
|
||||||
<ToolAccordion {...getAccordionMeta(output)}>
|
<ToolAccordion {...getAccordionMeta(output)}>
|
||||||
{isOperating && (
|
{isOperating && output.message && (
|
||||||
<ContentGrid>
|
<ContentMessage>{output.message}</ContentMessage>
|
||||||
<MiniGame />
|
|
||||||
<ContentHint>
|
|
||||||
This could take a few minutes — play while you wait!
|
|
||||||
</ContentHint>
|
|
||||||
</ContentGrid>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAgentSavedOutput(output) && (
|
{isAgentSavedOutput(output) && (
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ import { WarningDiamondIcon } from "@phosphor-icons/react";
|
|||||||
import type { ToolUIPart } from "ai";
|
import type { ToolUIPart } from "ai";
|
||||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
|
||||||
import {
|
import {
|
||||||
ContentCardDescription,
|
ContentCardDescription,
|
||||||
ContentCodeBlock,
|
ContentCodeBlock,
|
||||||
ContentGrid,
|
ContentGrid,
|
||||||
ContentHint,
|
|
||||||
ContentLink,
|
ContentLink,
|
||||||
ContentMessage,
|
ContentMessage,
|
||||||
} from "../../components/ToolAccordion/AccordionContent";
|
} from "../../components/ToolAccordion/AccordionContent";
|
||||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||||
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
|
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||||
import {
|
import {
|
||||||
ClarificationQuestionsCard,
|
ClarificationQuestionsCard,
|
||||||
ClarifyingQuestion,
|
ClarifyingQuestion,
|
||||||
@@ -81,9 +79,8 @@ function getAccordionMeta(output: EditAgentToolOutput): {
|
|||||||
isOperationInProgressOutput(output)
|
isOperationInProgressOutput(output)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
icon: <OrbitLoader size={32} />,
|
icon,
|
||||||
title: "Editing agent, this may take a few minutes. Play while you wait.",
|
title: output.message || "Agent editing started",
|
||||||
expanded: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -148,15 +145,22 @@ export function EditAgentTool({ part }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isStreaming && (
|
||||||
|
<ToolAccordion
|
||||||
|
icon={<AccordionIcon />}
|
||||||
|
title="Editing agent, this may take a few minutes. Play while you wait."
|
||||||
|
expanded
|
||||||
|
>
|
||||||
|
<ContentGrid>
|
||||||
|
<MiniGame />
|
||||||
|
</ContentGrid>
|
||||||
|
</ToolAccordion>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasExpandableContent && output && (
|
{hasExpandableContent && output && (
|
||||||
<ToolAccordion {...getAccordionMeta(output)}>
|
<ToolAccordion {...getAccordionMeta(output)}>
|
||||||
{isOperating && (
|
{isOperating && output.message && (
|
||||||
<ContentGrid>
|
<ContentMessage>{output.message}</ContentMessage>
|
||||||
<MiniGame />
|
|
||||||
<ContentHint>
|
|
||||||
This could take a few minutes — play while you wait!
|
|
||||||
</ContentHint>
|
|
||||||
</ContentGrid>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAgentSavedOutput(output) && (
|
{isAgentSavedOutput(output) && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ContentHint,
|
ContentHint,
|
||||||
ContentMessage,
|
ContentMessage,
|
||||||
} from "../../components/ToolAccordion/AccordionContent";
|
} from "../../components/ToolAccordion/AccordionContent";
|
||||||
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
|
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||||
import {
|
import {
|
||||||
getAccordionMeta,
|
getAccordionMeta,
|
||||||
getAnimationText,
|
getAnimationText,
|
||||||
@@ -47,14 +47,25 @@ export function RunAgentTool({ part }: Props) {
|
|||||||
const isError =
|
const isError =
|
||||||
part.state === "output-error" ||
|
part.state === "output-error" ||
|
||||||
(!!output && isRunAgentErrorOutput(output));
|
(!!output && isRunAgentErrorOutput(output));
|
||||||
|
const isOutputAvailable = part.state === "output-available" && !!output;
|
||||||
|
|
||||||
|
const setupRequirementsOutput =
|
||||||
|
isOutputAvailable && isRunAgentSetupRequirementsOutput(output)
|
||||||
|
? output
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const agentDetailsOutput =
|
||||||
|
isOutputAvailable && isRunAgentAgentDetailsOutput(output) ? output : null;
|
||||||
|
|
||||||
|
const needLoginOutput =
|
||||||
|
isOutputAvailable && isRunAgentNeedLoginOutput(output) ? output : null;
|
||||||
|
|
||||||
const hasExpandableContent =
|
const hasExpandableContent =
|
||||||
part.state === "output-available" &&
|
isOutputAvailable &&
|
||||||
!!output &&
|
!setupRequirementsOutput &&
|
||||||
(isRunAgentExecutionStartedOutput(output) ||
|
!agentDetailsOutput &&
|
||||||
isRunAgentAgentDetailsOutput(output) ||
|
!needLoginOutput &&
|
||||||
isRunAgentSetupRequirementsOutput(output) ||
|
(isRunAgentExecutionStartedOutput(output) || isRunAgentErrorOutput(output));
|
||||||
isRunAgentNeedLoginOutput(output) ||
|
|
||||||
isRunAgentErrorOutput(output));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
@@ -81,24 +92,30 @@ export function RunAgentTool({ part }: Props) {
|
|||||||
</ToolAccordion>
|
</ToolAccordion>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{setupRequirementsOutput && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<SetupRequirementsCard output={setupRequirementsOutput} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentDetailsOutput && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<AgentDetailsCard output={agentDetailsOutput} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needLoginOutput && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ContentMessage>{needLoginOutput.message}</ContentMessage>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasExpandableContent && output && (
|
{hasExpandableContent && output && (
|
||||||
<ToolAccordion {...getAccordionMeta(output)}>
|
<ToolAccordion {...getAccordionMeta(output)}>
|
||||||
{isRunAgentExecutionStartedOutput(output) && (
|
{isRunAgentExecutionStartedOutput(output) && (
|
||||||
<ExecutionStartedCard output={output} />
|
<ExecutionStartedCard output={output} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isRunAgentAgentDetailsOutput(output) && (
|
|
||||||
<AgentDetailsCard output={output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRunAgentSetupRequirementsOutput(output) && (
|
|
||||||
<SetupRequirementsCard output={output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRunAgentNeedLoginOutput(output) && (
|
|
||||||
<ContentMessage>{output.message}</ContentMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRunAgentErrorOutput(output) && <ErrorCard output={output} />}
|
{isRunAgentErrorOutput(output) && <ErrorCard output={output} />}
|
||||||
</ToolAccordion>
|
</ToolAccordion>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
|
||||||
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
||||||
|
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||||
|
import { useState } from "react";
|
||||||
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||||
import {
|
import {
|
||||||
ContentBadge,
|
ContentBadge,
|
||||||
@@ -38,40 +39,40 @@ export function SetupRequirementsCard({ output }: Props) {
|
|||||||
setInputCredentials((prev) => ({ ...prev, [key]: value }));
|
setInputCredentials((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAllComplete =
|
const needsCredentials = credentialFields.length > 0;
|
||||||
credentialFields.length > 0 &&
|
const isAllCredentialsComplete =
|
||||||
|
needsCredentials &&
|
||||||
[...requiredCredentials].every((key) => !!inputCredentials[key]);
|
[...requiredCredentials].every((key) => !!inputCredentials[key]);
|
||||||
|
|
||||||
|
const canProceed =
|
||||||
|
!hasSent && (!needsCredentials || isAllCredentialsComplete);
|
||||||
|
|
||||||
function handleProceed() {
|
function handleProceed() {
|
||||||
setHasSent(true);
|
setHasSent(true);
|
||||||
onSend(
|
const message = needsCredentials
|
||||||
"I've configured the required credentials. Please check if everything is ready and proceed with running the agent.",
|
? "I've configured the required credentials. Please check if everything is ready and proceed with running the agent."
|
||||||
);
|
: "Please proceed with running the agent.";
|
||||||
|
onSend(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<ContentMessage>{output.message}</ContentMessage>
|
<ContentMessage>{output.message}</ContentMessage>
|
||||||
|
|
||||||
{credentialFields.length > 0 && (
|
{needsCredentials && (
|
||||||
<div className="rounded-2xl border bg-background p-3">
|
<div className="rounded-2xl border bg-background p-3">
|
||||||
<CredentialsGroupedView
|
<Text variant="small" className="w-fit border-b text-zinc-500">
|
||||||
credentialFields={credentialFields}
|
Agent credentials
|
||||||
requiredCredentials={requiredCredentials}
|
</Text>
|
||||||
inputCredentials={inputCredentials}
|
<div className="mt-6">
|
||||||
inputValues={{}}
|
<CredentialsGroupedView
|
||||||
onCredentialChange={handleCredentialChange}
|
credentialFields={credentialFields}
|
||||||
/>
|
requiredCredentials={requiredCredentials}
|
||||||
{isAllComplete && !hasSent && (
|
inputCredentials={inputCredentials}
|
||||||
<Button
|
inputValues={{}}
|
||||||
variant="primary"
|
onCredentialChange={handleCredentialChange}
|
||||||
size="small"
|
/>
|
||||||
className="mt-3 w-full"
|
</div>
|
||||||
onClick={handleProceed}
|
|
||||||
>
|
|
||||||
Proceed
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -100,6 +101,18 @@ export function SetupRequirementsCard({ output }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(needsCredentials || expectedInputs.length > 0) && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
className="mt-4 w-fit"
|
||||||
|
disabled={!canProceed}
|
||||||
|
onClick={handleProceed}
|
||||||
|
>
|
||||||
|
Proceed
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,19 @@ export function RunBlockTool({ part }: Props) {
|
|||||||
const isError =
|
const isError =
|
||||||
part.state === "output-error" ||
|
part.state === "output-error" ||
|
||||||
(!!output && isRunBlockErrorOutput(output));
|
(!!output && isRunBlockErrorOutput(output));
|
||||||
|
const setupRequirementsOutput =
|
||||||
|
part.state === "output-available" &&
|
||||||
|
output &&
|
||||||
|
isRunBlockSetupRequirementsOutput(output)
|
||||||
|
? output
|
||||||
|
: null;
|
||||||
|
|
||||||
const hasExpandableContent =
|
const hasExpandableContent =
|
||||||
part.state === "output-available" &&
|
part.state === "output-available" &&
|
||||||
!!output &&
|
!!output &&
|
||||||
|
!setupRequirementsOutput &&
|
||||||
(isRunBlockBlockOutput(output) ||
|
(isRunBlockBlockOutput(output) ||
|
||||||
isRunBlockDetailsOutput(output) ||
|
isRunBlockDetailsOutput(output) ||
|
||||||
isRunBlockSetupRequirementsOutput(output) ||
|
|
||||||
isRunBlockErrorOutput(output));
|
isRunBlockErrorOutput(output));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +64,12 @@ export function RunBlockTool({ part }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{setupRequirementsOutput && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<SetupRequirementsCard output={setupRequirementsOutput} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasExpandableContent && output && (
|
{hasExpandableContent && output && (
|
||||||
<ToolAccordion {...getAccordionMeta(output)}>
|
<ToolAccordion {...getAccordionMeta(output)}>
|
||||||
{isRunBlockBlockOutput(output) && <BlockOutputCard output={output} />}
|
{isRunBlockBlockOutput(output) && <BlockOutputCard output={output} />}
|
||||||
@@ -65,10 +78,6 @@ export function RunBlockTool({ part }: Props) {
|
|||||||
<BlockDetailsCard output={output} />
|
<BlockDetailsCard output={output} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isRunBlockSetupRequirementsOutput(output) && (
|
|
||||||
<SetupRequirementsCard output={output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRunBlockErrorOutput(output) && <ErrorCard output={output} />}
|
{isRunBlockErrorOutput(output) && <ErrorCard output={output} />}
|
||||||
</ToolAccordion>
|
</ToolAccordion>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,15 +6,9 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
||||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||||
import {
|
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
|
||||||
ContentBadge,
|
|
||||||
ContentCardDescription,
|
|
||||||
ContentCardTitle,
|
|
||||||
ContentMessage,
|
|
||||||
} from "../../../../components/ToolAccordion/AccordionContent";
|
|
||||||
import {
|
import {
|
||||||
buildExpectedInputsSchema,
|
buildExpectedInputsSchema,
|
||||||
coerceCredentialFields,
|
coerceCredentialFields,
|
||||||
@@ -31,10 +25,8 @@ export function SetupRequirementsCard({ output }: Props) {
|
|||||||
const [inputCredentials, setInputCredentials] = useState<
|
const [inputCredentials, setInputCredentials] = useState<
|
||||||
Record<string, CredentialsMetaInput | undefined>
|
Record<string, CredentialsMetaInput | undefined>
|
||||||
>({});
|
>({});
|
||||||
const [hasSentCredentials, setHasSentCredentials] = useState(false);
|
|
||||||
|
|
||||||
const [showInputForm, setShowInputForm] = useState(false);
|
|
||||||
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
|
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [hasSent, setHasSent] = useState(false);
|
||||||
|
|
||||||
const { credentialFields, requiredCredentials } = coerceCredentialFields(
|
const { credentialFields, requiredCredentials } = coerceCredentialFields(
|
||||||
output.setup_info.user_readiness?.missing_credentials,
|
output.setup_info.user_readiness?.missing_credentials,
|
||||||
@@ -50,27 +42,49 @@ export function SetupRequirementsCard({ output }: Props) {
|
|||||||
setInputCredentials((prev) => ({ ...prev, [key]: value }));
|
setInputCredentials((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsCredentials = credentialFields.length > 0;
|
||||||
const isAllCredentialsComplete =
|
const isAllCredentialsComplete =
|
||||||
credentialFields.length > 0 &&
|
needsCredentials &&
|
||||||
[...requiredCredentials].every((key) => !!inputCredentials[key]);
|
[...requiredCredentials].every((key) => !!inputCredentials[key]);
|
||||||
|
|
||||||
function handleProceedCredentials() {
|
const needsInputs = inputSchema !== null;
|
||||||
setHasSentCredentials(true);
|
const requiredInputNames = expectedInputs
|
||||||
onSend(
|
.filter((i) => i.required)
|
||||||
"I've configured the required credentials. Please re-run the block now.",
|
.map((i) => i.name);
|
||||||
);
|
const isAllInputsComplete =
|
||||||
}
|
needsInputs &&
|
||||||
|
requiredInputNames.every((name) => {
|
||||||
|
const v = inputValues[name];
|
||||||
|
return v !== undefined && v !== null && v !== "";
|
||||||
|
});
|
||||||
|
|
||||||
function handleRunWithInputs() {
|
const canRun =
|
||||||
const nonEmpty = Object.fromEntries(
|
!hasSent &&
|
||||||
Object.entries(inputValues).filter(
|
(!needsCredentials || isAllCredentialsComplete) &&
|
||||||
([, v]) => v !== undefined && v !== null && v !== "",
|
(!needsInputs || isAllInputsComplete);
|
||||||
),
|
|
||||||
);
|
function handleRun() {
|
||||||
onSend(
|
setHasSent(true);
|
||||||
`Run the block with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
|
|
||||||
);
|
const parts: string[] = [];
|
||||||
setShowInputForm(false);
|
if (needsCredentials) {
|
||||||
|
parts.push("I've configured the required credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsInputs) {
|
||||||
|
const nonEmpty = Object.fromEntries(
|
||||||
|
Object.entries(inputValues).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
`Run the block with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push("Please re-run the block now.");
|
||||||
|
}
|
||||||
|
|
||||||
|
onSend(parts.join(" "));
|
||||||
setInputValues({});
|
setInputValues({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,119 +92,54 @@ export function SetupRequirementsCard({ output }: Props) {
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<ContentMessage>{output.message}</ContentMessage>
|
<ContentMessage>{output.message}</ContentMessage>
|
||||||
|
|
||||||
{credentialFields.length > 0 && (
|
{needsCredentials && (
|
||||||
<div className="rounded-2xl border bg-background p-3">
|
<div className="rounded-2xl border bg-background p-3">
|
||||||
<CredentialsGroupedView
|
<Text variant="small" className="w-fit border-b text-zinc-500">
|
||||||
credentialFields={credentialFields}
|
Block credentials
|
||||||
requiredCredentials={requiredCredentials}
|
</Text>
|
||||||
inputCredentials={inputCredentials}
|
<div className="mt-6">
|
||||||
inputValues={{}}
|
<CredentialsGroupedView
|
||||||
onCredentialChange={handleCredentialChange}
|
credentialFields={credentialFields}
|
||||||
/>
|
requiredCredentials={requiredCredentials}
|
||||||
{isAllCredentialsComplete && !hasSentCredentials && (
|
inputCredentials={inputCredentials}
|
||||||
<Button
|
inputValues={{}}
|
||||||
variant="primary"
|
onCredentialChange={handleCredentialChange}
|
||||||
size="small"
|
/>
|
||||||
className="mt-3 w-full"
|
</div>
|
||||||
onClick={handleProceedCredentials}
|
|
||||||
>
|
|
||||||
Proceed
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inputSchema && (
|
{inputSchema && (
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="rounded-2xl border bg-background p-3 pt-4">
|
||||||
<Button
|
<Text variant="small" className="w-fit border-b text-zinc-500">
|
||||||
variant="outline"
|
Block inputs
|
||||||
size="small"
|
</Text>
|
||||||
className="w-fit"
|
<FormRenderer
|
||||||
onClick={() => setShowInputForm((prev) => !prev)}
|
jsonSchema={inputSchema}
|
||||||
>
|
className="mb-3 mt-3"
|
||||||
{showInputForm ? "Hide inputs" : "Fill in inputs"}
|
handleChange={(v) => setInputValues(v.formData ?? {})}
|
||||||
</Button>
|
uiSchema={{
|
||||||
|
"ui:submitButtonOptions": { norender: true },
|
||||||
|
}}
|
||||||
|
initialValues={inputValues}
|
||||||
|
formContext={{
|
||||||
|
showHandles: false,
|
||||||
|
size: "small",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
{(needsCredentials || needsInputs) && (
|
||||||
{showInputForm && inputSchema && (
|
<Button
|
||||||
<motion.div
|
variant="primary"
|
||||||
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
|
size="small"
|
||||||
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
|
className="w-fit"
|
||||||
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
|
disabled={!canRun}
|
||||||
transition={{
|
onClick={handleRun}
|
||||||
height: { type: "spring", bounce: 0.15, duration: 0.5 },
|
>
|
||||||
opacity: { duration: 0.25 },
|
Proceed
|
||||||
filter: { duration: 0.2 },
|
</Button>
|
||||||
}}
|
|
||||||
className="overflow-hidden"
|
|
||||||
style={{ willChange: "height, opacity, filter" }}
|
|
||||||
>
|
|
||||||
<div className="rounded-2xl border bg-background p-3 pt-4">
|
|
||||||
<Text variant="body-medium">Block inputs</Text>
|
|
||||||
<FormRenderer
|
|
||||||
jsonSchema={inputSchema}
|
|
||||||
handleChange={(v) => setInputValues(v.formData ?? {})}
|
|
||||||
uiSchema={{
|
|
||||||
"ui:submitButtonOptions": { norender: true },
|
|
||||||
}}
|
|
||||||
initialValues={inputValues}
|
|
||||||
formContext={{
|
|
||||||
showHandles: false,
|
|
||||||
size: "small",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="-mt-8 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="small"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={handleRunWithInputs}
|
|
||||||
>
|
|
||||||
Run
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
className="w-fit"
|
|
||||||
onClick={() => {
|
|
||||||
setShowInputForm(false);
|
|
||||||
setInputValues({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{expectedInputs.length > 0 && !inputSchema && (
|
|
||||||
<div className="rounded-2xl border bg-background p-3">
|
|
||||||
<ContentCardTitle className="text-xs">
|
|
||||||
Expected inputs
|
|
||||||
</ContentCardTitle>
|
|
||||||
<div className="mt-2 grid gap-2">
|
|
||||||
{expectedInputs.map((input) => (
|
|
||||||
<div key={input.name} className="rounded-xl border p-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<ContentCardTitle className="text-xs">
|
|
||||||
{input.title}
|
|
||||||
</ContentCardTitle>
|
|
||||||
<ContentBadge>
|
|
||||||
{input.required ? "Required" : "Optional"}
|
|
||||||
</ContentBadge>
|
|
||||||
</div>
|
|
||||||
<ContentCardDescription className="mt-1">
|
|
||||||
{input.name} • {input.type}
|
|
||||||
{input.description ? ` \u2022 ${input.description}` : ""}
|
|
||||||
</ContentCardDescription>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function CredentialsFlatView({
|
|||||||
) : (
|
) : (
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={onAddCredential}
|
onClick={onAddCredential}
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { RJSFSchema } from "@rjsf/utils";
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { customValidator } from "./utils/custom-validator";
|
|
||||||
import Form from "./registry";
|
import Form from "./registry";
|
||||||
import { ExtendedFormContextType } from "./types";
|
import { ExtendedFormContextType } from "./types";
|
||||||
|
import { customValidator } from "./utils/custom-validator";
|
||||||
import { generateUiSchemaForCustomFields } from "./utils/generate-ui-schema";
|
import { generateUiSchemaForCustomFields } from "./utils/generate-ui-schema";
|
||||||
|
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
|
||||||
|
|
||||||
type FormRendererProps = {
|
type FormRendererProps = {
|
||||||
jsonSchema: RJSFSchema;
|
jsonSchema: RJSFSchema;
|
||||||
@@ -12,15 +13,17 @@ type FormRendererProps = {
|
|||||||
uiSchema: any;
|
uiSchema: any;
|
||||||
initialValues: any;
|
initialValues: any;
|
||||||
formContext: ExtendedFormContextType;
|
formContext: ExtendedFormContextType;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormRenderer = ({
|
export function FormRenderer({
|
||||||
jsonSchema,
|
jsonSchema,
|
||||||
handleChange,
|
handleChange,
|
||||||
uiSchema,
|
uiSchema,
|
||||||
initialValues,
|
initialValues,
|
||||||
formContext,
|
formContext,
|
||||||
}: FormRendererProps) => {
|
className,
|
||||||
|
}: FormRendererProps) {
|
||||||
const preprocessedSchema = useMemo(() => {
|
const preprocessedSchema = useMemo(() => {
|
||||||
return preprocessInputSchema(jsonSchema);
|
return preprocessInputSchema(jsonSchema);
|
||||||
}, [jsonSchema]);
|
}, [jsonSchema]);
|
||||||
@@ -31,7 +34,10 @@ export const FormRenderer = ({
|
|||||||
}, [preprocessedSchema, uiSchema]);
|
}, [preprocessedSchema, uiSchema]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"mb-6 mt-4"} data-tutorial-id="input-handles">
|
<div
|
||||||
|
className={cn("mb-6 mt-4", className)}
|
||||||
|
data-tutorial-id="input-handles"
|
||||||
|
>
|
||||||
<Form
|
<Form
|
||||||
formContext={formContext}
|
formContext={formContext}
|
||||||
idPrefix="agpt"
|
idPrefix="agpt"
|
||||||
@@ -45,4 +51,4 @@ export const FormRenderer = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
## Advanced Setup
|
## Advanced Setup
|
||||||
|
|
||||||
* [Advanced Setup](advanced_setup.md)
|
* [Advanced Setup](advanced_setup.md)
|
||||||
|
* [Deployment Environment Variables](deployment-environment-variables.md)
|
||||||
|
|
||||||
## Building Blocks
|
## Building Blocks
|
||||||
|
|
||||||
|
|||||||
397
docs/platform/deployment-environment-variables.md
Normal file
397
docs/platform/deployment-environment-variables.md
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
# Deployment Environment Variables
|
||||||
|
|
||||||
|
This guide documents **all environment variables that must be configured** when deploying AutoGPT to a new server or environment. Use this as a checklist to ensure your deployment works correctly.
|
||||||
|
|
||||||
|
## Quick Reference: What MUST Change
|
||||||
|
|
||||||
|
When deploying to a new server, these variables **must** be updated from their localhost defaults:
|
||||||
|
|
||||||
|
| Variable | Location | Default | Purpose |
|
||||||
|
|----------|----------|---------|---------|
|
||||||
|
| `SITE_URL` | `.env` | `http://localhost:3000` | Frontend URL for auth redirects |
|
||||||
|
| `API_EXTERNAL_URL` | `.env` | `http://localhost:8000` | Public Supabase API URL |
|
||||||
|
| `SUPABASE_PUBLIC_URL` | `.env` | `http://localhost:8000` | Studio dashboard URL |
|
||||||
|
| `PLATFORM_BASE_URL` | `backend/.env` | `http://localhost:8000` | Backend platform URL |
|
||||||
|
| `FRONTEND_BASE_URL` | `backend/.env` | `http://localhost:3000` | Frontend URL for webhooks/OAuth |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_URL` | `frontend/.env` | `http://localhost:8000` | Client-side Supabase URL |
|
||||||
|
| `NEXT_PUBLIC_AGPT_SERVER_URL` | `frontend/.env` | `http://localhost:8006/api` | Client-side backend API URL |
|
||||||
|
| `NEXT_PUBLIC_AGPT_WS_SERVER_URL` | `frontend/.env` | `ws://localhost:8001/ws` | Client-side WebSocket URL |
|
||||||
|
| `NEXT_PUBLIC_FRONTEND_BASE_URL` | `frontend/.env` | `http://localhost:3000` | Client-side frontend URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
AutoGPT uses multiple `.env` files across different components:
|
||||||
|
|
||||||
|
```text
|
||||||
|
autogpt_platform/
|
||||||
|
├── .env # Supabase/infrastructure config
|
||||||
|
├── backend/
|
||||||
|
│ ├── .env.default # Backend defaults (DO NOT EDIT)
|
||||||
|
│ └── .env # Your backend overrides
|
||||||
|
└── frontend/
|
||||||
|
├── .env.default # Frontend defaults (DO NOT EDIT)
|
||||||
|
└── .env # Your frontend overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
**Loading Order** (later overrides earlier):
|
||||||
|
|
||||||
|
1. `*.env.default` - Base defaults
|
||||||
|
2. `*.env` - Your overrides
|
||||||
|
3. Docker `environment:` section
|
||||||
|
4. Shell environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. URL Configuration (REQUIRED)
|
||||||
|
|
||||||
|
These URLs must be updated to match your deployment domain/IP.
|
||||||
|
|
||||||
|
### Root `.env` (Supabase)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auth redirects - where users return after login
|
||||||
|
SITE_URL=https://your-domain.com:3000
|
||||||
|
|
||||||
|
# Public API URL - exposed to clients
|
||||||
|
API_EXTERNAL_URL=https://your-domain.com:8000
|
||||||
|
|
||||||
|
# Studio dashboard URL
|
||||||
|
SUPABASE_PUBLIC_URL=https://your-domain.com:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Platform URLs for webhooks and OAuth callbacks
|
||||||
|
PLATFORM_BASE_URL=https://your-domain.com:8000
|
||||||
|
FRONTEND_BASE_URL=https://your-domain.com:3000
|
||||||
|
|
||||||
|
# Internal Supabase URL (use Docker service name if containerized)
|
||||||
|
SUPABASE_URL=http://kong:8000 # Docker
|
||||||
|
# SUPABASE_URL=https://your-domain.com:8000 # External
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Client-side URLs (used in browser)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://your-domain.com:8000
|
||||||
|
NEXT_PUBLIC_AGPT_SERVER_URL=https://your-domain.com:8006/api
|
||||||
|
NEXT_PUBLIC_AGPT_WS_SERVER_URL=wss://your-domain.com:8001/ws
|
||||||
|
NEXT_PUBLIC_FRONTEND_BASE_URL=https://your-domain.com:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "HTTPS Note"
|
||||||
|
For production, use HTTPS URLs and `wss://` for WebSocket. You'll need a reverse proxy (nginx, Caddy) with SSL certificates.
|
||||||
|
|
||||||
|
!!! info "Port Numbers"
|
||||||
|
The port numbers shown (`:3000`, `:8000`, `:8001`, `:8006`) are internal Docker service ports. In production with a reverse proxy, your public URLs typically won't include port numbers (e.g., `https://your-domain.com` instead of `https://your-domain.com:3000`). Configure your reverse proxy to route external traffic to the internal service ports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Security Keys (MUST REGENERATE)
|
||||||
|
|
||||||
|
These default values are **public** and **must be changed** for production.
|
||||||
|
|
||||||
|
### Root `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database password
|
||||||
|
POSTGRES_PASSWORD=<generate-strong-password>
|
||||||
|
|
||||||
|
# JWT secret for Supabase auth (min 32 chars)
|
||||||
|
JWT_SECRET=<generate-random-string>
|
||||||
|
|
||||||
|
# Supabase keys (regenerate with matching JWT_SECRET)
|
||||||
|
ANON_KEY=<regenerate>
|
||||||
|
SERVICE_ROLE_KEY=<regenerate>
|
||||||
|
|
||||||
|
# Studio dashboard credentials
|
||||||
|
DASHBOARD_USERNAME=<your-username>
|
||||||
|
DASHBOARD_PASSWORD=<strong-password>
|
||||||
|
|
||||||
|
# Encryption keys
|
||||||
|
SECRET_KEY_BASE=<generate-random-string>
|
||||||
|
VAULT_ENC_KEY=<generate-32-char-key> # Run: openssl rand -hex 16
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Must match root POSTGRES_PASSWORD
|
||||||
|
DB_PASS=<same-as-POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# Must match root SERVICE_ROLE_KEY
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=<same-as-SERVICE_ROLE_KEY>
|
||||||
|
|
||||||
|
# Must match root JWT_SECRET
|
||||||
|
JWT_VERIFY_KEY=<same-as-JWT_SECRET>
|
||||||
|
|
||||||
|
# Generate new encryption keys
|
||||||
|
# Run: python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())"
|
||||||
|
ENCRYPTION_KEY=<generated-fernet-key>
|
||||||
|
UNSUBSCRIBE_SECRET_KEY=<generated-fernet-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating Keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate Fernet encryption key (for ENCRYPTION_KEY, UNSUBSCRIBE_SECRET_KEY)
|
||||||
|
python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())"
|
||||||
|
|
||||||
|
# Generate random string (for JWT_SECRET, SECRET_KEY_BASE)
|
||||||
|
openssl rand -base64 32
|
||||||
|
|
||||||
|
# Generate 32-character key (for VAULT_ENC_KEY)
|
||||||
|
openssl rand -hex 16
|
||||||
|
|
||||||
|
# Generate Supabase keys (requires matching JWT_SECRET)
|
||||||
|
# Use: https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database Configuration
|
||||||
|
|
||||||
|
### Root `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTGRES_HOST=db # Docker service name or external host
|
||||||
|
POSTGRES_DB=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_PASSWORD=<your-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASS=<your-password>
|
||||||
|
DB_NAME=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_HOST=localhost # Default is localhost; use 'db' in Docker
|
||||||
|
DB_SCHEMA=platform
|
||||||
|
|
||||||
|
# Connection pooling
|
||||||
|
DB_CONNECTION_LIMIT=12
|
||||||
|
DB_CONNECT_TIMEOUT=60
|
||||||
|
DB_POOL_TIMEOUT=300
|
||||||
|
|
||||||
|
# Full connection URL (auto-constructed from above in .env.default)
|
||||||
|
# Variable substitution is handled automatically; only override if you need custom parameters
|
||||||
|
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Service Dependencies
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDIS_HOST=redis # Docker: 'redis', External: hostname/IP
|
||||||
|
REDIS_PORT=6379
|
||||||
|
# REDIS_PASSWORD= # Uncomment if using authentication
|
||||||
|
```
|
||||||
|
|
||||||
|
### RabbitMQ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RABBITMQ_DEFAULT_USER=<username>
|
||||||
|
RABBITMQ_DEFAULT_PASS=<strong-password>
|
||||||
|
# In Docker, host is 'rabbitmq'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Default Ports
|
||||||
|
|
||||||
|
| Service | Port | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| Frontend | 3000 | Next.js web UI |
|
||||||
|
| Kong (Supabase API) | 8000 | API gateway |
|
||||||
|
| WebSocket Server | 8001 | Real-time updates |
|
||||||
|
| Executor | 8002 | Agent execution |
|
||||||
|
| Scheduler | 8003 | Scheduled tasks |
|
||||||
|
| Database Manager | 8005 | DB operations |
|
||||||
|
| REST Server | 8006 | Main API |
|
||||||
|
| Notification Server | 8007 | Notifications |
|
||||||
|
| PostgreSQL | 5432 | Database |
|
||||||
|
| Redis | 6379 | Cache/queue |
|
||||||
|
| RabbitMQ | 5672/15672 | Message queue |
|
||||||
|
| ClamAV | 3310 | Antivirus scanning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. OAuth Callbacks
|
||||||
|
|
||||||
|
When configuring OAuth providers, use this callback URL format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://your-domain.com/auth/integrations/oauth_callback
|
||||||
|
# Or with explicit port if not using a reverse proxy:
|
||||||
|
# https://your-domain.com:3000/auth/integrations/oauth_callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported OAuth Providers
|
||||||
|
|
||||||
|
| Provider | Env Variables | Setup URL |
|
||||||
|
|----------|---------------|-----------|
|
||||||
|
| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | [github.com/settings/developers](https://github.com/settings/developers) |
|
||||||
|
| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | [console.cloud.google.com](https://console.cloud.google.com/apis/credentials) |
|
||||||
|
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` | [discord.com/developers](https://discord.com/developers/applications) |
|
||||||
|
| Twitter/X | `TWITTER_CLIENT_ID`, `TWITTER_CLIENT_SECRET` | [developer.x.com](https://developer.x.com) |
|
||||||
|
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET` | [developers.notion.com](https://developers.notion.com) |
|
||||||
|
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET` | [linear.app/settings/api](https://linear.app/settings/api/applications/new) |
|
||||||
|
| Reddit | `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET` | [reddit.com/prefs/apps](https://reddit.com/prefs/apps) |
|
||||||
|
| Todoist | `TODOIST_CLIENT_ID`, `TODOIST_CLIENT_SECRET` | [developer.todoist.com](https://developer.todoist.com/appconsole.html) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Optional Services
|
||||||
|
|
||||||
|
### AI/LLM Providers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GROQ_API_KEY=
|
||||||
|
OPEN_ROUTER_API_KEY=
|
||||||
|
NVIDIA_API_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email (SMTP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supabase auth emails
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=<username>
|
||||||
|
SMTP_PASS=<password>
|
||||||
|
SMTP_ADMIN_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Application emails (Postmark)
|
||||||
|
POSTMARK_SERVER_API_TOKEN=
|
||||||
|
POSTMARK_SENDER_EMAIL=noreply@your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payments (Stripe)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STRIPE_API_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Tracking (Sentry)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SENTRY_DSN=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics (PostHog)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTHOG_API_KEY=
|
||||||
|
POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deployment Checklist
|
||||||
|
|
||||||
|
Use this checklist when deploying to a new environment:
|
||||||
|
|
||||||
|
### Pre-deployment
|
||||||
|
|
||||||
|
- [ ] Clone repository and navigate to `autogpt_platform/`
|
||||||
|
- [ ] Copy all `.env.default` files to `.env`
|
||||||
|
- [ ] Determine your deployment domain/IP
|
||||||
|
|
||||||
|
### URL Configuration
|
||||||
|
|
||||||
|
- [ ] Update `SITE_URL` in root `.env`
|
||||||
|
- [ ] Update `API_EXTERNAL_URL` in root `.env`
|
||||||
|
- [ ] Update `SUPABASE_PUBLIC_URL` in root `.env`
|
||||||
|
- [ ] Update `PLATFORM_BASE_URL` in `backend/.env`
|
||||||
|
- [ ] Update `FRONTEND_BASE_URL` in `backend/.env`
|
||||||
|
- [ ] Update all `NEXT_PUBLIC_*` URLs in `frontend/.env`
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- [ ] Generate new `POSTGRES_PASSWORD`
|
||||||
|
- [ ] Generate new `JWT_SECRET` (min 32 chars)
|
||||||
|
- [ ] Regenerate `ANON_KEY` and `SERVICE_ROLE_KEY`
|
||||||
|
- [ ] Change `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD`
|
||||||
|
- [ ] Generate new `ENCRYPTION_KEY` (backend)
|
||||||
|
- [ ] Generate new `UNSUBSCRIBE_SECRET_KEY` (backend)
|
||||||
|
- [ ] Update `DB_PASS` to match `POSTGRES_PASSWORD`
|
||||||
|
- [ ] Update `JWT_VERIFY_KEY` to match `JWT_SECRET`
|
||||||
|
- [ ] Update `SUPABASE_SERVICE_ROLE_KEY` to match
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- [ ] Configure Redis connection (if external)
|
||||||
|
- [ ] Configure RabbitMQ credentials
|
||||||
|
- [ ] Configure SMTP for emails (if needed)
|
||||||
|
|
||||||
|
### OAuth (if using integrations)
|
||||||
|
|
||||||
|
- [ ] Register OAuth apps with your callback URL
|
||||||
|
- [ ] Add client IDs and secrets to `backend/.env`
|
||||||
|
|
||||||
|
### Post-deployment
|
||||||
|
|
||||||
|
- [ ] Run `docker compose up -d --build`
|
||||||
|
- [ ] Verify frontend loads at your URL
|
||||||
|
- [ ] Test authentication flow
|
||||||
|
- [ ] Test WebSocket connection (real-time updates)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Docker vs External Services
|
||||||
|
|
||||||
|
### Running Everything in Docker (Default)
|
||||||
|
|
||||||
|
The docker-compose files automatically set internal hostnames:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Internal Docker service names (container-to-container communication)
|
||||||
|
# These are set automatically in docker-compose.platform.yml
|
||||||
|
DB_HOST: db
|
||||||
|
REDIS_HOST: redis
|
||||||
|
RABBITMQ_HOST: rabbitmq
|
||||||
|
SUPABASE_URL: http://kong:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using External Services
|
||||||
|
|
||||||
|
If using managed services (AWS RDS, Redis Cloud, etc.), override in your `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# External PostgreSQL
|
||||||
|
DB_HOST=your-rds-instance.region.rds.amazonaws.com
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# External Redis
|
||||||
|
REDIS_HOST=your-redis.cache.amazonaws.com
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<if-required>
|
||||||
|
|
||||||
|
# External Supabase (hosted)
|
||||||
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Getting Started](getting-started.md) - Basic setup guide
|
||||||
|
- [Advanced Setup](advanced_setup.md) - Development configuration
|
||||||
|
- [OAuth & SSO](integrating/oauth-guide.md) - Integration setup
|
||||||
@@ -218,6 +218,17 @@ If you initially installed Docker with Hyper-V, you **don’t need to reinstall*
|
|||||||
|
|
||||||
For more details, refer to [Docker's official documentation](https://docs.docker.com/desktop/windows/wsl/).
|
For more details, refer to [Docker's official documentation](https://docs.docker.com/desktop/windows/wsl/).
|
||||||
|
|
||||||
|
### ⚠️ Podman Not Supported
|
||||||
|
|
||||||
|
AutoGPT requires **Docker** (Docker Desktop or Docker Engine). **Podman and podman-compose are not supported** and may cause path resolution issues, particularly on Windows.
|
||||||
|
|
||||||
|
If you see errors like:
|
||||||
|
```text
|
||||||
|
Error: the specified Containerfile or Dockerfile does not exist, ..\..\autogpt_platform\backend\Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
This indicates you're using Podman instead of Docker. Please install [Docker Desktop](https://docs.docker.com/desktop/) and use `docker compose` instead of `podman-compose`.
|
||||||
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user