mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-24 03:00:28 -05:00
Compare commits
9 Commits
docs/deplo
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e6fca8c3 | ||
|
|
6e737e0b74 | ||
|
|
5ce002803d | ||
|
|
f8ad8484ee | ||
|
|
b6064d0155 | ||
|
|
76e0c96aa9 | ||
|
|
3364a8e415 | ||
|
|
9f4f2749a4 | ||
|
|
2b0f457985 |
@@ -27,6 +27,7 @@ class ChatConfig(BaseSettings):
|
||||
session_ttl: int = Field(default=43200, description="Session TTL in seconds")
|
||||
|
||||
# Streaming Configuration
|
||||
stream_timeout: int = Field(default=300, description="Stream timeout in seconds")
|
||||
max_retries: int = Field(
|
||||
default=3,
|
||||
description="Max retries for fallback path (SDK handles retries internally)",
|
||||
@@ -38,10 +39,8 @@ class ChatConfig(BaseSettings):
|
||||
|
||||
# Long-running operation configuration
|
||||
long_running_operation_ttl: int = Field(
|
||||
default=3600,
|
||||
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.",
|
||||
default=600,
|
||||
description="TTL in seconds for long-running operation tracking in Redis (safety net if pod dies)",
|
||||
)
|
||||
|
||||
# Stream registry configuration for SSE reconnection
|
||||
@@ -49,11 +48,6 @@ class ChatConfig(BaseSettings):
|
||||
default=3600,
|
||||
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(
|
||||
default=10000,
|
||||
description="Maximum number of messages to store per stream",
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from prisma.errors import UniqueViolationError
|
||||
from prisma.models import ChatMessage as PrismaChatMessage
|
||||
from prisma.models import ChatSession as PrismaChatSession
|
||||
from prisma.types import (
|
||||
@@ -93,9 +92,10 @@ async def add_chat_message(
|
||||
function_call: dict[str, Any] | None = None,
|
||||
) -> ChatMessage:
|
||||
"""Add a message to a chat session."""
|
||||
# Build ChatMessageCreateInput with only non-None values
|
||||
# (Prisma TypedDict rejects optional fields set to None)
|
||||
data: ChatMessageCreateInput = {
|
||||
# Build input dict dynamically rather than using ChatMessageCreateInput directly
|
||||
# because Prisma's TypedDict validation rejects optional fields set to None.
|
||||
# We only include fields that have values, then cast at the end.
|
||||
data: dict[str, Any] = {
|
||||
"Session": {"connect": {"id": session_id}},
|
||||
"role": role,
|
||||
"sequence": sequence,
|
||||
@@ -123,7 +123,7 @@ async def add_chat_message(
|
||||
where={"id": session_id},
|
||||
data={"updatedAt": datetime.now(UTC)},
|
||||
),
|
||||
PrismaChatMessage.prisma().create(data=data),
|
||||
PrismaChatMessage.prisma().create(data=cast(ChatMessageCreateInput, data)),
|
||||
)
|
||||
return ChatMessage.from_db(message)
|
||||
|
||||
@@ -132,93 +132,58 @@ async def add_chat_messages_batch(
|
||||
session_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
start_sequence: int,
|
||||
) -> int:
|
||||
) -> list[ChatMessage]:
|
||||
"""Add multiple messages to a chat session in a batch.
|
||||
|
||||
Uses collision detection with retry: tries to create messages starting
|
||||
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.
|
||||
Uses a transaction for atomicity - if any message creation fails,
|
||||
the entire batch is rolled back.
|
||||
"""
|
||||
if not messages:
|
||||
# No messages to add - return current count
|
||||
return start_sequence
|
||||
return []
|
||||
|
||||
max_retries = 5
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Single timestamp for all messages and session update
|
||||
now = datetime.now(UTC)
|
||||
created_messages = []
|
||||
|
||||
async with db.transaction() as tx:
|
||||
# Build all message data
|
||||
messages_data = []
|
||||
for i, msg in enumerate(messages):
|
||||
# Build ChatMessageCreateInput with only non-None values
|
||||
# (Prisma TypedDict rejects optional fields set to None)
|
||||
# Note: create_many doesn't support nested creates, use sessionId directly
|
||||
data: ChatMessageCreateInput = {
|
||||
"sessionId": session_id,
|
||||
"role": msg["role"],
|
||||
"sequence": start_sequence + i,
|
||||
"createdAt": now,
|
||||
}
|
||||
async with db.transaction() as tx:
|
||||
for i, msg in enumerate(messages):
|
||||
# Build input dict dynamically rather than using ChatMessageCreateInput
|
||||
# directly because Prisma's TypedDict validation rejects optional fields
|
||||
# set to None. We only include fields that have values, then cast.
|
||||
data: dict[str, Any] = {
|
||||
"Session": {"connect": {"id": session_id}},
|
||||
"role": msg["role"],
|
||||
"sequence": start_sequence + i,
|
||||
}
|
||||
|
||||
# Add optional string fields
|
||||
if msg.get("content") is not None:
|
||||
data["content"] = msg["content"]
|
||||
if msg.get("name") is not None:
|
||||
data["name"] = msg["name"]
|
||||
if msg.get("tool_call_id") is not None:
|
||||
data["toolCallId"] = msg["tool_call_id"]
|
||||
if msg.get("refusal") is not None:
|
||||
data["refusal"] = msg["refusal"]
|
||||
# Add optional string fields
|
||||
if msg.get("content") is not None:
|
||||
data["content"] = msg["content"]
|
||||
if msg.get("name") is not None:
|
||||
data["name"] = msg["name"]
|
||||
if msg.get("tool_call_id") is not None:
|
||||
data["toolCallId"] = msg["tool_call_id"]
|
||||
if msg.get("refusal") is not None:
|
||||
data["refusal"] = msg["refusal"]
|
||||
|
||||
# Add optional JSON fields only when they have values
|
||||
if msg.get("tool_calls") is not None:
|
||||
data["toolCalls"] = SafeJson(msg["tool_calls"])
|
||||
if msg.get("function_call") is not None:
|
||||
data["functionCall"] = SafeJson(msg["function_call"])
|
||||
# Add optional JSON fields only when they have values
|
||||
if msg.get("tool_calls") is not None:
|
||||
data["toolCalls"] = SafeJson(msg["tool_calls"])
|
||||
if msg.get("function_call") is not None:
|
||||
data["functionCall"] = SafeJson(msg["function_call"])
|
||||
|
||||
messages_data.append(data)
|
||||
created = await PrismaChatMessage.prisma(tx).create(
|
||||
data=cast(ChatMessageCreateInput, data)
|
||||
)
|
||||
created_messages.append(created)
|
||||
|
||||
# Run create_many and session update in parallel within transaction
|
||||
# Both use the same timestamp for consistency
|
||||
await asyncio.gather(
|
||||
PrismaChatMessage.prisma(tx).create_many(data=messages_data),
|
||||
PrismaChatSession.prisma(tx).update(
|
||||
where={"id": session_id},
|
||||
data={"updatedAt": now},
|
||||
),
|
||||
)
|
||||
# Update session's updatedAt timestamp within the same transaction.
|
||||
# Note: Token usage (total_prompt_tokens, total_completion_tokens) is updated
|
||||
# separately via update_chat_session() after streaming completes.
|
||||
await PrismaChatSession.prisma(tx).update(
|
||||
where={"id": session_id},
|
||||
data={"updatedAt": datetime.now(UTC)},
|
||||
)
|
||||
|
||||
# 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")
|
||||
return [ChatMessage.from_db(m) for m in created_messages]
|
||||
|
||||
|
||||
async def get_user_chat_sessions(
|
||||
@@ -272,20 +237,10 @@ async def delete_chat_session(session_id: str, user_id: str | None = None) -> bo
|
||||
return False
|
||||
|
||||
|
||||
async def get_next_sequence(session_id: str) -> int:
|
||||
"""Get the next sequence number for a new message in this session.
|
||||
|
||||
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 get_chat_session_message_count(session_id: str) -> int:
|
||||
"""Get the number of messages in a chat session."""
|
||||
count = await PrismaChatMessage.prisma().count(where={"sessionId": session_id})
|
||||
return count
|
||||
|
||||
|
||||
async def update_tool_message_content(
|
||||
|
||||
@@ -266,11 +266,7 @@ class CoPilotProcessor:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
log.info("Task cancelled")
|
||||
await stream_registry.mark_task_completed(
|
||||
entry.task_id,
|
||||
status="failed",
|
||||
error_message="Task was cancelled",
|
||||
)
|
||||
await stream_registry.mark_task_completed(entry.task_id, status="failed")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -432,9 +432,7 @@ async def _get_session_from_db(session_id: str) -> ChatSession | None:
|
||||
return session
|
||||
|
||||
|
||||
async def upsert_chat_session(
|
||||
session: ChatSession,
|
||||
) -> ChatSession:
|
||||
async def upsert_chat_session(session: ChatSession) -> ChatSession:
|
||||
"""Update a chat session in both cache and database.
|
||||
|
||||
Uses session-level locking to prevent race conditions when concurrent
|
||||
@@ -451,18 +449,16 @@ async def upsert_chat_session(
|
||||
lock = await _get_session_lock(session.session_id)
|
||||
|
||||
async with lock:
|
||||
# Always query DB for existing message count to ensure consistency
|
||||
existing_message_count = await chat_db().get_next_sequence(session.session_id)
|
||||
# Get existing message count from DB for incremental saves
|
||||
existing_message_count = await chat_db().get_chat_session_message_count(
|
||||
session.session_id
|
||||
)
|
||||
|
||||
db_error: Exception | None = None
|
||||
|
||||
# Save to database (primary storage)
|
||||
try:
|
||||
await _save_session_to_db(
|
||||
session,
|
||||
existing_message_count,
|
||||
skip_existence_check=existing_message_count > 0,
|
||||
)
|
||||
await _save_session_to_db(session, existing_message_count)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to save session {session.session_id} to database: {e}"
|
||||
@@ -493,31 +489,21 @@ async def upsert_chat_session(
|
||||
|
||||
|
||||
async def _save_session_to_db(
|
||||
session: ChatSession,
|
||||
existing_message_count: int,
|
||||
*,
|
||||
skip_existence_check: bool = False,
|
||||
session: ChatSession, existing_message_count: int
|
||||
) -> None:
|
||||
"""Save or update a chat session in the database.
|
||||
|
||||
Args:
|
||||
skip_existence_check: When True, skip the ``get_chat_session`` query
|
||||
and assume the session row already exists. Saves one DB round trip
|
||||
for incremental saves during streaming.
|
||||
"""
|
||||
"""Save or update a chat session in the database."""
|
||||
db = chat_db()
|
||||
|
||||
if not skip_existence_check:
|
||||
# Check if session exists in DB
|
||||
existing = await db.get_chat_session(session.session_id)
|
||||
# Check if session exists in DB
|
||||
existing = await db.get_chat_session(session.session_id)
|
||||
|
||||
if not existing:
|
||||
# Create new session
|
||||
await db.create_chat_session(
|
||||
session_id=session.session_id,
|
||||
user_id=session.user_id,
|
||||
)
|
||||
existing_message_count = 0
|
||||
if not existing:
|
||||
# Create new session
|
||||
await db.create_chat_session(
|
||||
session_id=session.session_id,
|
||||
user_id=session.user_id,
|
||||
)
|
||||
existing_message_count = 0
|
||||
|
||||
# Calculate total tokens from usage
|
||||
total_prompt = sum(u.prompt_tokens for u in session.usage)
|
||||
@@ -576,7 +562,9 @@ async def append_and_save_message(session_id: str, message: ChatMessage) -> Chat
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
session.messages.append(message)
|
||||
existing_message_count = await chat_db().get_next_sequence(session_id)
|
||||
existing_message_count = await chat_db().get_chat_session_message_count(
|
||||
session_id
|
||||
)
|
||||
|
||||
try:
|
||||
await _save_session_to_db(session, existing_message_count)
|
||||
|
||||
@@ -331,96 +331,3 @@ def test_to_openai_messages_merges_split_assistants():
|
||||
tc_list = merged.get("tool_calls")
|
||||
assert tc_list is not None and len(list(tc_list)) == 1
|
||||
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
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Tests for _format_conversation_context and _build_query_message."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.copilot.model import ChatMessage, ChatSession
|
||||
from backend.copilot.sdk.service import (
|
||||
_build_query_message,
|
||||
_format_conversation_context,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_conversation_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_empty_list():
|
||||
assert _format_conversation_context([]) is None
|
||||
|
||||
|
||||
def test_format_none_content_messages():
|
||||
msgs = [ChatMessage(role="user", content=None)]
|
||||
assert _format_conversation_context(msgs) is None
|
||||
|
||||
|
||||
def test_format_user_message():
|
||||
msgs = [ChatMessage(role="user", content="hello")]
|
||||
result = _format_conversation_context(msgs)
|
||||
assert result is not None
|
||||
assert "User: hello" in result
|
||||
assert result.startswith("<conversation_history>")
|
||||
assert result.endswith("</conversation_history>")
|
||||
|
||||
|
||||
def test_format_assistant_text():
|
||||
msgs = [ChatMessage(role="assistant", content="hi there")]
|
||||
result = _format_conversation_context(msgs)
|
||||
assert result is not None
|
||||
assert "You responded: hi there" in result
|
||||
|
||||
|
||||
def test_format_assistant_tool_calls():
|
||||
msgs = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
content=None,
|
||||
tool_calls=[{"function": {"name": "search", "arguments": '{"q": "test"}'}}],
|
||||
)
|
||||
]
|
||||
result = _format_conversation_context(msgs)
|
||||
assert result is not None
|
||||
assert 'You called tool: search({"q": "test"})' in result
|
||||
|
||||
|
||||
def test_format_tool_result():
|
||||
msgs = [ChatMessage(role="tool", content='{"result": "ok"}')]
|
||||
result = _format_conversation_context(msgs)
|
||||
assert result is not None
|
||||
assert 'Tool result: {"result": "ok"}' in result
|
||||
|
||||
|
||||
def test_format_tool_result_none_content():
|
||||
msgs = [ChatMessage(role="tool", content=None)]
|
||||
result = _format_conversation_context(msgs)
|
||||
assert result is not None
|
||||
assert "Tool result: " in result
|
||||
|
||||
|
||||
def test_format_full_conversation():
|
||||
msgs = [
|
||||
ChatMessage(role="user", content="find agents"),
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
content="I'll search for agents.",
|
||||
tool_calls=[
|
||||
{"function": {"name": "find_agents", "arguments": '{"q": "test"}'}}
|
||||
],
|
||||
),
|
||||
ChatMessage(role="tool", content='[{"id": "1", "name": "Agent1"}]'),
|
||||
ChatMessage(role="assistant", content="Found Agent1."),
|
||||
]
|
||||
result = _format_conversation_context(msgs)
|
||||
assert result is not None
|
||||
assert "User: find agents" in result
|
||||
assert "You responded: I'll search for agents." in result
|
||||
assert "You called tool: find_agents" in result
|
||||
assert "Tool result:" in result
|
||||
assert "You responded: Found Agent1." in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_query_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_session(messages: list[ChatMessage]) -> ChatSession:
|
||||
"""Build a minimal ChatSession with the given messages."""
|
||||
now = datetime.now(UTC)
|
||||
return ChatSession(
|
||||
session_id="test-session",
|
||||
user_id="user-1",
|
||||
messages=messages,
|
||||
title="test",
|
||||
usage=[],
|
||||
started_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_query_resume_up_to_date():
|
||||
"""With --resume and transcript covers all messages, return raw message."""
|
||||
session = _make_session(
|
||||
[
|
||||
ChatMessage(role="user", content="hello"),
|
||||
ChatMessage(role="assistant", content="hi"),
|
||||
ChatMessage(role="user", content="what's new?"),
|
||||
]
|
||||
)
|
||||
result = await _build_query_message(
|
||||
"what's new?",
|
||||
session,
|
||||
use_resume=True,
|
||||
transcript_msg_count=2,
|
||||
session_id="test-session",
|
||||
)
|
||||
# transcript_msg_count == msg_count - 1, so no gap
|
||||
assert result == "what's new?"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_query_resume_stale_transcript():
|
||||
"""With --resume and stale transcript, gap context is prepended."""
|
||||
session = _make_session(
|
||||
[
|
||||
ChatMessage(role="user", content="turn 1"),
|
||||
ChatMessage(role="assistant", content="reply 1"),
|
||||
ChatMessage(role="user", content="turn 2"),
|
||||
ChatMessage(role="assistant", content="reply 2"),
|
||||
ChatMessage(role="user", content="turn 3"),
|
||||
]
|
||||
)
|
||||
result = await _build_query_message(
|
||||
"turn 3",
|
||||
session,
|
||||
use_resume=True,
|
||||
transcript_msg_count=2,
|
||||
session_id="test-session",
|
||||
)
|
||||
assert "<conversation_history>" in result
|
||||
assert "turn 2" in result
|
||||
assert "reply 2" in result
|
||||
assert "Now, the user says:\nturn 3" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_query_resume_zero_msg_count():
|
||||
"""With --resume but transcript_msg_count=0, return raw message."""
|
||||
session = _make_session(
|
||||
[
|
||||
ChatMessage(role="user", content="hello"),
|
||||
ChatMessage(role="assistant", content="hi"),
|
||||
ChatMessage(role="user", content="new msg"),
|
||||
]
|
||||
)
|
||||
result = await _build_query_message(
|
||||
"new msg",
|
||||
session,
|
||||
use_resume=True,
|
||||
transcript_msg_count=0,
|
||||
session_id="test-session",
|
||||
)
|
||||
assert result == "new msg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_query_no_resume_single_message():
|
||||
"""Without --resume and only 1 message, return raw message."""
|
||||
session = _make_session([ChatMessage(role="user", content="first")])
|
||||
result = await _build_query_message(
|
||||
"first",
|
||||
session,
|
||||
use_resume=False,
|
||||
transcript_msg_count=0,
|
||||
session_id="test-session",
|
||||
)
|
||||
assert result == "first"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_query_no_resume_multi_message(monkeypatch):
|
||||
"""Without --resume and multiple messages, compress and prepend."""
|
||||
session = _make_session(
|
||||
[
|
||||
ChatMessage(role="user", content="older question"),
|
||||
ChatMessage(role="assistant", content="older answer"),
|
||||
ChatMessage(role="user", content="new question"),
|
||||
]
|
||||
)
|
||||
|
||||
# Mock _compress_conversation_history to return the messages as-is
|
||||
async def _mock_compress(sess):
|
||||
return sess.messages[:-1]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.copilot.sdk.service._compress_conversation_history",
|
||||
_mock_compress,
|
||||
)
|
||||
|
||||
result = await _build_query_message(
|
||||
"new question",
|
||||
session,
|
||||
use_resume=False,
|
||||
transcript_msg_count=0,
|
||||
session_id="test-session",
|
||||
)
|
||||
assert "<conversation_history>" in result
|
||||
assert "older question" in result
|
||||
assert "older answer" in result
|
||||
assert "Now, the user says:\nnew question" in result
|
||||
@@ -47,9 +47,8 @@ class SDKResponseAdapter:
|
||||
text blocks, tool calls, and message lifecycle.
|
||||
"""
|
||||
|
||||
def __init__(self, message_id: str | None = None, session_id: str | None = None):
|
||||
def __init__(self, message_id: str | None = None):
|
||||
self.message_id = message_id or str(uuid.uuid4())
|
||||
self.session_id = session_id
|
||||
self.text_block_id = str(uuid.uuid4())
|
||||
self.has_started_text = False
|
||||
self.has_ended_text = False
|
||||
@@ -62,11 +61,6 @@ class SDKResponseAdapter:
|
||||
"""Set the task ID for reconnection support."""
|
||||
self.task_id = task_id
|
||||
|
||||
@property
|
||||
def has_unresolved_tool_calls(self) -> bool:
|
||||
"""True when there are tool calls that haven't received output yet."""
|
||||
return bool(self.current_tool_calls.keys() - self.resolved_tool_calls)
|
||||
|
||||
def convert_message(self, sdk_message: Message) -> list[StreamBaseResponse]:
|
||||
"""Convert a single SDK message to Vercel AI SDK format."""
|
||||
responses: list[StreamBaseResponse] = []
|
||||
@@ -83,12 +77,7 @@ class SDKResponseAdapter:
|
||||
elif isinstance(sdk_message, AssistantMessage):
|
||||
# Flush any SDK built-in tool calls that didn't get a UserMessage
|
||||
# result (e.g. WebSearch, Read handled internally by the CLI).
|
||||
# BUT skip flush when this AssistantMessage is a parallel tool
|
||||
# continuation (contains only ToolUseBlocks) — the prior tools
|
||||
# are still executing concurrently and haven't finished yet.
|
||||
is_tool_only = all(isinstance(b, ToolUseBlock) for b in sdk_message.content)
|
||||
if not is_tool_only:
|
||||
self._flush_unresolved_tool_calls(responses)
|
||||
self._flush_unresolved_tool_calls(responses)
|
||||
|
||||
# After tool results, the SDK sends a new AssistantMessage for the
|
||||
# next LLM turn. Open a new step if the previous one was closed.
|
||||
@@ -129,24 +118,8 @@ class SDKResponseAdapter:
|
||||
blocks = content if isinstance(content, list) else []
|
||||
resolved_in_blocks: set[str] = set()
|
||||
|
||||
sid = (self.session_id or "?")[:12]
|
||||
parent_id_preview = getattr(sdk_message, "parent_tool_use_id", None)
|
||||
logger.info(
|
||||
"[SDK] [%s] UserMessage: %d blocks, content_type=%s, "
|
||||
"parent_tool_use_id=%s",
|
||||
sid,
|
||||
len(blocks),
|
||||
type(content).__name__,
|
||||
parent_id_preview[:12] if parent_id_preview else "None",
|
||||
)
|
||||
|
||||
for block in blocks:
|
||||
if isinstance(block, ToolResultBlock) and block.tool_use_id:
|
||||
# Skip if already resolved (e.g. by flush) — the real
|
||||
# result supersedes the empty flush, but re-emitting
|
||||
# would confuse the frontend's state machine.
|
||||
if block.tool_use_id in self.resolved_tool_calls:
|
||||
continue
|
||||
tool_info = self.current_tool_calls.get(block.tool_use_id, {})
|
||||
tool_name = tool_info.get("name", "unknown")
|
||||
|
||||
@@ -171,11 +144,7 @@ class SDKResponseAdapter:
|
||||
# Handle SDK built-in tool results carried via parent_tool_use_id
|
||||
# instead of (or in addition to) ToolResultBlock content.
|
||||
parent_id = sdk_message.parent_tool_use_id
|
||||
if (
|
||||
parent_id
|
||||
and parent_id not in resolved_in_blocks
|
||||
and parent_id not in self.resolved_tool_calls
|
||||
):
|
||||
if parent_id and parent_id not in resolved_in_blocks:
|
||||
tool_info = self.current_tool_calls.get(parent_id, {})
|
||||
tool_name = tool_info.get("name", "unknown")
|
||||
|
||||
@@ -259,28 +228,11 @@ class SDKResponseAdapter:
|
||||
output, which we pop and emit here before the next ``AssistantMessage``
|
||||
starts.
|
||||
"""
|
||||
unresolved = [
|
||||
(tid, info.get("name", "unknown"))
|
||||
for tid, info in self.current_tool_calls.items()
|
||||
if tid not in self.resolved_tool_calls
|
||||
]
|
||||
sid = (self.session_id or "?")[:12]
|
||||
if not unresolved:
|
||||
logger.info(
|
||||
"[SDK] [%s] Flush called but all %d tool(s) already resolved",
|
||||
sid,
|
||||
len(self.current_tool_calls),
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"[SDK] [%s] Flushing %d unresolved tool call(s): %s",
|
||||
sid,
|
||||
len(unresolved),
|
||||
", ".join(f"{name}({tid[:12]})" for tid, name in unresolved),
|
||||
)
|
||||
|
||||
flushed = False
|
||||
for tool_id, tool_name in unresolved:
|
||||
for tool_id, tool_info in self.current_tool_calls.items():
|
||||
if tool_id in self.resolved_tool_calls:
|
||||
continue
|
||||
tool_name = tool_info.get("name", "unknown")
|
||||
output = pop_pending_tool_output(tool_name)
|
||||
if output is not None:
|
||||
responses.append(
|
||||
@@ -293,12 +245,9 @@ class SDKResponseAdapter:
|
||||
)
|
||||
self.resolved_tool_calls.add(tool_id)
|
||||
flushed = True
|
||||
logger.info(
|
||||
"[SDK] [%s] Flushed stashed output for %s " "(call %s, %d chars)",
|
||||
sid,
|
||||
tool_name,
|
||||
tool_id[:12],
|
||||
len(output),
|
||||
logger.debug(
|
||||
f"Flushed pending output for built-in tool {tool_name} "
|
||||
f"(call {tool_id})"
|
||||
)
|
||||
else:
|
||||
# No output available — emit an empty output so the frontend
|
||||
@@ -314,14 +263,9 @@ class SDKResponseAdapter:
|
||||
)
|
||||
self.resolved_tool_calls.add(tool_id)
|
||||
flushed = True
|
||||
logger.warning(
|
||||
"[SDK] [%s] Flushed EMPTY output for unresolved tool %s "
|
||||
"(call %s) — stash was empty (likely SDK hook race "
|
||||
"condition: PostToolUse hook hadn't completed before "
|
||||
"flush was triggered)",
|
||||
sid,
|
||||
tool_name,
|
||||
tool_id[:12],
|
||||
logger.debug(
|
||||
f"Flushed empty output for unresolved tool {tool_name} "
|
||||
f"(call {tool_id})"
|
||||
)
|
||||
|
||||
if flushed and self.step_open:
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Unit tests for the SDK response adapter."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ResultMessage,
|
||||
@@ -30,10 +27,6 @@ from backend.copilot.response_model import (
|
||||
|
||||
from .response_adapter import SDKResponseAdapter
|
||||
from .tool_adapter import MCP_TOOL_PREFIX
|
||||
from .tool_adapter import _pending_tool_outputs as _pto
|
||||
from .tool_adapter import _stash_event
|
||||
from .tool_adapter import stash_pending_tool_output as _stash
|
||||
from .tool_adapter import wait_for_stash
|
||||
|
||||
|
||||
def _adapter() -> SDKResponseAdapter:
|
||||
@@ -371,310 +364,3 @@ def test_full_conversation_flow():
|
||||
"StreamFinishStep", # step 2 closed
|
||||
"StreamFinish",
|
||||
]
|
||||
|
||||
|
||||
# -- Flush unresolved tool calls --------------------------------------------
|
||||
|
||||
|
||||
def test_flush_unresolved_at_result_message():
|
||||
"""Built-in tools (WebSearch) without UserMessage results get flushed at ResultMessage."""
|
||||
adapter = _adapter()
|
||||
all_responses: list[StreamBaseResponse] = []
|
||||
|
||||
# 1. Init
|
||||
all_responses.extend(
|
||||
adapter.convert_message(SystemMessage(subtype="init", data={}))
|
||||
)
|
||||
# 2. Tool use (built-in tool — no MCP prefix)
|
||||
all_responses.extend(
|
||||
adapter.convert_message(
|
||||
AssistantMessage(
|
||||
content=[
|
||||
ToolUseBlock(id="ws-1", name="WebSearch", input={"query": "test"})
|
||||
],
|
||||
model="test",
|
||||
)
|
||||
)
|
||||
)
|
||||
# 3. No UserMessage for this tool — go straight to ResultMessage
|
||||
all_responses.extend(
|
||||
adapter.convert_message(
|
||||
ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=100,
|
||||
duration_api_ms=50,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id="s1",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
types = [type(r).__name__ for r in all_responses]
|
||||
assert types == [
|
||||
"StreamStart",
|
||||
"StreamStartStep",
|
||||
"StreamToolInputStart",
|
||||
"StreamToolInputAvailable",
|
||||
"StreamToolOutputAvailable", # flushed with empty output
|
||||
"StreamFinishStep", # step closed by flush
|
||||
"StreamFinish",
|
||||
]
|
||||
# The flushed output should be empty (no stash available)
|
||||
output_event = [
|
||||
r for r in all_responses if isinstance(r, StreamToolOutputAvailable)
|
||||
][0]
|
||||
assert output_event.toolCallId == "ws-1"
|
||||
assert output_event.toolName == "WebSearch"
|
||||
assert output_event.output == ""
|
||||
|
||||
|
||||
def test_flush_unresolved_at_next_assistant_message():
|
||||
"""Built-in tools get flushed when the next AssistantMessage arrives."""
|
||||
adapter = _adapter()
|
||||
all_responses: list[StreamBaseResponse] = []
|
||||
|
||||
# 1. Init
|
||||
all_responses.extend(
|
||||
adapter.convert_message(SystemMessage(subtype="init", data={}))
|
||||
)
|
||||
# 2. Tool use (built-in — no UserMessage will come)
|
||||
all_responses.extend(
|
||||
adapter.convert_message(
|
||||
AssistantMessage(
|
||||
content=[
|
||||
ToolUseBlock(id="ws-1", name="WebSearch", input={"query": "test"})
|
||||
],
|
||||
model="test",
|
||||
)
|
||||
)
|
||||
)
|
||||
# 3. Next AssistantMessage triggers flush before processing its blocks
|
||||
all_responses.extend(
|
||||
adapter.convert_message(
|
||||
AssistantMessage(
|
||||
content=[TextBlock(text="Here are the results")], model="test"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
types = [type(r).__name__ for r in all_responses]
|
||||
assert types == [
|
||||
"StreamStart",
|
||||
"StreamStartStep",
|
||||
"StreamToolInputStart",
|
||||
"StreamToolInputAvailable",
|
||||
# Flush at next AssistantMessage:
|
||||
"StreamToolOutputAvailable",
|
||||
"StreamFinishStep", # step closed by flush
|
||||
# New step for continuation text:
|
||||
"StreamStartStep",
|
||||
"StreamTextStart",
|
||||
"StreamTextDelta",
|
||||
]
|
||||
|
||||
|
||||
def test_flush_with_stashed_output():
|
||||
"""Stashed output from PostToolUse hook is used when flushing."""
|
||||
adapter = _adapter()
|
||||
|
||||
# Simulate PostToolUse hook stashing output
|
||||
_pto.set({})
|
||||
_stash("WebSearch", "Search result: 5 items found")
|
||||
|
||||
all_responses: list[StreamBaseResponse] = []
|
||||
|
||||
# Tool use
|
||||
all_responses.extend(
|
||||
adapter.convert_message(
|
||||
AssistantMessage(
|
||||
content=[
|
||||
ToolUseBlock(id="ws-1", name="WebSearch", input={"query": "test"})
|
||||
],
|
||||
model="test",
|
||||
)
|
||||
)
|
||||
)
|
||||
# ResultMessage triggers flush
|
||||
all_responses.extend(
|
||||
adapter.convert_message(
|
||||
ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=100,
|
||||
duration_api_ms=50,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id="s1",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
output_events = [
|
||||
r for r in all_responses if isinstance(r, StreamToolOutputAvailable)
|
||||
]
|
||||
assert len(output_events) == 1
|
||||
assert output_events[0].output == "Search result: 5 items found"
|
||||
|
||||
# Cleanup
|
||||
_pto.set({}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# -- wait_for_stash synchronisation tests --
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_stash_signaled():
|
||||
"""wait_for_stash returns True when stash_pending_tool_output signals."""
|
||||
_pto.set({})
|
||||
event = asyncio.Event()
|
||||
_stash_event.set(event)
|
||||
|
||||
# Simulate a PostToolUse hook that stashes output after a short delay
|
||||
async def delayed_stash():
|
||||
await asyncio.sleep(0.01)
|
||||
_stash("WebSearch", "result data")
|
||||
|
||||
asyncio.create_task(delayed_stash())
|
||||
result = await wait_for_stash(timeout=1.0)
|
||||
|
||||
assert result is True
|
||||
assert _pto.get({}).get("WebSearch") == ["result data"]
|
||||
|
||||
# Cleanup
|
||||
_pto.set({}) # type: ignore[arg-type]
|
||||
_stash_event.set(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_stash_timeout():
|
||||
"""wait_for_stash returns False on timeout when no stash occurs."""
|
||||
_pto.set({})
|
||||
event = asyncio.Event()
|
||||
_stash_event.set(event)
|
||||
|
||||
result = await wait_for_stash(timeout=0.05)
|
||||
assert result is False
|
||||
|
||||
# Cleanup
|
||||
_pto.set({}) # type: ignore[arg-type]
|
||||
_stash_event.set(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_stash_already_stashed():
|
||||
"""wait_for_stash picks up a stash that happened just before the wait."""
|
||||
_pto.set({})
|
||||
event = asyncio.Event()
|
||||
_stash_event.set(event)
|
||||
|
||||
# Stash before waiting — simulates hook completing before message arrives
|
||||
_stash("Read", "file contents")
|
||||
# Event is now set; wait_for_stash detects the fast path and returns
|
||||
# immediately without timing out.
|
||||
result = await wait_for_stash(timeout=0.05)
|
||||
assert result is True
|
||||
|
||||
# But the stash itself is populated
|
||||
assert _pto.get({}).get("Read") == ["file contents"]
|
||||
|
||||
# Cleanup
|
||||
_pto.set({}) # type: ignore[arg-type]
|
||||
_stash_event.set(None)
|
||||
|
||||
|
||||
# -- Parallel tool call tests --
|
||||
|
||||
|
||||
def test_parallel_tool_calls_not_flushed_prematurely():
|
||||
"""Parallel tool calls should NOT be flushed when the next AssistantMessage
|
||||
only contains ToolUseBlocks (parallel continuation)."""
|
||||
adapter = SDKResponseAdapter()
|
||||
|
||||
# Init
|
||||
adapter.convert_message(SystemMessage(subtype="init", data={}))
|
||||
|
||||
# First AssistantMessage: tool call #1
|
||||
msg1 = AssistantMessage(
|
||||
content=[ToolUseBlock(id="t1", name="WebSearch", input={"q": "foo"})],
|
||||
model="test",
|
||||
)
|
||||
r1 = adapter.convert_message(msg1)
|
||||
assert any(isinstance(r, StreamToolInputAvailable) for r in r1)
|
||||
assert adapter.has_unresolved_tool_calls
|
||||
|
||||
# Second AssistantMessage: tool call #2 (parallel continuation)
|
||||
msg2 = AssistantMessage(
|
||||
content=[ToolUseBlock(id="t2", name="WebSearch", input={"q": "bar"})],
|
||||
model="test",
|
||||
)
|
||||
r2 = adapter.convert_message(msg2)
|
||||
|
||||
# No flush should have happened — t1 should NOT have StreamToolOutputAvailable
|
||||
output_events = [r for r in r2 if isinstance(r, StreamToolOutputAvailable)]
|
||||
assert len(output_events) == 0, (
|
||||
f"Tool-only AssistantMessage should not flush prior tools, "
|
||||
f"but got {len(output_events)} output events"
|
||||
)
|
||||
|
||||
# Both t1 and t2 should still be unresolved
|
||||
assert "t1" not in adapter.resolved_tool_calls
|
||||
assert "t2" not in adapter.resolved_tool_calls
|
||||
|
||||
|
||||
def test_text_assistant_message_flushes_prior_tools():
|
||||
"""An AssistantMessage with text (new turn) should flush unresolved tools."""
|
||||
adapter = SDKResponseAdapter()
|
||||
|
||||
# Init
|
||||
adapter.convert_message(SystemMessage(subtype="init", data={}))
|
||||
|
||||
# Tool call
|
||||
msg1 = AssistantMessage(
|
||||
content=[ToolUseBlock(id="t1", name="WebSearch", input={"q": "foo"})],
|
||||
model="test",
|
||||
)
|
||||
adapter.convert_message(msg1)
|
||||
assert adapter.has_unresolved_tool_calls
|
||||
|
||||
# Text AssistantMessage (new turn after tools completed)
|
||||
msg2 = AssistantMessage(
|
||||
content=[TextBlock(text="Here are the results")],
|
||||
model="test",
|
||||
)
|
||||
r2 = adapter.convert_message(msg2)
|
||||
|
||||
# Flush SHOULD have happened — t1 gets empty output
|
||||
output_events = [r for r in r2 if isinstance(r, StreamToolOutputAvailable)]
|
||||
assert len(output_events) == 1
|
||||
assert output_events[0].toolCallId == "t1"
|
||||
assert "t1" in adapter.resolved_tool_calls
|
||||
|
||||
|
||||
def test_already_resolved_tool_skipped_in_user_message():
|
||||
"""A tool result in UserMessage should be skipped if already resolved by flush."""
|
||||
adapter = SDKResponseAdapter()
|
||||
|
||||
adapter.convert_message(SystemMessage(subtype="init", data={}))
|
||||
|
||||
# Tool call + flush via text message
|
||||
adapter.convert_message(
|
||||
AssistantMessage(
|
||||
content=[ToolUseBlock(id="t1", name="WebSearch", input={})],
|
||||
model="test",
|
||||
)
|
||||
)
|
||||
adapter.convert_message(
|
||||
AssistantMessage(
|
||||
content=[TextBlock(text="Done")],
|
||||
model="test",
|
||||
)
|
||||
)
|
||||
assert "t1" in adapter.resolved_tool_calls
|
||||
|
||||
# Now UserMessage arrives with the real result — should be skipped
|
||||
user_msg = UserMessage(content=[ToolResultBlock(tool_use_id="t1", content="real")])
|
||||
r = adapter.convert_message(user_msg)
|
||||
output_events = [r_ for r_ in r if isinstance(r_, StreamToolOutputAvailable)]
|
||||
assert (
|
||||
len(output_events) == 0
|
||||
), "Already-resolved tool should not emit duplicate output"
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
"""SDK compatibility tests — verify the claude-agent-sdk public API surface we depend on.
|
||||
|
||||
Instead of pinning to a narrow version range, these tests verify that the
|
||||
installed SDK exposes every class, function, attribute, and method the copilot
|
||||
integration relies on. If an SDK upgrade removes or renames something these
|
||||
tests will catch it immediately.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public types & factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sdk_exports_client_and_options():
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
assert inspect.isclass(ClaudeSDKClient)
|
||||
assert inspect.isclass(ClaudeAgentOptions)
|
||||
|
||||
|
||||
def test_sdk_exports_message_types():
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
Message,
|
||||
ResultMessage,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
for cls in (AssistantMessage, ResultMessage, SystemMessage, UserMessage):
|
||||
assert inspect.isclass(cls), f"{cls.__name__} is not a class"
|
||||
# Message is a Union type alias, just verify it's importable
|
||||
assert Message is not None
|
||||
|
||||
|
||||
def test_sdk_exports_content_block_types():
|
||||
from claude_agent_sdk import TextBlock, ToolResultBlock, ToolUseBlock
|
||||
|
||||
for cls in (TextBlock, ToolResultBlock, ToolUseBlock):
|
||||
assert inspect.isclass(cls), f"{cls.__name__} is not a class"
|
||||
|
||||
|
||||
def test_sdk_exports_mcp_helpers():
|
||||
from claude_agent_sdk import create_sdk_mcp_server, tool
|
||||
|
||||
assert callable(create_sdk_mcp_server)
|
||||
assert callable(tool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClaudeSDKClient interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_client_has_required_methods():
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
|
||||
required = ["connect", "disconnect", "query", "receive_messages"]
|
||||
for name in required:
|
||||
attr = getattr(ClaudeSDKClient, name, None)
|
||||
assert attr is not None, f"ClaudeSDKClient.{name} missing"
|
||||
assert callable(attr), f"ClaudeSDKClient.{name} is not callable"
|
||||
|
||||
|
||||
def test_client_supports_async_context_manager():
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
|
||||
assert hasattr(ClaudeSDKClient, "__aenter__")
|
||||
assert hasattr(ClaudeSDKClient, "__aexit__")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClaudeAgentOptions fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agent_options_accepts_required_fields():
|
||||
"""Verify ClaudeAgentOptions accepts all kwargs our code passes."""
|
||||
from claude_agent_sdk import ClaudeAgentOptions
|
||||
|
||||
opts = ClaudeAgentOptions(
|
||||
system_prompt="test",
|
||||
cwd="/tmp",
|
||||
)
|
||||
assert opts.system_prompt == "test"
|
||||
assert opts.cwd == "/tmp"
|
||||
|
||||
|
||||
def test_agent_options_accepts_all_our_fields():
|
||||
"""Comprehensive check of every field we use in service.py."""
|
||||
from claude_agent_sdk import ClaudeAgentOptions
|
||||
|
||||
fields_we_use = [
|
||||
"system_prompt",
|
||||
"mcp_servers",
|
||||
"allowed_tools",
|
||||
"disallowed_tools",
|
||||
"hooks",
|
||||
"cwd",
|
||||
"model",
|
||||
"env",
|
||||
"resume",
|
||||
"max_buffer_size",
|
||||
]
|
||||
sig = inspect.signature(ClaudeAgentOptions)
|
||||
for field in fields_we_use:
|
||||
assert field in sig.parameters, (
|
||||
f"ClaudeAgentOptions no longer accepts '{field}' — "
|
||||
f"available params: {list(sig.parameters.keys())}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_assistant_message_has_content_and_model():
|
||||
from claude_agent_sdk import AssistantMessage, TextBlock
|
||||
|
||||
msg = AssistantMessage(content=[TextBlock(text="hi")], model="test")
|
||||
assert hasattr(msg, "content")
|
||||
assert hasattr(msg, "model")
|
||||
|
||||
|
||||
def test_result_message_has_required_attrs():
|
||||
from claude_agent_sdk import ResultMessage
|
||||
|
||||
msg = ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=100,
|
||||
duration_api_ms=50,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id="s1",
|
||||
)
|
||||
assert msg.subtype == "success"
|
||||
assert hasattr(msg, "result")
|
||||
|
||||
|
||||
def test_system_message_has_subtype_and_data():
|
||||
from claude_agent_sdk import SystemMessage
|
||||
|
||||
msg = SystemMessage(subtype="init", data={})
|
||||
assert msg.subtype == "init"
|
||||
assert msg.data == {}
|
||||
|
||||
|
||||
def test_user_message_has_parent_tool_use_id():
|
||||
from claude_agent_sdk import UserMessage
|
||||
|
||||
msg = UserMessage(content="test")
|
||||
assert hasattr(msg, "parent_tool_use_id")
|
||||
assert hasattr(msg, "tool_use_result")
|
||||
|
||||
|
||||
def test_tool_use_block_has_id_name_input():
|
||||
from claude_agent_sdk import ToolUseBlock
|
||||
|
||||
block = ToolUseBlock(id="t1", name="test", input={"key": "val"})
|
||||
assert block.id == "t1"
|
||||
assert block.name == "test"
|
||||
assert block.input == {"key": "val"}
|
||||
|
||||
|
||||
def test_tool_result_block_has_required_attrs():
|
||||
from claude_agent_sdk import ToolResultBlock
|
||||
|
||||
block = ToolResultBlock(tool_use_id="t1", content="result")
|
||||
assert block.tool_use_id == "t1"
|
||||
assert block.content == "result"
|
||||
assert hasattr(block, "is_error")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hook_event",
|
||||
["PreToolUse", "PostToolUse", "Stop"],
|
||||
)
|
||||
def test_sdk_exports_hook_event_type(hook_event: str):
|
||||
"""Verify HookEvent literal includes the events our security_hooks use."""
|
||||
from claude_agent_sdk.types import HookEvent
|
||||
|
||||
# HookEvent is a Literal type — check that our events are valid values.
|
||||
# We can't easily inspect Literal at runtime, so just verify the type exists.
|
||||
assert HookEvent is not None
|
||||
@@ -124,20 +124,20 @@ def _validate_user_isolation(
|
||||
"""Validate that tool calls respect user isolation."""
|
||||
# For workspace file tools, ensure path doesn't escape
|
||||
if "workspace" in tool_name.lower():
|
||||
# The "path" param is a cloud storage key (e.g. "/ASEAN/report.md")
|
||||
# where a leading "/" is normal. Only check for ".." traversal.
|
||||
# Filesystem paths (source_path, save_to_path) are validated inside
|
||||
# the tool itself via _validate_ephemeral_path.
|
||||
path = tool_input.get("path", "") or tool_input.get("file_path", "")
|
||||
if path and ".." in path:
|
||||
logger.warning(f"Blocked path traversal attempt: {path} by user {user_id}")
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "Path traversal not allowed",
|
||||
if path:
|
||||
# Check for path traversal
|
||||
if ".." in path or path.startswith("/"):
|
||||
logger.warning(
|
||||
f"Blocked path traversal attempt: {path} by user {user_id}"
|
||||
)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "Path traversal not allowed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
@@ -188,19 +188,8 @@ def create_security_hooks(
|
||||
|
||||
# Rate-limit Task (sub-agent) spawns per session
|
||||
if tool_name == "Task":
|
||||
# Block background task execution first — denied calls
|
||||
# should not consume a subtask slot.
|
||||
if tool_input.get("run_in_background"):
|
||||
logger.info(f"[SDK] Blocked background Task, user={user_id}")
|
||||
return cast(
|
||||
SyncHookJSONOutput,
|
||||
_deny(
|
||||
"Background task execution is not supported. "
|
||||
"Run tasks in the foreground instead "
|
||||
"(remove the run_in_background parameter)."
|
||||
),
|
||||
)
|
||||
if task_spawn_count >= max_subtasks:
|
||||
task_spawn_count += 1
|
||||
if task_spawn_count > max_subtasks:
|
||||
logger.warning(
|
||||
f"[SDK] Task limit reached ({max_subtasks}), user={user_id}"
|
||||
)
|
||||
@@ -211,7 +200,6 @@ def create_security_hooks(
|
||||
"Please continue in the main conversation."
|
||||
),
|
||||
)
|
||||
task_spawn_count += 1
|
||||
|
||||
# Strip MCP prefix for consistent validation
|
||||
is_copilot_tool = tool_name.startswith(MCP_TOOL_PREFIX)
|
||||
@@ -246,33 +234,15 @@ def create_security_hooks(
|
||||
"""
|
||||
_ = context
|
||||
tool_name = cast(str, input_data.get("tool_name", ""))
|
||||
is_builtin = not tool_name.startswith(MCP_TOOL_PREFIX)
|
||||
logger.info(
|
||||
"[SDK] PostToolUse: %s (builtin=%s, tool_use_id=%s)",
|
||||
tool_name,
|
||||
is_builtin,
|
||||
(tool_use_id or "")[:12],
|
||||
)
|
||||
logger.debug(f"[SDK] Tool success: {tool_name}, tool_use_id={tool_use_id}")
|
||||
|
||||
# Stash output for SDK built-in tools so the response adapter can
|
||||
# emit StreamToolOutputAvailable even when the CLI doesn't surface
|
||||
# a separate UserMessage with ToolResultBlock content.
|
||||
if is_builtin:
|
||||
if not tool_name.startswith(MCP_TOOL_PREFIX):
|
||||
tool_response = input_data.get("tool_response")
|
||||
if tool_response is not None:
|
||||
resp_preview = str(tool_response)[:100]
|
||||
logger.info(
|
||||
"[SDK] Stashing builtin output for %s (%d chars): %s...",
|
||||
tool_name,
|
||||
len(str(tool_response)),
|
||||
resp_preview,
|
||||
)
|
||||
stash_pending_tool_output(tool_name, tool_response)
|
||||
else:
|
||||
logger.warning(
|
||||
"[SDK] PostToolUse for builtin %s but tool_response is None",
|
||||
tool_name,
|
||||
)
|
||||
|
||||
return cast(SyncHookJSONOutput, {})
|
||||
|
||||
|
||||
@@ -7,23 +7,11 @@ tool access, and dangerous input patterns.
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from .security_hooks import _validate_tool_access, _validate_user_isolation
|
||||
from .service import _is_tool_error_or_denial
|
||||
|
||||
SDK_CWD = "/tmp/copilot-abc123"
|
||||
|
||||
|
||||
def _sdk_available() -> bool:
|
||||
try:
|
||||
import claude_agent_sdk # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_denied(result: dict) -> bool:
|
||||
hook = result.get("hookSpecificOutput", {})
|
||||
return hook.get("permissionDecision") == "deny"
|
||||
@@ -165,12 +153,11 @@ def test_workspace_path_traversal_blocked():
|
||||
assert _is_denied(result)
|
||||
|
||||
|
||||
def test_workspace_absolute_path_allowed():
|
||||
"""Workspace 'path' is a cloud storage key — leading '/' is normal."""
|
||||
def test_workspace_absolute_path_blocked():
|
||||
result = _validate_user_isolation(
|
||||
"workspace_read", {"path": "/ASEAN/report.md"}, user_id="user-1"
|
||||
"workspace_read", {"path": "/etc/passwd"}, user_id="user-1"
|
||||
)
|
||||
assert result == {}
|
||||
assert _is_denied(result)
|
||||
|
||||
|
||||
def test_workspace_normal_path_allowed():
|
||||
@@ -201,135 +188,3 @@ def test_bash_builtin_blocked_message_clarity():
|
||||
reason = _reason(_validate_tool_access("Bash", {"command": "echo hello"}))
|
||||
assert "[SECURITY]" in reason
|
||||
assert "cannot be bypassed" in reason
|
||||
|
||||
|
||||
# -- Task sub-agent hooks (require SDK) --------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _hooks():
|
||||
"""Create security hooks and return the PreToolUse handler."""
|
||||
from .security_hooks import create_security_hooks
|
||||
|
||||
hooks = create_security_hooks(user_id="u1", sdk_cwd=SDK_CWD, max_subtasks=2)
|
||||
pre = hooks["PreToolUse"][0].hooks[0]
|
||||
return pre
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _sdk_available(), reason="claude_agent_sdk not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_background_blocked(_hooks):
|
||||
"""Task with run_in_background=true must be denied."""
|
||||
result = await _hooks(
|
||||
{"tool_name": "Task", "tool_input": {"run_in_background": True, "prompt": "x"}},
|
||||
tool_use_id=None,
|
||||
context={},
|
||||
)
|
||||
assert _is_denied(result)
|
||||
assert "foreground" in _reason(result).lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _sdk_available(), reason="claude_agent_sdk not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_foreground_allowed(_hooks):
|
||||
"""Task without run_in_background should be allowed."""
|
||||
result = await _hooks(
|
||||
{"tool_name": "Task", "tool_input": {"prompt": "do stuff"}},
|
||||
tool_use_id=None,
|
||||
context={},
|
||||
)
|
||||
assert not _is_denied(result)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _sdk_available(), reason="claude_agent_sdk not installed")
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_limit_enforced(_hooks):
|
||||
"""Task spawns beyond max_subtasks should be denied."""
|
||||
# First two should pass
|
||||
for _ in range(2):
|
||||
result = await _hooks(
|
||||
{"tool_name": "Task", "tool_input": {"prompt": "ok"}},
|
||||
tool_use_id=None,
|
||||
context={},
|
||||
)
|
||||
assert not _is_denied(result)
|
||||
|
||||
# Third should be denied (limit=2)
|
||||
result = await _hooks(
|
||||
{"tool_name": "Task", "tool_input": {"prompt": "over limit"}},
|
||||
tool_use_id=None,
|
||||
context={},
|
||||
)
|
||||
assert _is_denied(result)
|
||||
assert "Maximum" in _reason(result)
|
||||
|
||||
|
||||
# -- _is_tool_error_or_denial ------------------------------------------------
|
||||
|
||||
|
||||
class TestIsToolErrorOrDenial:
|
||||
def test_none_content(self):
|
||||
assert _is_tool_error_or_denial(None) is False
|
||||
|
||||
def test_empty_content(self):
|
||||
assert _is_tool_error_or_denial("") is False
|
||||
|
||||
def test_benign_output(self):
|
||||
assert _is_tool_error_or_denial("All good, no issues.") is False
|
||||
|
||||
def test_security_marker(self):
|
||||
assert _is_tool_error_or_denial("[SECURITY] Tool access blocked") is True
|
||||
|
||||
def test_cannot_be_bypassed(self):
|
||||
assert _is_tool_error_or_denial("This restriction cannot be bypassed.") is True
|
||||
|
||||
def test_not_allowed(self):
|
||||
assert _is_tool_error_or_denial("Operation not allowed in sandbox") is True
|
||||
|
||||
def test_background_task_denial(self):
|
||||
assert (
|
||||
_is_tool_error_or_denial(
|
||||
"Background task execution is not supported. "
|
||||
"Run tasks in the foreground instead."
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_subtask_limit_denial(self):
|
||||
assert (
|
||||
_is_tool_error_or_denial(
|
||||
"Maximum 2 sub-tasks per session. Please continue in the main conversation."
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_denied_marker(self):
|
||||
assert (
|
||||
_is_tool_error_or_denial("Access denied: insufficient privileges") is True
|
||||
)
|
||||
|
||||
def test_blocked_marker(self):
|
||||
assert _is_tool_error_or_denial("Request blocked by security policy") is True
|
||||
|
||||
def test_failed_marker(self):
|
||||
assert _is_tool_error_or_denial("Failed to execute tool: timeout") is True
|
||||
|
||||
def test_mcp_iserror(self):
|
||||
assert _is_tool_error_or_denial('{"isError": true, "content": []}') is True
|
||||
|
||||
def test_benign_error_in_value(self):
|
||||
"""Content like '0 errors found' should not trigger — 'error' was removed."""
|
||||
assert _is_tool_error_or_denial("0 errors found") is False
|
||||
|
||||
def test_benign_permission_field(self):
|
||||
"""Schema descriptions mentioning 'permission' should not trigger."""
|
||||
assert (
|
||||
_is_tool_error_or_denial(
|
||||
'{"fields": [{"name": "permission_level", "type": "int"}]}'
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_benign_not_found_in_listing(self):
|
||||
"""File listing containing 'not found' in filenames should not trigger."""
|
||||
assert _is_tool_error_or_denial("readme.md\nfile-not-found-handler.py") is False
|
||||
|
||||
@@ -7,10 +7,8 @@ import os
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from backend.data.redis_client import get_redis_async
|
||||
from backend.executor.cluster_lock import AsyncClusterLock
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from .. import stream_registry
|
||||
@@ -26,8 +24,6 @@ from ..response_model import (
|
||||
StreamBaseResponse,
|
||||
StreamError,
|
||||
StreamFinish,
|
||||
StreamFinishStep,
|
||||
StreamHeartbeat,
|
||||
StreamStart,
|
||||
StreamTextDelta,
|
||||
StreamToolInputAvailable,
|
||||
@@ -49,7 +45,6 @@ from .tool_adapter import (
|
||||
LongRunningCallback,
|
||||
create_copilot_mcp_server,
|
||||
set_execution_context,
|
||||
wait_for_stash,
|
||||
)
|
||||
from .transcript import (
|
||||
cleanup_cli_project_dir,
|
||||
@@ -63,7 +58,6 @@ from .transcript import (
|
||||
logger = logging.getLogger(__name__)
|
||||
config = ChatConfig()
|
||||
|
||||
|
||||
# Set to hold background tasks to prevent garbage collection
|
||||
_background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
@@ -74,7 +68,6 @@ class CapturedTranscript:
|
||||
|
||||
path: str = ""
|
||||
sdk_session_id: str = ""
|
||||
raw_content: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -83,9 +76,6 @@ class CapturedTranscript:
|
||||
|
||||
_SDK_CWD_PREFIX = WORKSPACE_PREFIX
|
||||
|
||||
# Heartbeat interval — keep SSE alive through proxies/LBs during tool execution.
|
||||
_HEARTBEAT_INTERVAL = 15.0 # seconds
|
||||
|
||||
# Appended to the system prompt to inform the agent about available tools.
|
||||
# The SDK built-in Bash is NOT available — use mcp__copilot__bash_exec instead,
|
||||
# which has kernel-level network isolation (unshare --net).
|
||||
@@ -93,54 +83,23 @@ _SDK_TOOL_SUPPLEMENT = """
|
||||
|
||||
## Tool notes
|
||||
|
||||
### Shell commands
|
||||
- The SDK built-in Bash tool is NOT available. Use the `bash_exec` MCP tool
|
||||
for shell commands — it runs in a network-isolated sandbox.
|
||||
|
||||
### Two storage systems — CRITICAL to understand
|
||||
|
||||
1. **Ephemeral working directory** (`/tmp/copilot-<session>/`):
|
||||
- Shared by SDK Read/Write/Edit/Glob/Grep tools AND `bash_exec`
|
||||
- Files here are **lost between turns** — do NOT rely on them persisting
|
||||
- Use for temporary work: running scripts, processing data, etc.
|
||||
|
||||
2. **Persistent workspace** (cloud storage):
|
||||
- Files here **survive across turns and sessions**
|
||||
- Use `write_workspace_file` to save important files (code, outputs, configs)
|
||||
- Use `read_workspace_file` to retrieve previously saved files
|
||||
- Use `list_workspace_files` to see what files you've saved before
|
||||
- Call `list_workspace_files(include_all_sessions=True)` to see files from
|
||||
all sessions
|
||||
|
||||
### Moving files between ephemeral and persistent storage
|
||||
- **Ephemeral → Persistent**: Use `write_workspace_file` with either:
|
||||
- `content` param (plain text) — for text files
|
||||
- `source_path` param — to copy any file directly from the ephemeral dir
|
||||
- **Persistent → Ephemeral**: Use `read_workspace_file` with `save_to_path`
|
||||
param to download a workspace file to the ephemeral dir for processing
|
||||
|
||||
### File persistence workflow
|
||||
When you create or modify important files (code, configs, outputs), you MUST:
|
||||
1. Save them using `write_workspace_file` so they persist
|
||||
2. At the start of a new turn, call `list_workspace_files` to see what files
|
||||
are available from previous turns
|
||||
|
||||
### Long-running tools
|
||||
Long-running tools (create_agent, edit_agent, etc.) are handled
|
||||
asynchronously. You will receive an immediate response; the actual result
|
||||
is delivered to the user via a background stream.
|
||||
|
||||
### Sub-agent tasks
|
||||
- When using the Task tool, NEVER set `run_in_background` to true.
|
||||
All tasks must run in the foreground.
|
||||
- **Shared workspace**: The SDK Read/Write tools and `bash_exec` share the
|
||||
same working directory. Files created by one are readable by the other.
|
||||
- **IMPORTANT — File persistence**: Your working directory is **ephemeral** —
|
||||
files are lost between turns. When you create or modify important files
|
||||
(code, configs, outputs), you MUST save them using `write_workspace_file`
|
||||
so they persist. Use `read_workspace_file` and `list_workspace_files` to
|
||||
access files saved in previous turns. If a "Files from previous turns"
|
||||
section is present above, those files are available via `read_workspace_file`.
|
||||
- Long-running tools (create_agent, edit_agent, etc.) are handled
|
||||
asynchronously. You will receive an immediate response; the actual result
|
||||
is delivered to the user via a background stream.
|
||||
"""
|
||||
|
||||
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.
|
||||
|
||||
Long-running tools (create_agent, edit_agent, etc.) are delegated to the
|
||||
@@ -149,9 +108,6 @@ def _build_long_running_callback(
|
||||
page refreshes / pod restarts, and the frontend shows the proper loading
|
||||
widget with progress updates.
|
||||
|
||||
Args:
|
||||
user_id: User ID for the session
|
||||
|
||||
The returned callback matches the ``LongRunningCallback`` signature:
|
||||
``(tool_name, args, session) -> MCP response dict``.
|
||||
"""
|
||||
@@ -217,8 +173,7 @@ def _build_long_running_callback(
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
session.messages.append(pending_message)
|
||||
# Collision detection happens in add_chat_messages_batch (db.py)
|
||||
session = await upsert_chat_session(session)
|
||||
await upsert_chat_session(session)
|
||||
|
||||
# --- Spawn background task (reuses non-SDK infrastructure) ---
|
||||
bg_task = asyncio.create_task(
|
||||
@@ -357,15 +312,15 @@ async def _compress_conversation_history(
|
||||
|
||||
Returns the compressed prior messages (everything except the current message).
|
||||
"""
|
||||
messages = session.messages[:-1]
|
||||
if len(messages) < 2:
|
||||
return messages
|
||||
prior = session.messages[:-1]
|
||||
if len(prior) < 2:
|
||||
return prior
|
||||
|
||||
from backend.util.prompt import compress_context
|
||||
|
||||
# Convert ChatMessages to dicts for compress_context
|
||||
messages_dict = []
|
||||
for msg in messages:
|
||||
for msg in prior:
|
||||
msg_dict: dict[str, Any] = {"role": msg.role}
|
||||
if msg.content:
|
||||
msg_dict["content"] = msg.content
|
||||
@@ -413,15 +368,17 @@ async def _compress_conversation_history(
|
||||
for m in result.messages
|
||||
]
|
||||
|
||||
return messages
|
||||
return prior
|
||||
|
||||
|
||||
def _format_conversation_context(messages: list[ChatMessage]) -> str | None:
|
||||
"""Format conversation messages into a context prefix for the user message.
|
||||
|
||||
Includes user messages, assistant text, tool call summaries, and
|
||||
tool result summaries so the agent retains full context about what
|
||||
tools were invoked and their outcomes.
|
||||
Returns a string like:
|
||||
<conversation_history>
|
||||
User: hello
|
||||
You responded: Hi! How can I help?
|
||||
</conversation_history>
|
||||
|
||||
Returns None if there are no messages to format.
|
||||
"""
|
||||
@@ -430,21 +387,13 @@ def _format_conversation_context(messages: list[ChatMessage]) -> str | None:
|
||||
|
||||
lines: list[str] = []
|
||||
for msg in messages:
|
||||
if not msg.content:
|
||||
continue
|
||||
if msg.role == "user":
|
||||
if msg.content:
|
||||
lines.append(f"User: {msg.content}")
|
||||
lines.append(f"User: {msg.content}")
|
||||
elif msg.role == "assistant":
|
||||
if msg.content:
|
||||
lines.append(f"You responded: {msg.content}")
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
func = tc.get("function", {})
|
||||
tool_name = func.get("name", "unknown")
|
||||
tool_args = func.get("arguments", "")
|
||||
lines.append(f"You called tool: {tool_name}({tool_args})")
|
||||
elif msg.role == "tool":
|
||||
content = msg.content or ""
|
||||
lines.append(f"Tool result: {content}")
|
||||
lines.append(f"You responded: {msg.content}")
|
||||
# Skip tool messages — they're internal details
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
@@ -452,69 +401,6 @@ def _format_conversation_context(messages: list[ChatMessage]) -> str | None:
|
||||
return "<conversation_history>\n" + "\n".join(lines) + "\n</conversation_history>"
|
||||
|
||||
|
||||
def _is_tool_error_or_denial(content: str | None) -> bool:
|
||||
"""Check if a tool message content indicates an error or denial.
|
||||
|
||||
Currently unused — ``_format_conversation_context`` includes all tool
|
||||
results. Kept as a utility for future selective filtering.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
lower = content.lower()
|
||||
return any(
|
||||
marker in lower
|
||||
for marker in (
|
||||
"[security]",
|
||||
"cannot be bypassed",
|
||||
"not allowed",
|
||||
"not supported", # background-task denial
|
||||
"maximum", # subtask-limit denial
|
||||
"denied",
|
||||
"blocked",
|
||||
"failed to", # internal tool execution failures
|
||||
'"iserror": true', # MCP protocol error flag
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _build_query_message(
|
||||
current_message: str,
|
||||
session: ChatSession,
|
||||
use_resume: bool,
|
||||
transcript_msg_count: int,
|
||||
session_id: str,
|
||||
) -> str:
|
||||
"""Build the query message with appropriate context.
|
||||
|
||||
With --resume the CLI already has full context, so only the new message
|
||||
is needed. Without resume, compress history into a context prefix.
|
||||
Hybrid mode: if the transcript is stale, compress only the gap.
|
||||
"""
|
||||
msg_count = len(session.messages)
|
||||
|
||||
if use_resume and transcript_msg_count > 0:
|
||||
if transcript_msg_count < msg_count - 1:
|
||||
gap = session.messages[transcript_msg_count:-1]
|
||||
gap_context = _format_conversation_context(gap)
|
||||
if gap_context:
|
||||
logger.info(
|
||||
f"[SDK] Transcript stale: covers {transcript_msg_count} "
|
||||
f"of {msg_count} messages, compressing {len(gap)} missed"
|
||||
)
|
||||
return f"{gap_context}\n\nNow, the user says:\n{current_message}"
|
||||
elif not use_resume and msg_count > 1:
|
||||
logger.warning(
|
||||
f"[SDK] Using compression fallback for session "
|
||||
f"{session_id} ({msg_count} messages) — no transcript for --resume"
|
||||
)
|
||||
compressed = await _compress_conversation_history(session)
|
||||
history_context = _format_conversation_context(compressed)
|
||||
if history_context:
|
||||
return f"{history_context}\n\nNow, the user says:\n{current_message}"
|
||||
|
||||
return current_message
|
||||
|
||||
|
||||
async def stream_chat_completion_sdk(
|
||||
session_id: str,
|
||||
message: str | None = None,
|
||||
@@ -538,9 +424,6 @@ async def stream_chat_completion_sdk(
|
||||
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
|
||||
new_message_role = "user" if is_user_message else "assistant"
|
||||
if message and (
|
||||
@@ -578,39 +461,14 @@ async def stream_chat_completion_sdk(
|
||||
system_prompt += _SDK_TOOL_SUPPLEMENT
|
||||
message_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)
|
||||
|
||||
stream_completed = False
|
||||
# Initialise variables before the try so the finally block can
|
||||
# always attempt transcript upload regardless of errors.
|
||||
# Initialise sdk_cwd before the try so the finally can reference it
|
||||
# even if _make_sdk_cwd raises (in that case it stays as "").
|
||||
sdk_cwd = ""
|
||||
use_resume = False
|
||||
resume_file: str | None = None
|
||||
captured_transcript = CapturedTranscript()
|
||||
|
||||
try:
|
||||
# Use a session-specific temp dir to avoid cleanup race conditions
|
||||
@@ -640,23 +498,12 @@ async def stream_chat_completion_sdk(
|
||||
sdk_model = _resolve_sdk_model()
|
||||
|
||||
# --- Transcript capture via Stop hook ---
|
||||
# Read the file content immediately — the SDK may clean up
|
||||
# the file before our finally block runs.
|
||||
captured_transcript = CapturedTranscript()
|
||||
|
||||
def _on_stop(transcript_path: str, sdk_session_id: str) -> None:
|
||||
captured_transcript.path = transcript_path
|
||||
captured_transcript.sdk_session_id = sdk_session_id
|
||||
content = read_transcript_file(transcript_path)
|
||||
if content:
|
||||
captured_transcript.raw_content = content
|
||||
logger.info(
|
||||
f"[SDK] Stop hook: captured {len(content)}B from "
|
||||
f"{transcript_path}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[SDK] Stop hook: transcript file empty/missing at "
|
||||
f"{transcript_path}"
|
||||
)
|
||||
logger.debug(f"[SDK] Stop hook: path={transcript_path!r}")
|
||||
|
||||
security_hooks = create_security_hooks(
|
||||
user_id,
|
||||
@@ -666,16 +513,13 @@ async def stream_chat_completion_sdk(
|
||||
)
|
||||
|
||||
# --- Resume strategy: download transcript from bucket ---
|
||||
resume_file: str | None = None
|
||||
use_resume = False
|
||||
transcript_msg_count = 0 # watermark: session.messages length at upload
|
||||
|
||||
if config.claude_agent_use_resume and user_id and len(session.messages) > 1:
|
||||
dl = await download_transcript(user_id, session_id)
|
||||
is_valid = bool(dl and validate_transcript(dl.content))
|
||||
if dl and is_valid:
|
||||
logger.info(
|
||||
f"[SDK] Transcript available for session {session_id}: "
|
||||
f"{len(dl.content)}B, msg_count={dl.message_count}"
|
||||
)
|
||||
if dl and validate_transcript(dl.content):
|
||||
resume_file = write_transcript_to_tempfile(
|
||||
dl.content, session_id, sdk_cwd
|
||||
)
|
||||
@@ -686,15 +530,6 @@ async def stream_chat_completion_sdk(
|
||||
f"[SDK] Using --resume ({len(dl.content)}B, "
|
||||
f"msg_count={transcript_msg_count})"
|
||||
)
|
||||
elif dl:
|
||||
logger.warning(
|
||||
f"[SDK] Transcript downloaded but invalid for {session_id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[SDK] No transcript available for {session_id} "
|
||||
f"({len(session.messages)} messages in session)"
|
||||
)
|
||||
|
||||
sdk_options_kwargs: dict[str, Any] = {
|
||||
"system_prompt": system_prompt,
|
||||
@@ -713,7 +548,7 @@ async def stream_chat_completion_sdk(
|
||||
|
||||
options = ClaudeAgentOptions(**sdk_options_kwargs) # type: ignore[arg-type]
|
||||
|
||||
adapter = SDKResponseAdapter(message_id=message_id, session_id=session_id)
|
||||
adapter = SDKResponseAdapter(message_id=message_id)
|
||||
adapter.set_task_id(task_id)
|
||||
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
@@ -731,20 +566,54 @@ async def stream_chat_completion_sdk(
|
||||
yield StreamFinish()
|
||||
return
|
||||
|
||||
query_message = await _build_query_message(
|
||||
current_message,
|
||||
session,
|
||||
use_resume,
|
||||
transcript_msg_count,
|
||||
session_id,
|
||||
)
|
||||
# Build query: with --resume the CLI already has full
|
||||
# context, so we only send the new message. Without
|
||||
# resume, compress history into a context prefix.
|
||||
#
|
||||
# Hybrid mode: if the transcript is stale (upload missed
|
||||
# some turns), compress only the gap and prepend it so
|
||||
# the agent has transcript context + missed turns.
|
||||
query_message = current_message
|
||||
current_msg_count = len(session.messages)
|
||||
|
||||
if use_resume and transcript_msg_count > 0:
|
||||
# Transcript covers messages[0..M-1]. Current session
|
||||
# has N messages (last one is the new user msg).
|
||||
# Gap = messages[M .. N-2] (everything between upload
|
||||
# and the current turn).
|
||||
# When transcript_msg_count == 0 (no metadata), we trust
|
||||
# the transcript is up-to-date and skip gap detection to
|
||||
# avoid duplicating the full history.
|
||||
if transcript_msg_count < current_msg_count - 1:
|
||||
gap = session.messages[transcript_msg_count:-1]
|
||||
gap_context = _format_conversation_context(gap)
|
||||
if gap_context:
|
||||
logger.info(
|
||||
f"[SDK] Transcript stale: covers {transcript_msg_count} "
|
||||
f"of {current_msg_count} messages, compressing "
|
||||
f"{len(gap)} missed messages"
|
||||
)
|
||||
query_message = (
|
||||
f"{gap_context}\n\n"
|
||||
f"Now, the user says:\n{current_message}"
|
||||
)
|
||||
elif not use_resume and current_msg_count > 1:
|
||||
logger.warning(
|
||||
f"[SDK] Using compression fallback for session "
|
||||
f"{session_id} ({current_msg_count} messages) — "
|
||||
f"no transcript available for --resume"
|
||||
)
|
||||
compressed = await _compress_conversation_history(session)
|
||||
history_context = _format_conversation_context(compressed)
|
||||
if history_context:
|
||||
query_message = (
|
||||
f"{history_context}\n\n"
|
||||
f"Now, the user says:\n{current_message}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[SDK] [%s] Sending query — resume=%s, "
|
||||
"total_msgs=%d, query_len=%d",
|
||||
session_id[:12],
|
||||
use_resume,
|
||||
len(session.messages),
|
||||
len(query_message),
|
||||
f"[SDK] Sending query ({len(session.messages)} msgs, "
|
||||
f"resume={use_resume})"
|
||||
)
|
||||
await client.query(query_message, session_id=session_id)
|
||||
|
||||
@@ -753,288 +622,73 @@ async def stream_chat_completion_sdk(
|
||||
has_appended_assistant = False
|
||||
has_tool_results = False
|
||||
|
||||
# Use an explicit async iterator with non-cancelling heartbeats.
|
||||
# CRITICAL: we must NOT cancel __anext__() mid-flight — doing so
|
||||
# (via asyncio.timeout or wait_for) corrupts the SDK's internal
|
||||
# anyio memory stream, causing StopAsyncIteration on the next
|
||||
# call and silently dropping all in-flight tool results.
|
||||
# Instead, wrap __anext__() in a Task and use asyncio.wait()
|
||||
# with a timeout. On timeout we emit a heartbeat but keep the
|
||||
# Task alive so it can deliver the next message.
|
||||
msg_iter = client.receive_messages().__aiter__()
|
||||
pending_task: asyncio.Task[Any] | None = None
|
||||
try:
|
||||
while not stream_completed:
|
||||
if pending_task is None:
|
||||
|
||||
async def _next_msg() -> Any:
|
||||
return await msg_iter.__anext__()
|
||||
|
||||
pending_task = asyncio.create_task(_next_msg())
|
||||
|
||||
done, _ = await asyncio.wait(
|
||||
{pending_task}, timeout=_HEARTBEAT_INTERVAL
|
||||
)
|
||||
|
||||
if not done:
|
||||
# Timeout — emit heartbeat but keep the task alive
|
||||
# Also refresh lock TTL to keep it alive
|
||||
await lock.refresh()
|
||||
yield StreamHeartbeat()
|
||||
async for sdk_msg in client.receive_messages():
|
||||
logger.debug(
|
||||
f"[SDK] Received: {type(sdk_msg).__name__} "
|
||||
f"{getattr(sdk_msg, 'subtype', '')}"
|
||||
)
|
||||
for response in adapter.convert_message(sdk_msg):
|
||||
if isinstance(response, StreamStart):
|
||||
continue
|
||||
|
||||
# Task completed — get result
|
||||
pending_task = None
|
||||
try:
|
||||
sdk_msg = done.pop().result()
|
||||
except StopAsyncIteration:
|
||||
logger.info(
|
||||
"[SDK] [%s] Stream ended normally "
|
||||
"(StopAsyncIteration)",
|
||||
session_id[:12],
|
||||
)
|
||||
break
|
||||
except Exception as stream_err:
|
||||
# SDK sends {"type": "error"} which raises
|
||||
# Exception in receive_messages() — capture it
|
||||
# so the session can still be saved and the
|
||||
# frontend gets a clean finish.
|
||||
logger.error(
|
||||
"[SDK] [%s] Stream error from SDK: %s",
|
||||
session_id[:12],
|
||||
stream_err,
|
||||
exc_info=True,
|
||||
)
|
||||
yield StreamError(
|
||||
errorText=f"SDK stream error: {stream_err}",
|
||||
code="sdk_stream_error",
|
||||
)
|
||||
break
|
||||
yield response
|
||||
|
||||
logger.info(
|
||||
"[SDK] [%s] Received: %s %s "
|
||||
"(unresolved=%d, current=%d, resolved=%d)",
|
||||
session_id[:12],
|
||||
type(sdk_msg).__name__,
|
||||
getattr(sdk_msg, "subtype", ""),
|
||||
len(adapter.current_tool_calls)
|
||||
- len(adapter.resolved_tool_calls),
|
||||
len(adapter.current_tool_calls),
|
||||
len(adapter.resolved_tool_calls),
|
||||
)
|
||||
|
||||
# Race-condition fix: SDK hooks (PostToolUse) are
|
||||
# executed asynchronously via start_soon() — the next
|
||||
# message can arrive before the hook stashes output.
|
||||
# wait_for_stash() awaits an asyncio.Event signaled by
|
||||
# stash_pending_tool_output(), completing as soon as
|
||||
# the hook finishes (typically <1ms). The sleep(0)
|
||||
# after lets any remaining concurrent hooks complete.
|
||||
#
|
||||
# Skip for parallel tool continuations: when the SDK
|
||||
# sends parallel tool calls as separate
|
||||
# AssistantMessages (each containing only
|
||||
# ToolUseBlocks), we must NOT wait/flush — the prior
|
||||
# tools are still executing concurrently.
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ResultMessage,
|
||||
ToolUseBlock,
|
||||
)
|
||||
|
||||
is_parallel_continuation = isinstance(
|
||||
sdk_msg, AssistantMessage
|
||||
) and all(isinstance(b, ToolUseBlock) for b in sdk_msg.content)
|
||||
|
||||
if (
|
||||
adapter.has_unresolved_tool_calls
|
||||
and isinstance(sdk_msg, (AssistantMessage, ResultMessage))
|
||||
and not is_parallel_continuation
|
||||
):
|
||||
if await wait_for_stash(timeout=0.5):
|
||||
await asyncio.sleep(0)
|
||||
if isinstance(response, StreamTextDelta):
|
||||
delta = response.delta or ""
|
||||
# After tool results, start a new assistant
|
||||
# message for the post-tool text.
|
||||
if has_tool_results and has_appended_assistant:
|
||||
assistant_response = ChatMessage(
|
||||
role="assistant", content=delta
|
||||
)
|
||||
accumulated_tool_calls = []
|
||||
has_appended_assistant = False
|
||||
has_tool_results = False
|
||||
session.messages.append(assistant_response)
|
||||
has_appended_assistant = True
|
||||
else:
|
||||
logger.warning(
|
||||
"[SDK] [%s] Timed out waiting for "
|
||||
"PostToolUse hook stash "
|
||||
"(%d unresolved tool calls)",
|
||||
session_id[:12],
|
||||
len(adapter.current_tool_calls)
|
||||
- len(adapter.resolved_tool_calls),
|
||||
)
|
||||
|
||||
for response in adapter.convert_message(sdk_msg):
|
||||
if isinstance(response, StreamStart):
|
||||
continue
|
||||
|
||||
# Log tool events for debugging
|
||||
if isinstance(
|
||||
response,
|
||||
(
|
||||
StreamToolInputAvailable,
|
||||
StreamToolOutputAvailable,
|
||||
),
|
||||
):
|
||||
extra = ""
|
||||
if isinstance(response, StreamToolOutputAvailable):
|
||||
out_len = len(str(response.output))
|
||||
extra = f", output_len={out_len}"
|
||||
logger.info(
|
||||
"[SDK] [%s] Tool event: %s, tool=%s%s",
|
||||
session_id[:12],
|
||||
type(response).__name__,
|
||||
getattr(response, "toolName", "N/A"),
|
||||
extra,
|
||||
)
|
||||
|
||||
yield response
|
||||
|
||||
if isinstance(response, StreamTextDelta):
|
||||
delta = response.delta or ""
|
||||
# After tool results, start a new assistant
|
||||
# message for the post-tool text.
|
||||
if has_tool_results and has_appended_assistant:
|
||||
assistant_response = ChatMessage(
|
||||
role="assistant", content=delta
|
||||
)
|
||||
accumulated_tool_calls = []
|
||||
has_appended_assistant = False
|
||||
has_tool_results = False
|
||||
session.messages.append(assistant_response)
|
||||
has_appended_assistant = True
|
||||
else:
|
||||
assistant_response.content = (
|
||||
assistant_response.content or ""
|
||||
) + delta
|
||||
if not has_appended_assistant:
|
||||
session.messages.append(assistant_response)
|
||||
has_appended_assistant = True
|
||||
|
||||
elif isinstance(response, StreamToolInputAvailable):
|
||||
accumulated_tool_calls.append(
|
||||
{
|
||||
"id": response.toolCallId,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": response.toolName,
|
||||
"arguments": json.dumps(
|
||||
response.input or {}
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
assistant_response.tool_calls = accumulated_tool_calls
|
||||
assistant_response.content = (
|
||||
assistant_response.content or ""
|
||||
) + delta
|
||||
if not has_appended_assistant:
|
||||
session.messages.append(assistant_response)
|
||||
has_appended_assistant = True
|
||||
# Save before tool execution starts so the
|
||||
# pending tool call is visible on refresh /
|
||||
# other devices. Collision detection happens
|
||||
# in add_chat_messages_batch (db.py).
|
||||
try:
|
||||
session = await upsert_chat_session(session)
|
||||
except Exception as save_err:
|
||||
logger.warning(
|
||||
"[SDK] [%s] Incremental save " "failed: %s",
|
||||
session_id[:12],
|
||||
save_err,
|
||||
)
|
||||
|
||||
elif isinstance(response, StreamToolOutputAvailable):
|
||||
session.messages.append(
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
content=(
|
||||
response.output
|
||||
if isinstance(response.output, str)
|
||||
else str(response.output)
|
||||
),
|
||||
tool_call_id=response.toolCallId,
|
||||
)
|
||||
)
|
||||
has_tool_results = True
|
||||
# Save after tool completes so the result is
|
||||
# visible on refresh / other devices.
|
||||
# Collision detection happens in add_chat_messages_batch (db.py).
|
||||
try:
|
||||
session = await upsert_chat_session(session)
|
||||
except Exception as save_err:
|
||||
logger.warning(
|
||||
"[SDK] [%s] Incremental save " "failed: %s",
|
||||
session_id[:12],
|
||||
save_err,
|
||||
)
|
||||
|
||||
elif isinstance(response, StreamFinish):
|
||||
stream_completed = True
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task/generator was cancelled (e.g. client disconnect,
|
||||
# server shutdown). Log and let the safety-net / finally
|
||||
# blocks handle cleanup.
|
||||
logger.warning(
|
||||
"[SDK] [%s] Streaming loop cancelled "
|
||||
"(asyncio.CancelledError)",
|
||||
session_id[:12],
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Cancel the pending __anext__ task to avoid a leaked
|
||||
# coroutine. This is safe even if the task already
|
||||
# completed.
|
||||
if pending_task is not None and not pending_task.done():
|
||||
pending_task.cancel()
|
||||
try:
|
||||
await pending_task
|
||||
except (asyncio.CancelledError, StopAsyncIteration):
|
||||
pass
|
||||
|
||||
# Safety net: if tools are still unresolved after the
|
||||
# streaming loop (e.g. StopAsyncIteration before ResultMessage,
|
||||
# or SDK not sending UserMessages for built-in tools), flush
|
||||
# them now so the frontend stops showing spinners.
|
||||
if adapter.has_unresolved_tool_calls:
|
||||
logger.warning(
|
||||
"[SDK] [%s] %d unresolved tool(s) after stream loop — "
|
||||
"flushing as safety net",
|
||||
session_id[:12],
|
||||
len(adapter.current_tool_calls)
|
||||
- len(adapter.resolved_tool_calls),
|
||||
)
|
||||
safety_responses: list[StreamBaseResponse] = []
|
||||
adapter._flush_unresolved_tool_calls(safety_responses)
|
||||
for response in safety_responses:
|
||||
if isinstance(
|
||||
response,
|
||||
(StreamToolInputAvailable, StreamToolOutputAvailable),
|
||||
):
|
||||
logger.info(
|
||||
"[SDK] [%s] Safety flush: %s, tool=%s",
|
||||
session_id[:12],
|
||||
type(response).__name__,
|
||||
getattr(response, "toolName", "N/A"),
|
||||
elif isinstance(response, StreamToolInputAvailable):
|
||||
accumulated_tool_calls.append(
|
||||
{
|
||||
"id": response.toolCallId,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": response.toolName,
|
||||
"arguments": json.dumps(response.input or {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
yield response
|
||||
assistant_response.tool_calls = accumulated_tool_calls
|
||||
if not has_appended_assistant:
|
||||
session.messages.append(assistant_response)
|
||||
has_appended_assistant = True
|
||||
|
||||
# If the stream ended without a ResultMessage (no
|
||||
# StreamFinish), the SDK CLI exited unexpectedly. Close
|
||||
# the open step and emit StreamFinish so the frontend
|
||||
# transitions to the "ready" state.
|
||||
if not stream_completed:
|
||||
logger.warning(
|
||||
"[SDK] [%s] Stream ended without ResultMessage "
|
||||
"(StopAsyncIteration) — emitting StreamFinish",
|
||||
session_id[:12],
|
||||
)
|
||||
if adapter.step_open:
|
||||
yield StreamFinishStep()
|
||||
adapter.step_open = False
|
||||
closing_responses: list[StreamBaseResponse] = []
|
||||
adapter._end_text_if_open(closing_responses)
|
||||
for r in closing_responses:
|
||||
yield r
|
||||
yield StreamFinish()
|
||||
stream_completed = True
|
||||
elif isinstance(response, StreamToolOutputAvailable):
|
||||
session.messages.append(
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
content=(
|
||||
response.output
|
||||
if isinstance(response.output, str)
|
||||
else str(response.output)
|
||||
),
|
||||
tool_call_id=response.toolCallId,
|
||||
)
|
||||
)
|
||||
has_tool_results = True
|
||||
|
||||
elif isinstance(response, StreamFinish):
|
||||
stream_completed = True
|
||||
|
||||
if stream_completed:
|
||||
break
|
||||
|
||||
if (
|
||||
assistant_response.content or assistant_response.tool_calls
|
||||
@@ -1050,24 +704,11 @@ async def stream_chat_completion_sdk(
|
||||
# complete). Otherwise use the Stop hook path.
|
||||
if use_resume and resume_file:
|
||||
raw_transcript = read_transcript_file(resume_file)
|
||||
logger.debug("[SDK] Transcript source: resume file")
|
||||
elif captured_transcript.path:
|
||||
raw_transcript = read_transcript_file(captured_transcript.path)
|
||||
logger.debug(
|
||||
"[SDK] Transcript source: stop hook (%s), " "read result: %s",
|
||||
captured_transcript.path,
|
||||
f"{len(raw_transcript)}B" if raw_transcript else "None",
|
||||
)
|
||||
else:
|
||||
raw_transcript = None
|
||||
|
||||
if not raw_transcript:
|
||||
logger.debug(
|
||||
"[SDK] No usable transcript — CLI file had no "
|
||||
"conversation entries (expected for first turn "
|
||||
"without --resume)"
|
||||
)
|
||||
|
||||
if raw_transcript:
|
||||
# Shield the upload from generator cancellation so a
|
||||
# client disconnect / page refresh doesn't lose the
|
||||
@@ -1089,72 +730,28 @@ async def stream_chat_completion_sdk(
|
||||
"to use the OpenAI-compatible fallback."
|
||||
)
|
||||
|
||||
session = cast(ChatSession, await asyncio.shield(upsert_chat_session(session)))
|
||||
logger.info(
|
||||
"[SDK] [%s] Session saved with %d messages",
|
||||
session_id[:12],
|
||||
len(session.messages),
|
||||
await asyncio.shield(upsert_chat_session(session))
|
||||
logger.debug(
|
||||
f"[SDK] Session {session_id} saved with {len(session.messages)} messages"
|
||||
)
|
||||
if not stream_completed:
|
||||
yield StreamFinish()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Client disconnect / server shutdown — log but re-raise so
|
||||
# the framework can clean up. The finally block still runs
|
||||
# for transcript upload.
|
||||
logger.warning("[SDK] [%s] Session cancelled (CancelledError)", session_id[:12])
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[SDK] Error: {e}", exc_info=True)
|
||||
if session:
|
||||
try:
|
||||
await asyncio.shield(upsert_chat_session(session))
|
||||
except Exception as save_err:
|
||||
logger.error(f"[SDK] Failed to save session on error: {save_err}")
|
||||
try:
|
||||
await asyncio.shield(upsert_chat_session(session))
|
||||
except Exception as save_err:
|
||||
logger.error(f"[SDK] Failed to save session on error: {save_err}")
|
||||
yield StreamError(
|
||||
errorText="An error occurred. Please try again.",
|
||||
code="sdk_error",
|
||||
)
|
||||
yield StreamFinish()
|
||||
finally:
|
||||
# --- Upload transcript for next-turn --resume ---
|
||||
# This MUST run in finally so the transcript is uploaded even when
|
||||
# the streaming loop raises an exception. The CLI uses
|
||||
# appendFileSync, so whatever was written before the error/SIGTERM
|
||||
# is safely on disk and still useful for the next turn.
|
||||
if config.claude_agent_use_resume and user_id:
|
||||
try:
|
||||
# Prefer content captured in the Stop hook (read before
|
||||
# cleanup removes the file). Fall back to the resume
|
||||
# file when the stop hook didn't fire (e.g. error before
|
||||
# completion) so we don't lose the prior transcript.
|
||||
raw_transcript = captured_transcript.raw_content or None
|
||||
if not raw_transcript and use_resume and resume_file:
|
||||
raw_transcript = read_transcript_file(resume_file)
|
||||
|
||||
if raw_transcript and session is not None:
|
||||
await asyncio.shield(
|
||||
_try_upload_transcript(
|
||||
user_id,
|
||||
session_id,
|
||||
raw_transcript,
|
||||
message_count=len(session.messages),
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[SDK] No transcript to upload for {session_id}")
|
||||
except Exception as upload_err:
|
||||
logger.error(
|
||||
f"[SDK] Transcript upload failed in finally: {upload_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if 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(
|
||||
user_id: str,
|
||||
|
||||
@@ -9,7 +9,6 @@ via a callback provided by the service layer. This avoids wasteful SDK polling
|
||||
and makes results survive page refreshes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
@@ -45,14 +44,6 @@ _current_session: ContextVar[ChatSession | None] = ContextVar(
|
||||
_pending_tool_outputs: ContextVar[dict[str, list[str]]] = ContextVar(
|
||||
"pending_tool_outputs", default=None # type: ignore[arg-type]
|
||||
)
|
||||
# Event signaled whenever stash_pending_tool_output() adds a new entry.
|
||||
# Used by the streaming loop to wait for PostToolUse hooks to complete
|
||||
# instead of sleeping an arbitrary duration. The SDK fires hooks via
|
||||
# start_soon (fire-and-forget) so the next message can arrive before
|
||||
# the hook stashes its output — this event bridges that gap.
|
||||
_stash_event: ContextVar[asyncio.Event | None] = ContextVar(
|
||||
"_stash_event", default=None
|
||||
)
|
||||
|
||||
# Callback type for delegating long-running tools to the non-SDK infrastructure.
|
||||
# Args: (tool_name, arguments, session) → MCP-formatted response dict.
|
||||
@@ -85,7 +76,6 @@ def set_execution_context(
|
||||
_current_user_id.set(user_id)
|
||||
_current_session.set(session)
|
||||
_pending_tool_outputs.set({})
|
||||
_stash_event.set(asyncio.Event())
|
||||
_long_running_callback.set(long_running_callback)
|
||||
|
||||
|
||||
@@ -144,43 +134,6 @@ def stash_pending_tool_output(tool_name: str, output: Any) -> None:
|
||||
except (TypeError, ValueError):
|
||||
text = str(output)
|
||||
pending.setdefault(tool_name, []).append(text)
|
||||
# Signal any waiters that new output is available.
|
||||
event = _stash_event.get(None)
|
||||
if event is not None:
|
||||
event.set()
|
||||
|
||||
|
||||
async def wait_for_stash(timeout: float = 0.5) -> bool:
|
||||
"""Wait for a PostToolUse hook to stash tool output.
|
||||
|
||||
The SDK fires PostToolUse hooks asynchronously via ``start_soon()`` —
|
||||
the next message (AssistantMessage/ResultMessage) can arrive before the
|
||||
hook completes and stashes its output. This function bridges that gap
|
||||
by waiting on the ``_stash_event``, which is signaled by
|
||||
:func:`stash_pending_tool_output`.
|
||||
|
||||
After the event fires, callers should ``await asyncio.sleep(0)`` to
|
||||
give any remaining concurrent hooks a chance to complete.
|
||||
|
||||
Returns ``True`` if a stash signal was received, ``False`` on timeout.
|
||||
The timeout is a safety net — normally the stash happens within
|
||||
microseconds of yielding to the event loop.
|
||||
"""
|
||||
event = _stash_event.get(None)
|
||||
if event is None:
|
||||
return False
|
||||
# Fast path: hook already completed before we got here.
|
||||
if event.is_set():
|
||||
event.clear()
|
||||
return True
|
||||
# Slow path: wait for the hook to signal.
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
await event.wait()
|
||||
event.clear()
|
||||
return True
|
||||
except TimeoutError:
|
||||
return False
|
||||
|
||||
|
||||
async def _execute_tool_sync(
|
||||
|
||||
@@ -131,21 +131,18 @@ def read_transcript_file(transcript_path: str) -> str | None:
|
||||
content = f.read()
|
||||
|
||||
if not content.strip():
|
||||
logger.debug("[Transcript] File is empty: %s", transcript_path)
|
||||
return None
|
||||
|
||||
lines = content.strip().split("\n")
|
||||
|
||||
# Validate that the transcript has real conversation content
|
||||
# (not just metadata like queue-operation entries).
|
||||
if not validate_transcript(content):
|
||||
logger.debug(
|
||||
"[Transcript] No conversation content (%d lines) in %s",
|
||||
len(lines),
|
||||
transcript_path,
|
||||
)
|
||||
if len(lines) < 3:
|
||||
# Raw files with ≤2 lines are metadata-only
|
||||
# (queue-operation + file-history-snapshot, no conversation).
|
||||
return None
|
||||
|
||||
# Quick structural validation — parse first and last lines.
|
||||
json.loads(lines[0])
|
||||
json.loads(lines[-1])
|
||||
|
||||
logger.info(
|
||||
f"[Transcript] Read {len(lines)} lines, "
|
||||
f"{len(content)} bytes from {transcript_path}"
|
||||
|
||||
@@ -352,8 +352,7 @@ async def assign_user_to_session(
|
||||
if not session:
|
||||
raise NotFoundError(f"Session {session_id} not found")
|
||||
session.user_id = user_id
|
||||
session = await upsert_chat_session(session)
|
||||
return session
|
||||
return await upsert_chat_session(session)
|
||||
|
||||
|
||||
async def stream_chat_completion(
|
||||
@@ -1564,11 +1563,7 @@ async def _yield_tool_call(
|
||||
await _mark_operation_completed(tool_call_id)
|
||||
# Mark stream registry task as failed if it was created
|
||||
try:
|
||||
await stream_registry.mark_task_completed(
|
||||
task_id,
|
||||
status="failed",
|
||||
error_message=f"Failed to setup tool {tool_name}: {e}",
|
||||
)
|
||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
||||
except Exception as mark_err:
|
||||
logger.warning(f"Failed to mark task {task_id} as failed: {mark_err}")
|
||||
logger.error(
|
||||
@@ -1736,11 +1731,7 @@ async def _execute_long_running_tool_with_streaming(
|
||||
session = await get_chat_session(session_id, user_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for background tool")
|
||||
await stream_registry.mark_task_completed(
|
||||
task_id,
|
||||
status="failed",
|
||||
error_message=f"Session {session_id} not found",
|
||||
)
|
||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
||||
return
|
||||
|
||||
# Pass operation_id and task_id to the tool for async processing
|
||||
|
||||
@@ -132,23 +132,17 @@ async def test_sdk_resume_multi_turn(setup_test_user, test_user_id):
|
||||
assert not turn1_errors, f"Turn 1 errors: {turn1_errors}"
|
||||
assert turn1_text, "Turn 1 produced no text"
|
||||
|
||||
# Wait for background upload task to complete (retry up to 5s).
|
||||
# The CLI may not produce a usable transcript for very short
|
||||
# conversations (only metadata entries) — this is environment-dependent
|
||||
# (CLI version, platform). When that happens, multi-turn still works
|
||||
# via conversation compression (non-resume path), but we can't test
|
||||
# the --resume round-trip.
|
||||
# Wait for background upload task to complete (retry up to 5s)
|
||||
transcript = None
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0.5)
|
||||
transcript = await download_transcript(test_user_id, session.session_id)
|
||||
if transcript:
|
||||
break
|
||||
if not transcript:
|
||||
return pytest.skip(
|
||||
"CLI did not produce a usable transcript — "
|
||||
"cannot test --resume round-trip in this environment"
|
||||
)
|
||||
assert transcript, (
|
||||
"Transcript was not uploaded to bucket after turn 1 — "
|
||||
"Stop hook may not have fired or transcript was too small"
|
||||
)
|
||||
logger.info(f"Turn 1 transcript uploaded: {len(transcript.content)} bytes")
|
||||
|
||||
# Reload session for turn 2
|
||||
|
||||
@@ -227,14 +227,7 @@ async def publish_chunk(
|
||||
# Only log timing for significant chunks or slow operations
|
||||
if (
|
||||
chunk_type
|
||||
in (
|
||||
"StreamStart",
|
||||
"StreamFinish",
|
||||
"StreamTextStart",
|
||||
"StreamTextEnd",
|
||||
"StreamToolInputAvailable",
|
||||
"StreamToolOutputAvailable",
|
||||
)
|
||||
in ("StreamStart", "StreamFinish", "StreamTextStart", "StreamTextEnd")
|
||||
or total_time > 50
|
||||
):
|
||||
logger.info(
|
||||
@@ -644,8 +637,6 @@ async def _stream_listener(
|
||||
async def mark_task_completed(
|
||||
task_id: str,
|
||||
status: Literal["completed", "failed"] = "completed",
|
||||
*,
|
||||
error_message: str | None = None,
|
||||
) -> bool:
|
||||
"""Mark a task as completed and publish finish event.
|
||||
|
||||
@@ -656,10 +647,6 @@ async def mark_task_completed(
|
||||
Args:
|
||||
task_id: Task ID to mark as completed
|
||||
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:
|
||||
True if task was newly marked completed, False if already completed/failed
|
||||
@@ -675,17 +662,6 @@ async def mark_task_completed(
|
||||
logger.debug(f"Task {task_id} already completed/failed, skipping")
|
||||
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)
|
||||
try:
|
||||
await publish_chunk(task_id, StreamFinish())
|
||||
@@ -838,6 +814,27 @@ async def get_active_task_for_session(
|
||||
if task_user_id and user_id != task_user_id:
|
||||
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(
|
||||
f"[TASK_LOOKUP] Found running task {task_id[:8]}... for session {session_id[:8]}..."
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -246,7 +245,6 @@ async def run_sandboxed(
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=safe_env,
|
||||
start_new_session=True, # Own process group for clean kill
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -257,18 +255,7 @@ async def run_sandboxed(
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
return stdout, stderr, proc.returncode or 0, False
|
||||
except asyncio.TimeoutError:
|
||||
# Kill entire process group (bwrap + all children).
|
||||
# proc.kill() alone only kills the bwrap parent, leaving
|
||||
# children running until they finish naturally.
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass # Already exited
|
||||
except OSError as kill_err:
|
||||
logger.warning(
|
||||
"Failed to kill process group %d: %s", proc.pid, kill_err
|
||||
)
|
||||
# Always reap the subprocess regardless of killpg outcome.
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return "", f"Execution timed out after {timeout}s", -1, True
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.copilot.tools.sandbox import make_session_path
|
||||
from backend.data.db_accessors import workspace_db
|
||||
from backend.util.settings import Config
|
||||
from backend.util.virus_scanner import scan_content_safe
|
||||
@@ -20,151 +18,6 @@ from .models import ErrorResponse, ResponseType, ToolResponseBase
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_write_content(
|
||||
content_text: str | None,
|
||||
content_b64: str | None,
|
||||
source_path: str | None,
|
||||
session_id: str,
|
||||
) -> bytes | ErrorResponse:
|
||||
"""Resolve file content from exactly one of three input sources.
|
||||
|
||||
Returns the raw bytes on success, or an ``ErrorResponse`` on validation
|
||||
failure (wrong number of sources, invalid path, file not found, etc.).
|
||||
"""
|
||||
# Normalise empty strings to None so counting and dispatch stay in sync.
|
||||
if content_text is not None and content_text == "":
|
||||
content_text = None
|
||||
if content_b64 is not None and content_b64 == "":
|
||||
content_b64 = None
|
||||
if source_path is not None and source_path == "":
|
||||
source_path = None
|
||||
|
||||
sources_provided = sum(
|
||||
x is not None for x in [content_text, content_b64, source_path]
|
||||
)
|
||||
if sources_provided == 0:
|
||||
return ErrorResponse(
|
||||
message="Please provide one of: content, content_base64, or source_path",
|
||||
session_id=session_id,
|
||||
)
|
||||
if sources_provided > 1:
|
||||
return ErrorResponse(
|
||||
message="Provide only one of: content, content_base64, or source_path",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if source_path is not None:
|
||||
validated = _validate_ephemeral_path(
|
||||
source_path, param_name="source_path", session_id=session_id
|
||||
)
|
||||
if isinstance(validated, ErrorResponse):
|
||||
return validated
|
||||
try:
|
||||
with open(validated, "rb") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
return ErrorResponse(
|
||||
message=f"Source file not found: {source_path}",
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return ErrorResponse(
|
||||
message=f"Failed to read source file: {e}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if content_b64 is not None:
|
||||
try:
|
||||
return base64.b64decode(content_b64)
|
||||
except Exception:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
"Invalid base64 encoding in content_base64. "
|
||||
"Please encode the file content with standard base64, "
|
||||
"or use the 'content' parameter for plain text, "
|
||||
"or 'source_path' to copy from the working directory."
|
||||
),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert content_text is not None
|
||||
return content_text.encode("utf-8")
|
||||
|
||||
|
||||
def _validate_ephemeral_path(
|
||||
path: str, *, param_name: str, session_id: str
|
||||
) -> ErrorResponse | str:
|
||||
"""Validate that *path* is inside the session's ephemeral directory.
|
||||
|
||||
Uses the session-specific directory (``make_session_path(session_id)``)
|
||||
rather than the bare prefix, so ``/tmp/copilot-evil/...`` is rejected.
|
||||
|
||||
Returns the resolved real path on success, or an ``ErrorResponse`` when the
|
||||
path escapes the session directory.
|
||||
"""
|
||||
session_dir = os.path.realpath(make_session_path(session_id)) + os.sep
|
||||
real = os.path.realpath(path)
|
||||
if not real.startswith(session_dir):
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
f"{param_name} must be within the ephemeral working "
|
||||
f"directory ({make_session_path(session_id)})"
|
||||
),
|
||||
session_id=session_id,
|
||||
)
|
||||
return real
|
||||
|
||||
|
||||
_TEXT_MIME_PREFIXES = (
|
||||
"text/",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/x-python",
|
||||
"application/x-sh",
|
||||
)
|
||||
|
||||
_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||
|
||||
|
||||
def _is_text_mime(mime_type: str) -> bool:
|
||||
return any(mime_type.startswith(t) for t in _TEXT_MIME_PREFIXES)
|
||||
|
||||
|
||||
async def _get_manager(user_id: str, session_id: str) -> WorkspaceManager:
|
||||
"""Create a session-scoped WorkspaceManager."""
|
||||
workspace = await workspace_db().get_or_create_workspace(user_id)
|
||||
return WorkspaceManager(user_id, workspace.id, session_id)
|
||||
|
||||
|
||||
async def _resolve_file(
|
||||
manager: WorkspaceManager,
|
||||
file_id: str | None,
|
||||
path: str | None,
|
||||
session_id: str,
|
||||
) -> tuple[str, Any] | ErrorResponse:
|
||||
"""Resolve a file by file_id or path.
|
||||
|
||||
Returns ``(target_file_id, file_info)`` on success, or an
|
||||
``ErrorResponse`` if the file was not found.
|
||||
"""
|
||||
if file_id:
|
||||
file_info = await manager.get_file_info(file_id)
|
||||
if file_info is None:
|
||||
return ErrorResponse(
|
||||
message=f"File not found: {file_id}", session_id=session_id
|
||||
)
|
||||
return file_id, file_info
|
||||
|
||||
assert path is not None
|
||||
file_info = await manager.get_file_info_by_path(path)
|
||||
if file_info is None:
|
||||
return ErrorResponse(
|
||||
message=f"File not found at path: {path}", session_id=session_id
|
||||
)
|
||||
return file_info.id, file_info
|
||||
|
||||
|
||||
class WorkspaceFileInfoData(BaseModel):
|
||||
"""Data model for workspace file information (not a response itself)."""
|
||||
|
||||
@@ -215,8 +68,6 @@ class WorkspaceWriteResponse(ToolResponseBase):
|
||||
name: str
|
||||
path: str
|
||||
size_bytes: int
|
||||
source: str | None = None # "content", "base64", or "copied from <path>"
|
||||
content_preview: str | None = None # First 200 chars for text files
|
||||
|
||||
|
||||
class WorkspaceDeleteResponse(ToolResponseBase):
|
||||
@@ -285,9 +136,11 @@ class ListWorkspaceFilesTool(BaseTool):
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
session_id = session.session_id
|
||||
|
||||
if not user_id:
|
||||
return ErrorResponse(
|
||||
message="Authentication required", session_id=session_id
|
||||
message="Authentication required",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
path_prefix: Optional[str] = kwargs.get("path_prefix")
|
||||
@@ -295,13 +148,20 @@ class ListWorkspaceFilesTool(BaseTool):
|
||||
include_all_sessions: bool = kwargs.get("include_all_sessions", False)
|
||||
|
||||
try:
|
||||
manager = await _get_manager(user_id, session_id)
|
||||
workspace = await workspace_db().get_or_create_workspace(user_id)
|
||||
# Pass session_id for session-scoped file access
|
||||
manager = WorkspaceManager(user_id, workspace.id, session_id)
|
||||
|
||||
files = await manager.list_files(
|
||||
path=path_prefix, limit=limit, include_all_sessions=include_all_sessions
|
||||
path=path_prefix,
|
||||
limit=limit,
|
||||
include_all_sessions=include_all_sessions,
|
||||
)
|
||||
total = await manager.get_file_count(
|
||||
path=path_prefix, include_all_sessions=include_all_sessions
|
||||
path=path_prefix,
|
||||
include_all_sessions=include_all_sessions,
|
||||
)
|
||||
|
||||
file_infos = [
|
||||
WorkspaceFileInfoData(
|
||||
file_id=f.id,
|
||||
@@ -312,27 +172,19 @@ class ListWorkspaceFilesTool(BaseTool):
|
||||
)
|
||||
for f in files
|
||||
]
|
||||
scope = "all sessions" if include_all_sessions else "current session"
|
||||
total_size = sum(f.size_bytes for f in file_infos)
|
||||
|
||||
# Build a human-readable summary so the agent can relay details.
|
||||
lines = [f"Found {len(files)} file(s) in workspace ({scope}):"]
|
||||
for f in file_infos:
|
||||
lines.append(f" - {f.path} ({f.size_bytes:,} bytes, {f.mime_type})")
|
||||
if total > len(files):
|
||||
lines.append(f" ... and {total - len(files)} more")
|
||||
lines.append(f"Total size: {total_size:,} bytes")
|
||||
|
||||
scope_msg = "all sessions" if include_all_sessions else "current session"
|
||||
return WorkspaceFileListResponse(
|
||||
files=file_infos,
|
||||
total_count=total,
|
||||
message="\n".join(lines),
|
||||
message=f"Found {len(files)} files in workspace ({scope_msg})",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing workspace files: {e}", exc_info=True)
|
||||
return ErrorResponse(
|
||||
message=f"Failed to list workspace files: {e}",
|
||||
message=f"Failed to list workspace files: {str(e)}",
|
||||
error=str(e),
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -341,7 +193,10 @@ class ListWorkspaceFilesTool(BaseTool):
|
||||
class ReadWorkspaceFileTool(BaseTool):
|
||||
"""Tool for reading file content from workspace."""
|
||||
|
||||
# Size threshold for returning full content vs metadata+URL
|
||||
# Files larger than this return metadata with download URL to prevent context bloat
|
||||
MAX_INLINE_SIZE_BYTES = 32 * 1024 # 32KB
|
||||
# Preview size for text files
|
||||
PREVIEW_SIZE = 500
|
||||
|
||||
@property
|
||||
@@ -357,8 +212,6 @@ class ReadWorkspaceFileTool(BaseTool):
|
||||
"Specify either file_id or path to identify the file. "
|
||||
"For small text files, returns content directly. "
|
||||
"For large or binary files, returns metadata and a download URL. "
|
||||
"Optionally use 'save_to_path' to copy the file to the ephemeral "
|
||||
"working directory for processing with bash_exec or SDK tools. "
|
||||
"Paths are scoped to the current session by default. "
|
||||
"Use /sessions/<session_id>/... for cross-session access."
|
||||
)
|
||||
@@ -379,15 +232,6 @@ class ReadWorkspaceFileTool(BaseTool):
|
||||
"Scoped to current session by default."
|
||||
),
|
||||
},
|
||||
"save_to_path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"If provided, save the file to this path in the ephemeral "
|
||||
"working directory (e.g., '/tmp/copilot-.../data.csv') "
|
||||
"so it can be processed with bash_exec or SDK tools. "
|
||||
"The file content is still returned in the response."
|
||||
),
|
||||
},
|
||||
"force_download_url": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
@@ -403,6 +247,18 @@ class ReadWorkspaceFileTool(BaseTool):
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
def _is_text_mime_type(self, mime_type: str) -> bool:
|
||||
"""Check if the MIME type is a text-based type."""
|
||||
text_types = [
|
||||
"text/",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/x-python",
|
||||
"application/x-sh",
|
||||
]
|
||||
return any(mime_type.startswith(t) for t in text_types)
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
@@ -410,112 +266,117 @@ class ReadWorkspaceFileTool(BaseTool):
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
session_id = session.session_id
|
||||
|
||||
if not user_id:
|
||||
return ErrorResponse(
|
||||
message="Authentication required", session_id=session_id
|
||||
message="Authentication required",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
file_id: Optional[str] = kwargs.get("file_id")
|
||||
path: Optional[str] = kwargs.get("path")
|
||||
save_to_path: Optional[str] = kwargs.get("save_to_path")
|
||||
force_download_url: bool = kwargs.get("force_download_url", False)
|
||||
|
||||
if not file_id and not path:
|
||||
return ErrorResponse(
|
||||
message="Please provide either file_id or path", session_id=session_id
|
||||
message="Please provide either file_id or path",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Validate and resolve save_to_path (use sanitized real path).
|
||||
if save_to_path:
|
||||
validated_save = _validate_ephemeral_path(
|
||||
save_to_path, param_name="save_to_path", session_id=session_id
|
||||
)
|
||||
if isinstance(validated_save, ErrorResponse):
|
||||
return validated_save
|
||||
save_to_path = validated_save
|
||||
|
||||
try:
|
||||
manager = await _get_manager(user_id, session_id)
|
||||
resolved = await _resolve_file(manager, file_id, path, session_id)
|
||||
if isinstance(resolved, ErrorResponse):
|
||||
return resolved
|
||||
target_file_id, file_info = resolved
|
||||
workspace = await workspace_db().get_or_create_workspace(user_id)
|
||||
# Pass session_id for session-scoped file access
|
||||
manager = WorkspaceManager(user_id, workspace.id, session_id)
|
||||
|
||||
# If save_to_path, read + save; cache bytes for possible inline reuse.
|
||||
cached_content: bytes | None = None
|
||||
if save_to_path:
|
||||
cached_content = await manager.read_file_by_id(target_file_id)
|
||||
dir_path = os.path.dirname(save_to_path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with open(save_to_path, "wb") as f:
|
||||
f.write(cached_content)
|
||||
# Get file info
|
||||
if file_id:
|
||||
file_info = await manager.get_file_info(file_id)
|
||||
if file_info is None:
|
||||
return ErrorResponse(
|
||||
message=f"File not found: {file_id}",
|
||||
session_id=session_id,
|
||||
)
|
||||
target_file_id = file_id
|
||||
else:
|
||||
# path is guaranteed to be non-None here due to the check above
|
||||
assert path is not None
|
||||
file_info = await manager.get_file_info_by_path(path)
|
||||
if file_info is None:
|
||||
return ErrorResponse(
|
||||
message=f"File not found at path: {path}",
|
||||
session_id=session_id,
|
||||
)
|
||||
target_file_id = file_info.id
|
||||
|
||||
is_small = file_info.size_bytes <= self.MAX_INLINE_SIZE_BYTES
|
||||
is_text = _is_text_mime(file_info.mime_type)
|
||||
is_image = file_info.mime_type in _IMAGE_MIME_TYPES
|
||||
# Decide whether to return inline content or metadata+URL
|
||||
is_small_file = file_info.size_bytes <= self.MAX_INLINE_SIZE_BYTES
|
||||
is_text_file = self._is_text_mime_type(file_info.mime_type)
|
||||
|
||||
# Return inline content for small text/image files (unless force_download_url)
|
||||
is_image_file = file_info.mime_type in {
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
}
|
||||
if (
|
||||
is_small_file
|
||||
and (is_text_file or is_image_file)
|
||||
and not force_download_url
|
||||
):
|
||||
content = await manager.read_file_by_id(target_file_id)
|
||||
content_b64 = base64.b64encode(content).decode("utf-8")
|
||||
|
||||
# Inline content for small text/image files
|
||||
if is_small and (is_text or is_image) and not force_download_url:
|
||||
content = cached_content or await manager.read_file_by_id(
|
||||
target_file_id
|
||||
)
|
||||
msg = (
|
||||
f"Read {file_info.name} from workspace:{file_info.path} "
|
||||
f"({file_info.size_bytes:,} bytes, {file_info.mime_type})"
|
||||
)
|
||||
if save_to_path:
|
||||
msg += f" — also saved to {save_to_path}"
|
||||
return WorkspaceFileContentResponse(
|
||||
file_id=file_info.id,
|
||||
name=file_info.name,
|
||||
path=file_info.path,
|
||||
mime_type=file_info.mime_type,
|
||||
content_base64=base64.b64encode(content).decode("utf-8"),
|
||||
message=msg,
|
||||
content_base64=content_b64,
|
||||
message=f"Successfully read file: {file_info.name}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Metadata + download URL for large/binary files
|
||||
preview: str | None = None
|
||||
if is_text:
|
||||
try:
|
||||
raw = cached_content or await manager.read_file_by_id(
|
||||
target_file_id
|
||||
)
|
||||
preview = raw[: self.PREVIEW_SIZE].decode("utf-8", errors="replace")
|
||||
if len(raw) > self.PREVIEW_SIZE:
|
||||
preview += "..."
|
||||
except Exception:
|
||||
pass
|
||||
# Return metadata + workspace:// reference for large or binary files
|
||||
# This prevents context bloat (100KB file = ~133KB as base64)
|
||||
# Use workspace:// format so frontend urlTransform can add proxy prefix
|
||||
download_url = f"workspace://{target_file_id}"
|
||||
|
||||
# Generate preview for text files
|
||||
preview: str | None = None
|
||||
if is_text_file:
|
||||
try:
|
||||
content = await manager.read_file_by_id(target_file_id)
|
||||
preview_text = content[: self.PREVIEW_SIZE].decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
if len(content) > self.PREVIEW_SIZE:
|
||||
preview_text += "..."
|
||||
preview = preview_text
|
||||
except Exception:
|
||||
pass # Preview is optional
|
||||
|
||||
msg = (
|
||||
f"File: {file_info.name} at workspace:{file_info.path} "
|
||||
f"({file_info.size_bytes:,} bytes, {file_info.mime_type})"
|
||||
)
|
||||
if save_to_path:
|
||||
msg += f" — saved to {save_to_path}"
|
||||
else:
|
||||
msg += (
|
||||
" — use read_workspace_file with this file_id to retrieve content"
|
||||
)
|
||||
return WorkspaceFileMetadataResponse(
|
||||
file_id=file_info.id,
|
||||
name=file_info.name,
|
||||
path=file_info.path,
|
||||
mime_type=file_info.mime_type,
|
||||
size_bytes=file_info.size_bytes,
|
||||
download_url=f"workspace://{target_file_id}",
|
||||
download_url=download_url,
|
||||
preview=preview,
|
||||
message=msg,
|
||||
message=f"File: {file_info.name} ({file_info.size_bytes} bytes). Use download_url to retrieve content.",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return ErrorResponse(message=str(e), session_id=session_id)
|
||||
return ErrorResponse(
|
||||
message=str(e),
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading workspace file: {e}", exc_info=True)
|
||||
return ErrorResponse(
|
||||
message=f"Failed to read workspace file: {e}",
|
||||
message=f"Failed to read workspace file: {str(e)}",
|
||||
error=str(e),
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -534,9 +395,7 @@ class WriteWorkspaceFileTool(BaseTool):
|
||||
"Write or create a file in the user's persistent workspace (cloud storage). "
|
||||
"These files survive across sessions. "
|
||||
"For ephemeral session files, use the SDK Write tool instead. "
|
||||
"Provide content as plain text via 'content', OR base64-encoded via "
|
||||
"'content_base64', OR copy a file from the ephemeral working directory "
|
||||
"via 'source_path'. Exactly one of these three is required. "
|
||||
"Provide the content as a base64-encoded string. "
|
||||
f"Maximum file size is {Config().max_file_size_mb}MB. "
|
||||
"Files are saved to the current session's folder by default. "
|
||||
"Use /sessions/<session_id>/... for cross-session access."
|
||||
@@ -551,30 +410,9 @@ class WriteWorkspaceFileTool(BaseTool):
|
||||
"type": "string",
|
||||
"description": "Name for the file (e.g., 'report.pdf')",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plain text content to write. Use this for text files "
|
||||
"(code, configs, documents, etc.). "
|
||||
"Mutually exclusive with content_base64 and source_path."
|
||||
),
|
||||
},
|
||||
"content_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded file content. Use this for binary files "
|
||||
"(images, PDFs, etc.). "
|
||||
"Mutually exclusive with content and source_path."
|
||||
),
|
||||
},
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Path to a file in the ephemeral working directory to "
|
||||
"copy to workspace (e.g., '/tmp/copilot-.../output.csv'). "
|
||||
"Use this to persist files created by bash_exec or SDK Write. "
|
||||
"Mutually exclusive with content and content_base64."
|
||||
),
|
||||
"description": "Base64-encoded file content",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
@@ -596,7 +434,7 @@ class WriteWorkspaceFileTool(BaseTool):
|
||||
"description": "Whether to overwrite if file exists at path (default: false)",
|
||||
},
|
||||
},
|
||||
"required": ["filename"],
|
||||
"required": ["filename", "content_base64"],
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -610,92 +448,82 @@ class WriteWorkspaceFileTool(BaseTool):
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
session_id = session.session_id
|
||||
|
||||
if not user_id:
|
||||
return ErrorResponse(
|
||||
message="Authentication required", session_id=session_id
|
||||
message="Authentication required",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
filename: str = kwargs.get("filename", "")
|
||||
content_b64: str = kwargs.get("content_base64", "")
|
||||
path: Optional[str] = kwargs.get("path")
|
||||
mime_type: Optional[str] = kwargs.get("mime_type")
|
||||
overwrite: bool = kwargs.get("overwrite", False)
|
||||
|
||||
if not filename:
|
||||
return ErrorResponse(
|
||||
message="Please provide a filename", session_id=session_id
|
||||
message="Please provide a filename",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
source_path_arg: str | None = kwargs.get("source_path")
|
||||
content_text: str | None = kwargs.get("content")
|
||||
content_b64: str | None = kwargs.get("content_base64")
|
||||
if not content_b64:
|
||||
return ErrorResponse(
|
||||
message="Please provide content_base64",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
resolved = _resolve_write_content(
|
||||
content_text,
|
||||
content_b64,
|
||||
source_path_arg,
|
||||
session_id,
|
||||
)
|
||||
if isinstance(resolved, ErrorResponse):
|
||||
return resolved
|
||||
content: bytes = resolved
|
||||
# Decode content
|
||||
try:
|
||||
content = base64.b64decode(content_b64)
|
||||
except Exception:
|
||||
return ErrorResponse(
|
||||
message="Invalid base64-encoded content",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
max_size = Config().max_file_size_mb * 1024 * 1024
|
||||
if len(content) > max_size:
|
||||
# Check size
|
||||
max_file_size = Config().max_file_size_mb * 1024 * 1024
|
||||
if len(content) > max_file_size:
|
||||
return ErrorResponse(
|
||||
message=f"File too large. Maximum size is {Config().max_file_size_mb}MB",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# Virus scan
|
||||
await scan_content_safe(content, filename=filename)
|
||||
manager = await _get_manager(user_id, session_id)
|
||||
rec = await manager.write_file(
|
||||
|
||||
workspace = await workspace_db().get_or_create_workspace(user_id)
|
||||
# Pass session_id for session-scoped file access
|
||||
manager = WorkspaceManager(user_id, workspace.id, session_id)
|
||||
|
||||
file_record = await manager.write_file(
|
||||
content=content,
|
||||
filename=filename,
|
||||
path=kwargs.get("path"),
|
||||
mime_type=kwargs.get("mime_type"),
|
||||
overwrite=kwargs.get("overwrite", False),
|
||||
path=path,
|
||||
mime_type=mime_type,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
# Build informative source label and message.
|
||||
if source_path_arg:
|
||||
source = f"copied from {source_path_arg}"
|
||||
msg = (
|
||||
f"Copied {source_path_arg} → workspace:{rec.path} "
|
||||
f"({rec.size_bytes:,} bytes)"
|
||||
)
|
||||
elif content_b64:
|
||||
source = "base64"
|
||||
msg = (
|
||||
f"Wrote {rec.name} to workspace ({rec.size_bytes:,} bytes, "
|
||||
f"decoded from base64)"
|
||||
)
|
||||
else:
|
||||
source = "content"
|
||||
msg = f"Wrote {rec.name} to workspace ({rec.size_bytes:,} bytes)"
|
||||
|
||||
# Include a short preview for text content.
|
||||
preview: str | None = None
|
||||
if _is_text_mime(rec.mime_type):
|
||||
try:
|
||||
preview = content[:200].decode("utf-8", errors="replace")
|
||||
if len(content) > 200:
|
||||
preview += "..."
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return WorkspaceWriteResponse(
|
||||
file_id=rec.id,
|
||||
name=rec.name,
|
||||
path=rec.path,
|
||||
size_bytes=rec.size_bytes,
|
||||
source=source,
|
||||
content_preview=preview,
|
||||
message=msg,
|
||||
file_id=file_record.id,
|
||||
name=file_record.name,
|
||||
path=file_record.path,
|
||||
size_bytes=file_record.size_bytes,
|
||||
message=f"Successfully wrote file: {file_record.name}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return ErrorResponse(message=str(e), session_id=session_id)
|
||||
return ErrorResponse(
|
||||
message=str(e),
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing workspace file: {e}", exc_info=True)
|
||||
return ErrorResponse(
|
||||
message=f"Failed to write workspace file: {e}",
|
||||
message=f"Failed to write workspace file: {str(e)}",
|
||||
error=str(e),
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -748,42 +576,61 @@ class DeleteWorkspaceFileTool(BaseTool):
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
session_id = session.session_id
|
||||
|
||||
if not user_id:
|
||||
return ErrorResponse(
|
||||
message="Authentication required", session_id=session_id
|
||||
message="Authentication required",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
file_id: Optional[str] = kwargs.get("file_id")
|
||||
path: Optional[str] = kwargs.get("path")
|
||||
|
||||
if not file_id and not path:
|
||||
return ErrorResponse(
|
||||
message="Please provide either file_id or path", session_id=session_id
|
||||
message="Please provide either file_id or path",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
manager = await _get_manager(user_id, session_id)
|
||||
resolved = await _resolve_file(manager, file_id, path, session_id)
|
||||
if isinstance(resolved, ErrorResponse):
|
||||
return resolved
|
||||
target_file_id, file_info = resolved
|
||||
workspace = await workspace_db().get_or_create_workspace(user_id)
|
||||
# Pass session_id for session-scoped file access
|
||||
manager = WorkspaceManager(user_id, workspace.id, session_id)
|
||||
|
||||
if not await manager.delete_file(target_file_id):
|
||||
# Determine the file_id to delete
|
||||
target_file_id: str
|
||||
if file_id:
|
||||
target_file_id = file_id
|
||||
else:
|
||||
# path is guaranteed to be non-None here due to the check above
|
||||
assert path is not None
|
||||
file_info = await manager.get_file_info_by_path(path)
|
||||
if file_info is None:
|
||||
return ErrorResponse(
|
||||
message=f"File not found at path: {path}",
|
||||
session_id=session_id,
|
||||
)
|
||||
target_file_id = file_info.id
|
||||
|
||||
success = await manager.delete_file(target_file_id)
|
||||
|
||||
if not success:
|
||||
return ErrorResponse(
|
||||
message=f"File not found: {target_file_id}", session_id=session_id
|
||||
message=f"File not found: {target_file_id}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return WorkspaceDeleteResponse(
|
||||
file_id=target_file_id,
|
||||
success=True,
|
||||
message=(
|
||||
f"Deleted {file_info.name} from workspace:{file_info.path} "
|
||||
f"({file_info.size_bytes:,} bytes)"
|
||||
),
|
||||
message="File deleted successfully",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting workspace file: {e}", exc_info=True)
|
||||
return ErrorResponse(
|
||||
message=f"Failed to delete workspace file: {e}",
|
||||
message=f"Failed to delete workspace file: {str(e)}",
|
||||
error=str(e),
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Tests for workspace file tool helpers and path validation."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.copilot.tools._test_data import make_session, setup_test_data
|
||||
from backend.copilot.tools.workspace_files import (
|
||||
DeleteWorkspaceFileTool,
|
||||
ListWorkspaceFilesTool,
|
||||
ReadWorkspaceFileTool,
|
||||
WorkspaceDeleteResponse,
|
||||
WorkspaceFileListResponse,
|
||||
WorkspaceWriteResponse,
|
||||
WriteWorkspaceFileTool,
|
||||
_resolve_write_content,
|
||||
_validate_ephemeral_path,
|
||||
)
|
||||
|
||||
# Re-export so pytest discovers the session-scoped fixture
|
||||
setup_test_data = setup_test_data
|
||||
|
||||
# We need to mock make_session_path to return a known temp dir for tests.
|
||||
# The real one uses WORKSPACE_PREFIX = "/tmp/copilot-"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ephemeral_dir(tmp_path, monkeypatch):
|
||||
"""Create a temp dir that acts as the ephemeral session directory."""
|
||||
session_dir = tmp_path / "copilot-test-session"
|
||||
session_dir.mkdir()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.copilot.tools.workspace_files.make_session_path",
|
||||
lambda session_id: str(session_dir),
|
||||
)
|
||||
return session_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_ephemeral_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateEphemeralPath:
|
||||
def test_valid_path(self, ephemeral_dir):
|
||||
target = ephemeral_dir / "file.txt"
|
||||
target.touch()
|
||||
result = _validate_ephemeral_path(
|
||||
str(target), param_name="test", session_id="s1"
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
assert result == os.path.realpath(str(target))
|
||||
|
||||
def test_path_traversal_rejected(self, ephemeral_dir):
|
||||
evil_path = str(ephemeral_dir / ".." / "etc" / "passwd")
|
||||
result = _validate_ephemeral_path(evil_path, param_name="test", session_id="s1")
|
||||
# Should return ErrorResponse
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_different_session_rejected(self, ephemeral_dir, tmp_path):
|
||||
other_dir = tmp_path / "copilot-evil-session"
|
||||
other_dir.mkdir()
|
||||
target = other_dir / "steal.txt"
|
||||
target.touch()
|
||||
result = _validate_ephemeral_path(
|
||||
str(target), param_name="test", session_id="s1"
|
||||
)
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_symlink_escape_rejected(self, ephemeral_dir, tmp_path):
|
||||
"""Symlink inside session dir pointing outside should be rejected."""
|
||||
outside_file = tmp_path / "secret.txt"
|
||||
outside_file.write_text("secret")
|
||||
symlink = ephemeral_dir / "link.txt"
|
||||
symlink.symlink_to(outside_file)
|
||||
result = _validate_ephemeral_path(
|
||||
str(symlink), param_name="test", session_id="s1"
|
||||
)
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_nested_path_valid(self, ephemeral_dir):
|
||||
nested = ephemeral_dir / "subdir" / "deep"
|
||||
nested.mkdir(parents=True)
|
||||
target = nested / "data.csv"
|
||||
target.touch()
|
||||
result = _validate_ephemeral_path(
|
||||
str(target), param_name="test", session_id="s1"
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_write_content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveWriteContent:
|
||||
def test_no_sources_returns_error(self):
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
result = _resolve_write_content(None, None, None, "s1")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_multiple_sources_returns_error(self):
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
result = _resolve_write_content("text", "b64data", None, "s1")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_plain_text_content(self):
|
||||
result = _resolve_write_content("hello world", None, None, "s1")
|
||||
assert result == b"hello world"
|
||||
|
||||
def test_base64_content(self):
|
||||
raw = b"binary data"
|
||||
b64 = base64.b64encode(raw).decode()
|
||||
result = _resolve_write_content(None, b64, None, "s1")
|
||||
assert result == raw
|
||||
|
||||
def test_invalid_base64_returns_error(self):
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
result = _resolve_write_content(None, "not-valid-b64!!!", None, "s1")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
assert "base64" in result.message.lower()
|
||||
|
||||
def test_source_path(self, ephemeral_dir):
|
||||
target = ephemeral_dir / "input.txt"
|
||||
target.write_bytes(b"file content")
|
||||
result = _resolve_write_content(None, None, str(target), "s1")
|
||||
assert result == b"file content"
|
||||
|
||||
def test_source_path_not_found(self, ephemeral_dir):
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
missing = str(ephemeral_dir / "nope.txt")
|
||||
result = _resolve_write_content(None, None, missing, "s1")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_source_path_outside_ephemeral(self, ephemeral_dir, tmp_path):
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("nope")
|
||||
result = _resolve_write_content(None, None, str(outside), "s1")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_empty_string_sources_treated_as_none(self):
|
||||
from backend.copilot.tools.models import ErrorResponse
|
||||
|
||||
# All empty strings → same as no sources
|
||||
result = _resolve_write_content("", "", "", "s1")
|
||||
assert isinstance(result, ErrorResponse)
|
||||
|
||||
def test_empty_string_source_path_with_text(self):
|
||||
# source_path="" should be normalised to None, so only content counts
|
||||
result = _resolve_write_content("hello", "", "", "s1")
|
||||
assert result == b"hello"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2E: workspace file tool round-trip (write → list → read → delete)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_workspace_file_round_trip(setup_test_data):
|
||||
"""E2E: write a file, list it, read it back (with save_to_path), then delete it."""
|
||||
user = setup_test_data["user"]
|
||||
session = make_session(user.id)
|
||||
session_id = session.session_id
|
||||
|
||||
# ---- Write ----
|
||||
write_tool = WriteWorkspaceFileTool()
|
||||
write_resp = await write_tool._execute(
|
||||
user_id=user.id,
|
||||
session=session,
|
||||
filename="test_round_trip.txt",
|
||||
content="Hello from e2e test!",
|
||||
)
|
||||
assert isinstance(write_resp, WorkspaceWriteResponse), write_resp.message
|
||||
file_id = write_resp.file_id
|
||||
|
||||
# ---- List ----
|
||||
list_tool = ListWorkspaceFilesTool()
|
||||
list_resp = await list_tool._execute(user_id=user.id, session=session)
|
||||
assert isinstance(list_resp, WorkspaceFileListResponse), list_resp.message
|
||||
assert any(f.file_id == file_id for f in list_resp.files)
|
||||
|
||||
# ---- Read (inline) ----
|
||||
read_tool = ReadWorkspaceFileTool()
|
||||
read_resp = await read_tool._execute(
|
||||
user_id=user.id, session=session, file_id=file_id
|
||||
)
|
||||
from backend.copilot.tools.workspace_files import WorkspaceFileContentResponse
|
||||
|
||||
assert isinstance(read_resp, WorkspaceFileContentResponse), read_resp.message
|
||||
decoded = base64.b64decode(read_resp.content_base64).decode()
|
||||
assert decoded == "Hello from e2e test!"
|
||||
|
||||
# ---- Read with save_to_path ----
|
||||
from backend.copilot.tools.sandbox import make_session_path
|
||||
|
||||
ephemeral_dir = make_session_path(session_id)
|
||||
os.makedirs(ephemeral_dir, exist_ok=True)
|
||||
save_path = os.path.join(ephemeral_dir, "saved_copy.txt")
|
||||
|
||||
read_resp2 = await read_tool._execute(
|
||||
user_id=user.id, session=session, file_id=file_id, save_to_path=save_path
|
||||
)
|
||||
assert not isinstance(read_resp2, type(None))
|
||||
assert os.path.exists(save_path)
|
||||
with open(save_path) as f:
|
||||
assert f.read() == "Hello from e2e test!"
|
||||
|
||||
# ---- Delete ----
|
||||
delete_tool = DeleteWorkspaceFileTool()
|
||||
del_resp = await delete_tool._execute(
|
||||
user_id=user.id, session=session, file_id=file_id
|
||||
)
|
||||
assert isinstance(del_resp, WorkspaceDeleteResponse), del_resp.message
|
||||
assert del_resp.success is True
|
||||
|
||||
# Verify file is gone
|
||||
list_resp2 = await list_tool._execute(user_id=user.id, session=session)
|
||||
assert isinstance(list_resp2, WorkspaceFileListResponse)
|
||||
assert not any(f.file_id == file_id for f in list_resp2.files)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_write_workspace_file_source_path(setup_test_data):
|
||||
"""E2E: write a file from ephemeral source_path to workspace."""
|
||||
user = setup_test_data["user"]
|
||||
session = make_session(user.id)
|
||||
session_id = session.session_id
|
||||
|
||||
# Create a file in the ephemeral dir
|
||||
from backend.copilot.tools.sandbox import make_session_path
|
||||
|
||||
ephemeral_dir = make_session_path(session_id)
|
||||
os.makedirs(ephemeral_dir, exist_ok=True)
|
||||
source = os.path.join(ephemeral_dir, "generated_output.csv")
|
||||
with open(source, "w") as f:
|
||||
f.write("col1,col2\n1,2\n")
|
||||
|
||||
write_tool = WriteWorkspaceFileTool()
|
||||
write_resp = await write_tool._execute(
|
||||
user_id=user.id,
|
||||
session=session,
|
||||
filename="output.csv",
|
||||
source_path=source,
|
||||
)
|
||||
assert isinstance(write_resp, WorkspaceWriteResponse), write_resp.message
|
||||
|
||||
# Clean up
|
||||
delete_tool = DeleteWorkspaceFileTool()
|
||||
await delete_tool._execute(
|
||||
user_id=user.id, session=session, file_id=write_resp.file_id
|
||||
)
|
||||
@@ -303,7 +303,7 @@ class DatabaseManager(AppService):
|
||||
get_user_chat_sessions = _(chat_db.get_user_chat_sessions)
|
||||
get_user_session_count = _(chat_db.get_user_session_count)
|
||||
delete_chat_session = _(chat_db.delete_chat_session)
|
||||
get_next_sequence = _(chat_db.get_next_sequence)
|
||||
get_chat_session_message_count = _(chat_db.get_chat_session_message_count)
|
||||
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_session_count = d.get_user_session_count
|
||||
delete_chat_session = d.delete_chat_session
|
||||
get_next_sequence = d.get_next_sequence
|
||||
get_chat_session_message_count = d.get_chat_session_message_count
|
||||
update_tool_message_content = d.update_tool_message_content
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Redis-based distributed locking for cluster coordination."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
@@ -8,7 +7,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from redis import Redis
|
||||
from redis.asyncio import Redis as AsyncRedis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,124 +126,3 @@ class ClusterLock:
|
||||
|
||||
with self._refresh_lock:
|
||||
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
|
||||
|
||||
@@ -535,18 +535,14 @@ async def _summarize_messages_llm(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Create a factual summary of the conversation so far. "
|
||||
"Create a detailed summary of the conversation so far. "
|
||||
"This summary will be used as context when continuing the conversation.\n\n"
|
||||
"CRITICAL: Only include information that is EXPLICITLY present in the "
|
||||
"conversation. Do NOT fabricate, infer, or invent any details. "
|
||||
"If a section has no relevant content in the conversation, skip it entirely.\n\n"
|
||||
"Before writing the summary, analyze each message chronologically to identify:\n"
|
||||
"- User requests and their explicit goals\n"
|
||||
"- Actions taken and key decisions made\n"
|
||||
"- Your approach and key decisions made\n"
|
||||
"- Technical specifics (file names, tool outputs, function signatures)\n"
|
||||
"- Errors encountered and resolutions applied\n\n"
|
||||
"Include ONLY the sections below that have relevant content "
|
||||
"(skip sections with nothing to report):\n\n"
|
||||
"You MUST include ALL of the following sections:\n\n"
|
||||
"## 1. Primary Request and Intent\n"
|
||||
"The user's explicit goals and what they are trying to accomplish.\n\n"
|
||||
"## 2. Key Technical Concepts\n"
|
||||
@@ -554,14 +550,19 @@ async def _summarize_messages_llm(
|
||||
"## 3. Files and Resources Involved\n"
|
||||
"Specific files examined or modified, with relevant snippets and identifiers.\n\n"
|
||||
"## 4. Errors and Fixes\n"
|
||||
"Problems encountered, error messages, and their resolutions.\n\n"
|
||||
"## 5. All User Messages\n"
|
||||
"A complete list of all user inputs (excluding tool outputs) "
|
||||
"to preserve their exact requests.\n\n"
|
||||
"## 6. Pending Tasks\n"
|
||||
"Problems encountered, error messages, and their resolutions. "
|
||||
"Include any user feedback on fixes.\n\n"
|
||||
"## 5. Problem Solving\n"
|
||||
"Issues that have been resolved and how they were addressed.\n\n"
|
||||
"## 6. All User Messages\n"
|
||||
"A complete list of all user inputs (excluding tool outputs) to preserve their exact requests.\n\n"
|
||||
"## 7. Pending Tasks\n"
|
||||
"Work items the user explicitly requested that have not yet been completed.\n\n"
|
||||
"## 7. Current State\n"
|
||||
"What was happening most recently in the conversation."
|
||||
"## 8. Current Work\n"
|
||||
"Precise description of what was being worked on most recently, including relevant context.\n\n"
|
||||
"## 9. Next Steps\n"
|
||||
"What should happen next, aligned with the user's most recent requests. "
|
||||
"Include verbatim quotes of recent instructions if relevant."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": f"Summarize:\n\n{conversation_text}"},
|
||||
|
||||
14
autogpt_platform/backend/poetry.lock
generated
14
autogpt_platform/backend/poetry.lock
generated
@@ -899,17 +899,17 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "claude-agent-sdk"
|
||||
version = "0.1.39"
|
||||
version = "0.1.35"
|
||||
description = "Python SDK for Claude Code"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ed6a79781f545b761b9fe467bc5ae213a103c9d3f0fe7a9dad3c01790ed58fa"},
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:0c03b5a3772eaec42e29ea39240c7d24b760358082f2e36336db9e71dde3dda4"},
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d2665c9e87b6ffece590bcdd6eb9def47cde4809b0d2f66e0a61a719189be7c9"},
|
||||
{file = "claude_agent_sdk-0.1.39-py3-none-win_amd64.whl", hash = "sha256:d03324daf7076be79d2dd05944559aabf4cc11c98d3a574b992a442a7c7a26d6"},
|
||||
{file = "claude_agent_sdk-0.1.39.tar.gz", hash = "sha256:dcf0ebd5a638c9a7d9f3af7640932a9212b2705b7056e4f08bd3968a865b4268"},
|
||||
{file = "claude_agent_sdk-0.1.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df67f4deade77b16a9678b3a626c176498e40417f33b04beda9628287f375591"},
|
||||
{file = "claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:14963944f55ded7c8ed518feebfa5b4284aa6dd8d81aeff2e5b21a962ce65097"},
|
||||
{file = "claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:84344dcc535d179c1fc8a11c6f34c37c3b583447bdf09d869effb26514fd7a65"},
|
||||
{file = "claude_agent_sdk-0.1.35-py3-none-win_amd64.whl", hash = "sha256:1b3d54b47448c93f6f372acd4d1757f047c3c1e8ef5804be7a1e3e53e2c79a5f"},
|
||||
{file = "claude_agent_sdk-0.1.35.tar.gz", hash = "sha256:0f98e2b3c71ca85abfc042e7a35c648df88e87fda41c52e6779ef7b038dcbb52"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -8530,4 +8530,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.14"
|
||||
content-hash = "3ef62836d8321b9a3b8e897dade8dc6ca9022fd9468c53f384b0871b521ab343"
|
||||
content-hash = "55e095de555482f0fe47de7695f390fe93e7bcf739b31c391b2e5e3c3d938ae3"
|
||||
|
||||
@@ -16,7 +16,7 @@ anthropic = "^0.79.0"
|
||||
apscheduler = "^3.11.1"
|
||||
autogpt-libs = { path = "../autogpt_libs", develop = true }
|
||||
bleach = { extras = ["css"], version = "^6.2.0" }
|
||||
claude-agent-sdk = "^0.1.39" # see copilot/sdk/sdk_compat_test.py for capability checks
|
||||
claude-agent-sdk = "^0.1.0"
|
||||
click = "^8.2.0"
|
||||
cryptography = "^46.0"
|
||||
discord-py = "^2.5.2"
|
||||
|
||||
@@ -1,30 +1,100 @@
|
||||
// import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { memo } from "react";
|
||||
import { BlockMenu } from "./NewBlockMenu/BlockMenu/BlockMenu";
|
||||
import { useNewControlPanel } from "./useNewControlPanel";
|
||||
// import { NewSaveControl } from "../SaveControl/NewSaveControl";
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
// import { ControlPanelButton } from "../ControlPanelButton";
|
||||
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
|
||||
export const NewControlPanel = memo(() => {
|
||||
useNewControlPanel({});
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-10 z-10 overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
<BlockMenu />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<NewSaveControl />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<UndoRedoButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
export type NewControlPanelProps = {
|
||||
flowExecutionID?: GraphExecutionID | undefined;
|
||||
visualizeBeads?: "no" | "static" | "animate";
|
||||
pinSavePopover?: boolean;
|
||||
pinBlocksPopover?: boolean;
|
||||
nodes?: CustomNode[];
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string) => void;
|
||||
};
|
||||
export const NewControlPanel = memo(
|
||||
({
|
||||
flowExecutionID: _flowExecutionID,
|
||||
visualizeBeads: _visualizeBeads,
|
||||
pinSavePopover: _pinSavePopover,
|
||||
pinBlocksPopover: _pinBlocksPopover,
|
||||
nodes: _nodes,
|
||||
onNodeSelect: _onNodeSelect,
|
||||
onNodeHover: _onNodeHover,
|
||||
}: NewControlPanelProps) => {
|
||||
const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
|
||||
|
||||
const {
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
} = useNewControlPanel({});
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-10 z-10 overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
<BlockMenu />
|
||||
{/* <Separator className="text-[#E1E1E1]" />
|
||||
{isGraphSearchEnabled && (
|
||||
<>
|
||||
<GraphSearchMenu
|
||||
nodes={nodes}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
</>
|
||||
)}
|
||||
{controls.map((control, index) => (
|
||||
<ControlPanelButton
|
||||
key={index}
|
||||
onClick={() => control.onClick()}
|
||||
data-id={`control-button-${index}`}
|
||||
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
|
||||
disabled={control.disabled || false}
|
||||
className="rounded-none"
|
||||
>
|
||||
{control.icon}
|
||||
</ControlPanelButton>
|
||||
))} */}
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<NewSaveControl />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<UndoRedoButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default NewControlPanel;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomNode } from "../../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGraphSearch } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
import { CustomNode } from "../../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
|
||||
interface UseGraphMenuProps {
|
||||
nodes: CustomNode[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { beautifyString, categoryColorMap } from "@/lib/utils";
|
||||
import { beautifyString, getPrimaryCategoryColor } from "@/lib/utils";
|
||||
import { SearchableNode } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
import { TextRenderer } from "@/components/__legacy__/ui/render";
|
||||
import {
|
||||
@@ -73,12 +73,14 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
}
|
||||
|
||||
const nodeTitle =
|
||||
(node.data?.metadata?.customized_name as string) ||
|
||||
beautifyString(node.data?.title || "").replace(/ Block$/, "");
|
||||
const nodeType = beautifyString(node.data?.title || "").replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
);
|
||||
node.data?.metadata?.customized_name ||
|
||||
beautifyString(node.data?.blockType || "").replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
);
|
||||
const nodeType = beautifyString(
|
||||
node.data?.blockType || "",
|
||||
).replace(/ Block$/, "");
|
||||
|
||||
return (
|
||||
<TooltipProvider key={node.id}>
|
||||
@@ -98,13 +100,7 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
onMouseLeave={() => onNodeHover?.(null)}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-3 rounded-l-[7px] ${
|
||||
(node.data?.categories?.[0]?.category &&
|
||||
categoryColorMap[
|
||||
node.data.categories[0].category
|
||||
]) ||
|
||||
"bg-gray-300 dark:bg-slate-700"
|
||||
}`}
|
||||
className={`h-full w-3 rounded-l-[7px] ${getPrimaryCategoryColor(node.data?.categories)}`}
|
||||
/>
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
@@ -133,10 +129,9 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
<div className="font-semibold">
|
||||
Node Type: {nodeType}
|
||||
</div>
|
||||
{!!node.data?.metadata?.customized_name && (
|
||||
{node.data?.metadata?.customized_name && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Custom Name:{" "}
|
||||
{String(node.data.metadata.customized_name)}
|
||||
Custom Name: {node.data.metadata.customized_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useDeferredValue } from "react";
|
||||
import { CustomNode } from "../../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import jaro from "jaro-winkler";
|
||||
|
||||
@@ -67,10 +67,10 @@ function calculateNodeScore(
|
||||
const nodeTitle = (node.data?.title || "").toLowerCase(); // This includes the ID
|
||||
const nodeId = (node.id || "").toLowerCase();
|
||||
const nodeDescription = (node.data?.description || "").toLowerCase();
|
||||
const blockType = (node.data?.title || "").toLowerCase();
|
||||
const blockType = (node.data?.blockType || "").toLowerCase();
|
||||
const beautifiedBlockType = beautifyString(blockType).toLowerCase();
|
||||
const customizedName = String(
|
||||
node.data?.metadata?.customized_name || "",
|
||||
const customizedName = (
|
||||
node.data?.metadata?.customized_name || ""
|
||||
).toLowerCase();
|
||||
|
||||
// Get input and output names with defensive checks
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import { GraphID } from "@/lib/autogpt-server-api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NewControlPanelProps {
|
||||
// flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads?: "no" | "static" | "animate";
|
||||
}
|
||||
|
||||
export const useNewControlPanel = ({
|
||||
// flowExecutionID,
|
||||
visualizeBeads: _visualizeBeads,
|
||||
}: NewControlPanelProps) => {
|
||||
const [blockMenuSelected, setBlockMenuSelected] = useState<
|
||||
"save" | "block" | "search" | ""
|
||||
>("");
|
||||
const query = useSearchParams();
|
||||
const _graphVersion = query.get("flowVersion");
|
||||
const _graphVersionParsed = _graphVersion
|
||||
? parseInt(_graphVersion)
|
||||
: undefined;
|
||||
|
||||
const _flowID = (query.get("flowID") as GraphID | null) ?? undefined;
|
||||
// const {
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
// } = useAgentGraph(
|
||||
// flowID,
|
||||
// graphVersion,
|
||||
// flowExecutionID,
|
||||
// visualizeBeads !== "no",
|
||||
// );
|
||||
|
||||
return {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import React, { useCallback, useMemo, useState, useDeferredValue } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/__legacy__/ui/card";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { TextRenderer } from "@/components/__legacy__/ui/render";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import {
|
||||
Block,
|
||||
BlockIORootSchema,
|
||||
BlockUIType,
|
||||
GraphInputSchema,
|
||||
GraphOutputSchema,
|
||||
SpecialBlockID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import { IconToyBrick } from "@/components/__legacy__/ui/icons";
|
||||
import { getPrimaryCategoryColor } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import jaro from "jaro-winkler";
|
||||
import { getV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
|
||||
type _Block = Omit<Block, "inputSchema" | "outputSchema"> & {
|
||||
uiKey?: string;
|
||||
inputSchema: BlockIORootSchema | GraphInputSchema;
|
||||
outputSchema: BlockIORootSchema | GraphOutputSchema;
|
||||
hardcodedValues?: Record<string, any>;
|
||||
_cached?: {
|
||||
blockName: string;
|
||||
beautifiedName: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Hook to preprocess blocks with cached expensive operations
|
||||
const useSearchableBlocks = (blocks: _Block[]): _Block[] => {
|
||||
return useMemo(
|
||||
() =>
|
||||
blocks.map((block) => {
|
||||
if (!block._cached) {
|
||||
block._cached = {
|
||||
blockName: block.name.toLowerCase(),
|
||||
beautifiedName: beautifyString(block.name).toLowerCase(),
|
||||
description: block.description.toLowerCase(),
|
||||
};
|
||||
}
|
||||
return block;
|
||||
}),
|
||||
[blocks],
|
||||
);
|
||||
};
|
||||
|
||||
interface BlocksControlProps {
|
||||
blocks: _Block[];
|
||||
addBlock: (
|
||||
id: string,
|
||||
name: string,
|
||||
hardcodedValues: Record<string, any>,
|
||||
) => void;
|
||||
pinBlocksPopover: boolean;
|
||||
flows: GraphMeta[];
|
||||
nodes: CustomNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A React functional component that displays a control for managing blocks.
|
||||
*
|
||||
* @component
|
||||
* @param {Object} BlocksControlProps - The properties for the BlocksControl component.
|
||||
* @param {Block[]} BlocksControlProps.blocks - An array of blocks to be displayed and filtered.
|
||||
* @param {(id: string, name: string) => void} BlocksControlProps.addBlock - A function to call when a block is added.
|
||||
* @returns The rendered BlocksControl component.
|
||||
*/
|
||||
export function BlocksControl({
|
||||
blocks: _blocks,
|
||||
addBlock,
|
||||
pinBlocksPopover,
|
||||
flows,
|
||||
nodes,
|
||||
}: BlocksControlProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const blocks = useSearchableBlocks(_blocks);
|
||||
|
||||
const graphHasWebhookNodes = nodes.some((n) =>
|
||||
[BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(n.data.uiType),
|
||||
);
|
||||
const graphHasInputNodes = nodes.some(
|
||||
(n) => n.data.uiType == BlockUIType.INPUT,
|
||||
);
|
||||
|
||||
const filteredAvailableBlocks = useMemo(() => {
|
||||
const blockList = blocks
|
||||
.filter((b) => b.uiType !== BlockUIType.AGENT)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Agent blocks are created from GraphMeta which doesn't include schemas.
|
||||
// Schemas will be fetched on-demand when the block is actually added.
|
||||
const agentBlockList = flows
|
||||
.map((flow): _Block => {
|
||||
return {
|
||||
id: SpecialBlockID.AGENT,
|
||||
name: flow.name,
|
||||
description:
|
||||
`Ver.${flow.version}` +
|
||||
(flow.description ? ` | ${flow.description}` : ""),
|
||||
categories: [{ category: "AGENT", description: "" }],
|
||||
// Empty schemas - will be populated when block is added
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: { type: "object", properties: {} },
|
||||
staticOutput: false,
|
||||
uiType: BlockUIType.AGENT,
|
||||
costs: [],
|
||||
uiKey: flow.id,
|
||||
hardcodedValues: {
|
||||
graph_id: flow.id,
|
||||
graph_version: flow.version,
|
||||
// Schemas will be fetched on-demand when block is added
|
||||
},
|
||||
};
|
||||
})
|
||||
.map(
|
||||
(agentBlock): _Block => ({
|
||||
...agentBlock,
|
||||
_cached: {
|
||||
blockName: agentBlock.name.toLowerCase(),
|
||||
beautifiedName: beautifyString(agentBlock.name).toLowerCase(),
|
||||
description: agentBlock.description.toLowerCase(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return blockList
|
||||
.concat(agentBlockList)
|
||||
.map((block) => ({
|
||||
block,
|
||||
score: blockScoreForQuery(block, deferredSearchQuery),
|
||||
}))
|
||||
.filter(
|
||||
({ block, score }) =>
|
||||
score > 0 &&
|
||||
(!selectedCategory ||
|
||||
block.categories.some((cat) => cat.category === selectedCategory)),
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ block }) => ({
|
||||
...block,
|
||||
notAvailable:
|
||||
(block.uiType == BlockUIType.WEBHOOK &&
|
||||
graphHasWebhookNodes &&
|
||||
"Agents can only have one webhook-triggered block") ||
|
||||
(block.uiType == BlockUIType.WEBHOOK &&
|
||||
graphHasInputNodes &&
|
||||
"Webhook-triggered blocks can't be used together with input blocks") ||
|
||||
(block.uiType == BlockUIType.INPUT &&
|
||||
graphHasWebhookNodes &&
|
||||
"Input blocks can't be used together with a webhook-triggered block") ||
|
||||
null,
|
||||
}));
|
||||
}, [
|
||||
blocks,
|
||||
flows,
|
||||
selectedCategory,
|
||||
deferredSearchQuery,
|
||||
graphHasInputNodes,
|
||||
graphHasWebhookNodes,
|
||||
]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
setSelectedCategory(null);
|
||||
}, []);
|
||||
|
||||
// Handler to add a block, fetching graph data on-demand for agent blocks
|
||||
const handleAddBlock = useCallback(
|
||||
async (block: _Block & { notAvailable: string | null }) => {
|
||||
if (block.notAvailable) return;
|
||||
|
||||
// For agent blocks, fetch the full graph to get schemas
|
||||
if (block.uiType === BlockUIType.AGENT && block.hardcodedValues) {
|
||||
const graphID = block.hardcodedValues.graph_id as string;
|
||||
const graphVersion = block.hardcodedValues.graph_version as number;
|
||||
const graphData = okData(
|
||||
await getV1GetSpecificGraph(graphID, { version: graphVersion }),
|
||||
);
|
||||
|
||||
if (graphData) {
|
||||
addBlock(block.id, block.name, {
|
||||
...block.hardcodedValues,
|
||||
input_schema: graphData.input_schema,
|
||||
output_schema: graphData.output_schema,
|
||||
});
|
||||
} else {
|
||||
// Fallback: add without schemas (will be incomplete)
|
||||
console.error("Failed to fetch graph data for agent block");
|
||||
addBlock(block.id, block.name, block.hardcodedValues || {});
|
||||
}
|
||||
} else {
|
||||
addBlock(block.id, block.name, block.hardcodedValues || {});
|
||||
}
|
||||
},
|
||||
[addBlock],
|
||||
);
|
||||
|
||||
// Extract unique categories from blocks
|
||||
const categories = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set([
|
||||
null,
|
||||
...blocks
|
||||
.flatMap((block) => block.categories.map((cat) => cat.category))
|
||||
.sort(),
|
||||
]),
|
||||
);
|
||||
}, [blocks]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={pinBlocksPopover ? true : undefined}
|
||||
onOpenChange={(open) => open || resetFilters()}
|
||||
>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
name="Blocks"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
<IconToyBrick />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Blocks</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="blocks-control-popover-content"
|
||||
>
|
||||
<Card className="p-3 pb-0 dark:bg-slate-900">
|
||||
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
|
||||
<div className="items-center justify-between">
|
||||
<Label
|
||||
htmlFor="search-blocks"
|
||||
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
|
||||
data-id="blocks-control-label"
|
||||
data-testid="blocks-control-blocks-label"
|
||||
>
|
||||
Blocks
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<Input
|
||||
id="search-blocks"
|
||||
type="text"
|
||||
placeholder="Search blocks"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg px-8 py-5 dark:bg-slate-800 dark:text-white"
|
||||
data-id="blocks-control-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
data-testid="blocks-categories-list"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const color = getPrimaryCategoryColor([
|
||||
{ category: category || "All", description: "" },
|
||||
]);
|
||||
const colorClass =
|
||||
selectedCategory === category ? `${color}` : "";
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
data-testid="blocks-category"
|
||||
role="button"
|
||||
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
selectedCategory === category ? null : category,
|
||||
)
|
||||
}
|
||||
>
|
||||
{beautifyString((category || "All").toLowerCase())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-scroll border-t border-t-gray-200 p-0 dark:border-t-slate-700">
|
||||
<ScrollArea
|
||||
className="h-[60vh] w-full"
|
||||
data-id="blocks-control-scroll-area"
|
||||
>
|
||||
{filteredAvailableBlocks.map((block) => (
|
||||
<Card
|
||||
key={block.uiKey || block.id}
|
||||
className={`m-2 my-4 flex h-20 shadow-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 ${
|
||||
block.notAvailable
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-move hover:shadow-lg"
|
||||
}`}
|
||||
data-id={`block-card-${block.id}`}
|
||||
draggable={!block.notAvailable}
|
||||
onDragStart={(e) => {
|
||||
if (block.notAvailable) return;
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
JSON.stringify({
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
hardcodedValues: block?.hardcodedValues || {},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onClick={() => handleAddBlock(block)}
|
||||
title={block.notAvailable ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
|
||||
></div>
|
||||
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
<span
|
||||
className="block truncate pb-1 text-sm font-semibold dark:text-white"
|
||||
data-id={`block-name-${block.id}`}
|
||||
data-type={block.uiType}
|
||||
data-testid={`block-name-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={beautifyString(block.name).replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
)}
|
||||
truncateLengthLimit={45}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
|
||||
data-testid={`block-description-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={block.description}
|
||||
truncateLengthLimit={165}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1"
|
||||
data-id={`block-tooltip-${block.id}`}
|
||||
data-testid={`block-add`}
|
||||
>
|
||||
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates how well a block matches the search query and returns a relevance score.
|
||||
* The scoring algorithm works as follows:
|
||||
* - Returns 1 if no query (all blocks match equally)
|
||||
* - Normalized query for case-insensitive matching
|
||||
* - Returns 3 for exact substring matches in block name (highest priority)
|
||||
* - Returns 2 when all query words appear in the block name (regardless of order)
|
||||
* - Returns 1.X for blocks with names similar to query using Jaro-Winkler distance (X is similarity score)
|
||||
* - Returns 0.5 when all query words appear in the block description (lowest priority)
|
||||
* - Returns 0 for no match
|
||||
*
|
||||
* Higher scores will appear first in search results.
|
||||
*/
|
||||
function blockScoreForQuery(block: _Block, query: string): number {
|
||||
if (!query) return 1;
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
const queryWords = normalizedQuery.split(/\s+/);
|
||||
|
||||
// Use cached values for performance
|
||||
const { blockName, beautifiedName, description } = block._cached!;
|
||||
|
||||
// 1. Exact match in name (highest priority)
|
||||
if (
|
||||
blockName.includes(normalizedQuery) ||
|
||||
beautifiedName.includes(normalizedQuery)
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// 2. All query words in name (regardless of order)
|
||||
const allWordsInName = queryWords.every(
|
||||
(word) => blockName.includes(word) || beautifiedName.includes(word),
|
||||
);
|
||||
if (allWordsInName) return 2;
|
||||
|
||||
// 3. Similarity with name (Jaro-Winkler)
|
||||
const similarityThreshold = 0.65;
|
||||
const nameSimilarity = jaro(blockName, normalizedQuery);
|
||||
const beautifiedSimilarity = jaro(beautifiedName, normalizedQuery);
|
||||
const maxSimilarity = Math.max(nameSimilarity, beautifiedSimilarity);
|
||||
if (maxSimilarity > similarityThreshold) {
|
||||
return 1 + maxSimilarity; // Score between 1 and 2
|
||||
}
|
||||
|
||||
// 4. All query words in description (lower priority)
|
||||
const allWordsInDescription = queryWords.every((word) =>
|
||||
description.includes(word),
|
||||
);
|
||||
if (allWordsInDescription) return 0.5;
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
interface Props {
|
||||
onClickAgentOutputs?: () => void;
|
||||
onClickRunAgent?: () => void;
|
||||
onClickStopRun: () => void;
|
||||
onClickScheduleButton?: () => void;
|
||||
isRunning: boolean;
|
||||
isDisabled: boolean;
|
||||
className?: string;
|
||||
resolutionModeActive?: boolean;
|
||||
}
|
||||
|
||||
export const BuildActionBar: React.FC<Props> = ({
|
||||
onClickAgentOutputs,
|
||||
onClickRunAgent,
|
||||
onClickStopRun,
|
||||
onClickScheduleButton,
|
||||
isRunning,
|
||||
isDisabled,
|
||||
className,
|
||||
resolutionModeActive = false,
|
||||
}) => {
|
||||
const buttonClasses =
|
||||
"flex items-center gap-2 text-sm font-medium md:text-lg";
|
||||
|
||||
// Show resolution mode message instead of action buttons
|
||||
if (resolutionModeActive) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit select-none items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900/30">
|
||||
<WarningIcon className="size-5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Remove incompatible connections to continue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit select-none items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1 md:gap-4">
|
||||
{onClickAgentOutputs && (
|
||||
<Button
|
||||
className={buttonClasses}
|
||||
variant="outline"
|
||||
size="primary"
|
||||
onClick={onClickAgentOutputs}
|
||||
title="View agent outputs"
|
||||
>
|
||||
<LogOut className="hidden size-5 md:flex" /> Agent Outputs
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isRunning ? (
|
||||
<Button
|
||||
className={cn(
|
||||
buttonClasses,
|
||||
onClickRunAgent && isDisabled
|
||||
? "cursor-default opacity-50 hover:bg-accent"
|
||||
: "",
|
||||
)}
|
||||
variant="accent"
|
||||
size="primary"
|
||||
onClick={onClickRunAgent}
|
||||
disabled={!onClickRunAgent}
|
||||
title="Run the agent"
|
||||
aria-label="Run the agent"
|
||||
data-testid="primary-action-run-agent"
|
||||
data-tutorial-id="primary-action-run-agent"
|
||||
>
|
||||
<IconPlay /> Run
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={buttonClasses}
|
||||
variant="destructive"
|
||||
size="primary"
|
||||
onClick={onClickStopRun}
|
||||
title="Stop the agent"
|
||||
data-id="primary-action-stop-agent"
|
||||
>
|
||||
<IconSquare /> Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onClickScheduleButton && (
|
||||
<Button
|
||||
className={buttonClasses}
|
||||
variant="outline"
|
||||
size="primary"
|
||||
onClick={onClickScheduleButton}
|
||||
title="Set up a run schedule for the agent"
|
||||
data-id="primary-action-schedule-agent"
|
||||
>
|
||||
<ClockIcon className="hidden h-5 w-5 md:flex" />
|
||||
Schedule Run
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
ConnectionLineComponentProps,
|
||||
Node,
|
||||
getBezierPath,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
|
||||
export default function ConnectionLine<NodeType extends Node>({
|
||||
fromPosition,
|
||||
fromHandle,
|
||||
fromX,
|
||||
fromY,
|
||||
toPosition,
|
||||
toX,
|
||||
toY,
|
||||
}: ConnectionLineComponentProps<NodeType>) {
|
||||
const sourceX =
|
||||
fromPosition === Position.Right
|
||||
? fromX + ((fromHandle?.width ?? 0) / 2 - 5)
|
||||
: fromX - ((fromHandle?.width ?? 0) / 2 - 5);
|
||||
|
||||
const [path] = getBezierPath({
|
||||
sourceX: sourceX,
|
||||
sourceY: fromY,
|
||||
sourcePosition: fromPosition,
|
||||
targetX: toX,
|
||||
targetY: toY,
|
||||
targetPosition: toPosition,
|
||||
});
|
||||
|
||||
return <BaseEdge path={path} style={{ strokeWidth: 2, stroke: "#555" }} />;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Card, CardContent } from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Represents a control element for the ControlPanel Component.
|
||||
* @type {Object} Control
|
||||
* @property {React.ReactNode} icon - The icon of the control from lucide-react https://lucide.dev/icons/
|
||||
* @property {string} label - The label of the control, to be leveraged by ToolTip.
|
||||
* @property {onclick} onClick - The function to be executed when the control is clicked.
|
||||
*/
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface ControlPanelProps {
|
||||
controls: Control[];
|
||||
topChildren?: React.ReactNode;
|
||||
botChildren?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ControlPanel component displays a panel with controls as icons.tsx with the ability to take in children.
|
||||
* @param {Object} ControlPanelProps - The properties of the control panel component.
|
||||
* @param {Array} ControlPanelProps.controls - An array of control objects representing actions to be preformed.
|
||||
* @param {Array} ControlPanelProps.children - The child components of the control panel.
|
||||
* @param {string} ControlPanelProps.className - Additional CSS class names for the control panel.
|
||||
* @returns The rendered control panel component.
|
||||
*/
|
||||
export const ControlPanel = ({
|
||||
controls,
|
||||
topChildren,
|
||||
botChildren,
|
||||
className,
|
||||
}: ControlPanelProps) => {
|
||||
return (
|
||||
<Card className={cn("m-4 mt-24 w-14 dark:bg-slate-900", className)}>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl py-3">
|
||||
{topChildren}
|
||||
<Separator className="dark:bg-slate-700" />
|
||||
{controls.map((control, index) => (
|
||||
<Tooltip key={index} delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => control.onClick()}
|
||||
data-id={`control-button-${index}`}
|
||||
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
|
||||
disabled={control.disabled || false}
|
||||
className="dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{control.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Separator className="dark:bg-slate-700" />
|
||||
{botChildren}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default ControlPanel;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
useReactFlow,
|
||||
XYPosition,
|
||||
Edge,
|
||||
Node,
|
||||
} from "@xyflow/react";
|
||||
import "./customedge.css";
|
||||
import { X } from "lucide-react";
|
||||
import { BuilderContext } from "../Flow/Flow";
|
||||
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
|
||||
import { useCustomEdge } from "./useCustomEdge";
|
||||
|
||||
export type CustomEdgeData = {
|
||||
edgeColor: string;
|
||||
sourcePos?: XYPosition;
|
||||
isStatic?: boolean;
|
||||
beadUp: number;
|
||||
beadDown: number;
|
||||
beadData?: Map<string, NodeExecutionResult["status"]>;
|
||||
};
|
||||
|
||||
type Bead = {
|
||||
t: number;
|
||||
targetT: number;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
export type CustomEdge = Edge<CustomEdgeData, "custom">;
|
||||
|
||||
export function CustomEdge({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
markerEnd,
|
||||
}: EdgeProps<CustomEdge>) {
|
||||
const [beads, setBeads] = useState<{
|
||||
beads: Bead[];
|
||||
created: number;
|
||||
destroyed: number;
|
||||
}>({ beads: [], created: 0, destroyed: 0 });
|
||||
const beadsRef = useRef(beads);
|
||||
const { svgPath, length, getPointForT, getTForDistance } = useCustomEdge(
|
||||
sourceX - 5,
|
||||
sourceY - 5,
|
||||
targetX + 3,
|
||||
targetY - 5,
|
||||
);
|
||||
const { deleteElements } = useReactFlow<Node, CustomEdge>();
|
||||
const builderContext = useContext(BuilderContext);
|
||||
const { visualizeBeads } = builderContext ?? {
|
||||
visualizeBeads: "no",
|
||||
};
|
||||
|
||||
// Check if this edge is broken (during resolution mode)
|
||||
const isBroken =
|
||||
builderContext?.resolutionMode?.active &&
|
||||
builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
|
||||
|
||||
const onEdgeRemoveClick = () => {
|
||||
deleteElements({ edges: [{ id }] });
|
||||
};
|
||||
|
||||
const animationDuration = 500; // Duration in milliseconds for bead to travel the curve
|
||||
const beadDiameter = 12;
|
||||
const deltaTime = 16;
|
||||
|
||||
const setTargetPositions = useCallback(
|
||||
(beads: Bead[]) => {
|
||||
const distanceBetween = Math.min(
|
||||
(length - beadDiameter) / (beads.length + 1),
|
||||
beadDiameter,
|
||||
);
|
||||
|
||||
return beads.map((bead, index) => {
|
||||
const distanceFromEnd = beadDiameter * 1.35;
|
||||
const targetPosition = distanceBetween * index + distanceFromEnd;
|
||||
const t = getTForDistance(-targetPosition);
|
||||
|
||||
return {
|
||||
...bead,
|
||||
t: visualizeBeads === "animate" ? bead.t : t,
|
||||
targetT: t,
|
||||
} as Bead;
|
||||
});
|
||||
},
|
||||
[getTForDistance, length, visualizeBeads],
|
||||
);
|
||||
|
||||
beadsRef.current = beads;
|
||||
useEffect(() => {
|
||||
const beadUp: number = data?.beadUp ?? 0;
|
||||
const beadDown: number = data?.beadDown ?? 0;
|
||||
|
||||
if (
|
||||
beadUp === 0 &&
|
||||
beadDown === 0 &&
|
||||
(beads.created > 0 || beads.destroyed > 0)
|
||||
) {
|
||||
setBeads({ beads: [], created: 0, destroyed: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add beads
|
||||
if (beadUp > beads.created) {
|
||||
setBeads(({ beads, created, destroyed }) => {
|
||||
const newBeads = [];
|
||||
for (let i = 0; i < beadUp - created; i++) {
|
||||
newBeads.push({ t: 0, targetT: 0, startTime: Date.now() });
|
||||
}
|
||||
|
||||
const b = setTargetPositions([...beads, ...newBeads]);
|
||||
return { beads: b, created: beadUp, destroyed };
|
||||
});
|
||||
}
|
||||
|
||||
// Animate and remove beads
|
||||
const interval = setInterval(
|
||||
({ current: beads }) => {
|
||||
// If there are no beads visible or moving, stop re-rendering
|
||||
if (
|
||||
(beadUp === beads.created && beads.created === beads.destroyed) ||
|
||||
beads.beads.every((bead) => bead.t >= bead.targetT)
|
||||
) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
setBeads(({ beads, created, destroyed }) => {
|
||||
let destroyedCount = 0;
|
||||
|
||||
const newBeads = beads
|
||||
.map((bead) => {
|
||||
const progressIncrement = deltaTime / animationDuration;
|
||||
const t = Math.min(
|
||||
bead.t + bead.targetT * progressIncrement,
|
||||
bead.targetT,
|
||||
);
|
||||
|
||||
return { ...bead, t };
|
||||
})
|
||||
.filter((bead, index) => {
|
||||
const removeCount = beadDown - destroyed;
|
||||
if (bead.t >= bead.targetT && index < removeCount) {
|
||||
destroyedCount++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
beads: setTargetPositions(newBeads),
|
||||
created,
|
||||
destroyed: destroyed + destroyedCount,
|
||||
};
|
||||
});
|
||||
},
|
||||
deltaTime,
|
||||
beadsRef,
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [data?.beadUp, data?.beadDown, setTargetPositions, visualizeBeads]);
|
||||
|
||||
const middle = getPointForT(0.5);
|
||||
|
||||
// Determine edge color - red for broken edges
|
||||
const baseColor = data?.edgeColor ?? "#555555";
|
||||
const edgeColor = isBroken ? "#ef4444" : baseColor;
|
||||
// Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
|
||||
const strokeColor = isBroken
|
||||
? `${edgeColor}99`
|
||||
: selected
|
||||
? edgeColor
|
||||
: `${edgeColor}80`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={svgPath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth: data?.isStatic ? 2.5 : 2,
|
||||
strokeDasharray: data?.isStatic ? "5 3" : undefined,
|
||||
}}
|
||||
className="data-sentry-unmask transition-all duration-200"
|
||||
/>
|
||||
<path
|
||||
d={svgPath}
|
||||
fill="none"
|
||||
strokeOpacity={0}
|
||||
strokeWidth={20}
|
||||
className="data-sentry-unmask react-flow__edge-interaction"
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${middle.x}px,${middle.y}px)`,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
className="edge-label-renderer"
|
||||
>
|
||||
<button
|
||||
className="edge-label-button opacity-0 transition-opacity duration-200 hover:opacity-100"
|
||||
onClick={onEdgeRemoveClick}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
{beads.beads.map((bead, index) => {
|
||||
const pos = getPointForT(bead.t);
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={beadDiameter / 2} // Bead radius
|
||||
fill={data?.edgeColor ?? "#555555"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.edge-label-renderer {
|
||||
position: absolute;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.edge-label-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #eee;
|
||||
border: 1px solid #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
color: #555;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease-in-out,
|
||||
background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.edge-label-button.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edge-label-button:hover {
|
||||
box-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.08);
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.edge-label-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.react-flow__edge-interaction {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-flow__edges > svg:has(> g.selected) {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.react-flow__edgelabel-renderer {
|
||||
z-index: 11 !important;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type XYPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type BezierPath = {
|
||||
sourcePosition: XYPosition;
|
||||
control1: XYPosition;
|
||||
control2: XYPosition;
|
||||
targetPosition: XYPosition;
|
||||
};
|
||||
|
||||
export function useCustomEdge(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
) {
|
||||
const path: BezierPath = useMemo(() => {
|
||||
const xDifference = Math.abs(sourceX - targetX);
|
||||
const yDifference = Math.abs(sourceY - targetY);
|
||||
const xControlDistance =
|
||||
sourceX < targetX ? 64 : Math.max(xDifference / 2, 64);
|
||||
const yControlDistance = yDifference < 128 && sourceX > targetX ? -64 : 0;
|
||||
|
||||
return {
|
||||
sourcePosition: { x: sourceX, y: sourceY },
|
||||
control1: {
|
||||
x: sourceX + xControlDistance,
|
||||
y: sourceY + yControlDistance,
|
||||
},
|
||||
control2: {
|
||||
x: targetX - xControlDistance,
|
||||
y: targetY + yControlDistance,
|
||||
},
|
||||
targetPosition: { x: targetX, y: targetY },
|
||||
};
|
||||
}, [sourceX, sourceY, targetX, targetY]);
|
||||
|
||||
const svgPath = useMemo(
|
||||
() =>
|
||||
`M ${path.sourcePosition.x} ${path.sourcePosition.y} ` +
|
||||
`C ${path.control1.x} ${path.control1.y} ${path.control2.x} ${path.control2.y} ` +
|
||||
`${path.targetPosition.x}, ${path.targetPosition.y}`,
|
||||
[path],
|
||||
);
|
||||
|
||||
const getPointForT = useCallback(
|
||||
(t: number) => {
|
||||
// Bezier formula: (1-t)^3 * p0 + 3*(1-t)^2*t*p1 + 3*(1-t)*t^2*p2 + t^3*p3
|
||||
const x =
|
||||
Math.pow(1 - t, 3) * path.sourcePosition.x +
|
||||
3 * Math.pow(1 - t, 2) * t * path.control1.x +
|
||||
3 * (1 - t) * Math.pow(t, 2) * path.control2.x +
|
||||
Math.pow(t, 3) * path.targetPosition.x;
|
||||
|
||||
const y =
|
||||
Math.pow(1 - t, 3) * path.sourcePosition.y +
|
||||
3 * Math.pow(1 - t, 2) * t * path.control1.y +
|
||||
3 * (1 - t) * Math.pow(t, 2) * path.control2.y +
|
||||
Math.pow(t, 3) * path.targetPosition.y;
|
||||
|
||||
return { x, y };
|
||||
},
|
||||
[path],
|
||||
);
|
||||
|
||||
const getArcLength = useCallback(
|
||||
(t: number, samples: number = 100) => {
|
||||
let length = 0;
|
||||
let prevPoint = getPointForT(0);
|
||||
|
||||
for (let i = 1; i <= samples; i++) {
|
||||
const currT = (i / samples) * t;
|
||||
const currPoint = getPointForT(currT);
|
||||
length += Math.sqrt(
|
||||
Math.pow(currPoint.x - prevPoint.x, 2) +
|
||||
Math.pow(currPoint.y - prevPoint.y, 2),
|
||||
);
|
||||
prevPoint = currPoint;
|
||||
}
|
||||
|
||||
return length;
|
||||
},
|
||||
[getPointForT],
|
||||
);
|
||||
|
||||
const length = useMemo(() => {
|
||||
return getArcLength(1);
|
||||
}, [getArcLength]);
|
||||
|
||||
const getBezierDerivative = useCallback(
|
||||
(t: number) => {
|
||||
const mt = 1 - t;
|
||||
const x =
|
||||
3 *
|
||||
(mt * mt * (path.control1.x - path.sourcePosition.x) +
|
||||
2 * mt * t * (path.control2.x - path.control1.x) +
|
||||
t * t * (path.targetPosition.x - path.control2.x));
|
||||
const y =
|
||||
3 *
|
||||
(mt * mt * (path.control1.y - path.sourcePosition.y) +
|
||||
2 * mt * t * (path.control2.y - path.control1.y) +
|
||||
t * t * (path.targetPosition.y - path.control2.y));
|
||||
return { x, y };
|
||||
},
|
||||
[path],
|
||||
);
|
||||
|
||||
const getTForDistance = useCallback(
|
||||
(distance: number, epsilon: number = 0.0001) => {
|
||||
if (distance < 0) {
|
||||
distance = length + distance; // If distance is negative, calculate from the end of the curve
|
||||
}
|
||||
|
||||
let t = distance / getArcLength(1);
|
||||
let prevT = 0;
|
||||
|
||||
while (Math.abs(t - prevT) > epsilon) {
|
||||
prevT = t;
|
||||
const length = getArcLength(t);
|
||||
const derivative = Math.sqrt(
|
||||
Math.pow(getBezierDerivative(t).x, 2) +
|
||||
Math.pow(getBezierDerivative(t).y, 2),
|
||||
);
|
||||
t -= (length - distance) / derivative;
|
||||
t = Math.max(0, Math.min(1, t)); // Clamp t between 0 and 1
|
||||
}
|
||||
|
||||
return t;
|
||||
},
|
||||
[getArcLength, getBezierDerivative, length],
|
||||
);
|
||||
|
||||
const getPointAtDistance = useCallback(
|
||||
(distance: number) => {
|
||||
if (distance < 0) {
|
||||
distance = length + distance; // If distance is negative, calculate from the end of the curve
|
||||
}
|
||||
|
||||
const t = getTForDistance(distance);
|
||||
return getPointForT(t);
|
||||
},
|
||||
[getTForDistance, getPointForT, length],
|
||||
);
|
||||
|
||||
return {
|
||||
path,
|
||||
svgPath,
|
||||
length,
|
||||
getPointForT,
|
||||
getTForDistance,
|
||||
getPointAtDistance,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
|
||||
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||
|
||||
interface IncompatibilityDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
currentVersion: number;
|
||||
latestVersion: number;
|
||||
agentName: string;
|
||||
incompatibilities: IncompatibilityInfo;
|
||||
}
|
||||
|
||||
export const IncompatibilityDialog: React.FC<IncompatibilityDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
agentName,
|
||||
incompatibilities,
|
||||
}) => {
|
||||
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
|
||||
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
|
||||
const hasNewInputs = incompatibilities.newInputs.length > 0;
|
||||
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
|
||||
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
|
||||
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
|
||||
|
||||
const hasInputChanges = hasMissingInputs || hasNewInputs;
|
||||
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Incompatible Update
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Updating <strong>{beautifyString(agentName)}</strong> from v
|
||||
{currentVersion} to v{latestVersion} will break some connections.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Input changes - two column layout */}
|
||||
{hasInputChanges && (
|
||||
<TwoColumnSection
|
||||
title="Input Changes"
|
||||
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
leftTitle="Removed"
|
||||
leftItems={incompatibilities.missingInputs}
|
||||
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
|
||||
rightTitle="Added"
|
||||
rightItems={incompatibilities.newInputs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Output changes - two column layout */}
|
||||
{hasOutputChanges && (
|
||||
<TwoColumnSection
|
||||
title="Output Changes"
|
||||
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
leftTitle="Removed"
|
||||
leftItems={incompatibilities.missingOutputs}
|
||||
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
|
||||
rightTitle="Added"
|
||||
rightItems={incompatibilities.newOutputs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasTypeMismatches && (
|
||||
<SingleColumnSection
|
||||
icon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
title="Type Changed"
|
||||
description="These connected inputs have a different type:"
|
||||
items={incompatibilities.inputTypeMismatches.map(
|
||||
(m) => `${m.name} (${m.oldType} → ${m.newType})`,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasNewRequired && (
|
||||
<SingleColumnSection
|
||||
icon={<PlusCircle className="h-4 w-4 text-amber-500" />}
|
||||
title="New Required Inputs"
|
||||
description="These inputs are now required:"
|
||||
items={incompatibilities.newRequiredInputs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
If you proceed, you'll need to remove the broken connections
|
||||
before you can save or run your agent.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
Update Anyway
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface TwoColumnSectionProps {
|
||||
title: string;
|
||||
leftIcon: React.ReactNode;
|
||||
leftTitle: string;
|
||||
leftItems: string[];
|
||||
rightIcon: React.ReactNode;
|
||||
rightTitle: string;
|
||||
rightItems: string[];
|
||||
}
|
||||
|
||||
const TwoColumnSection: React.FC<TwoColumnSectionProps> = ({
|
||||
title,
|
||||
leftIcon,
|
||||
leftTitle,
|
||||
leftItems,
|
||||
rightIcon,
|
||||
rightTitle,
|
||||
rightItems,
|
||||
}) => (
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||
<span className="font-medium">{title}</span>
|
||||
<div className="mt-2 grid grid-cols-2 items-start gap-4">
|
||||
{/* Left column - Breaking changes */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{leftIcon}
|
||||
<span>{leftTitle}</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{leftItems.length > 0 ? (
|
||||
leftItems.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{item}
|
||||
</code>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||
None
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Right column - Possible solutions */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{rightIcon}
|
||||
<span>{rightTitle}</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{rightItems.length > 0 ? (
|
||||
rightItems.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{item}
|
||||
</code>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||
None
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SingleColumnSectionProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const SingleColumnSection: React.FC<SingleColumnSectionProps> = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}) => (
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium">{title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
|
||||
{item}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IncompatibilityDialog;
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { ArrowUp, AlertTriangle, Info } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SubAgentUpdateBarProps {
|
||||
currentVersion: number;
|
||||
latestVersion: number;
|
||||
isCompatible: boolean;
|
||||
incompatibilities: IncompatibilityInfo | null;
|
||||
onUpdate: () => void;
|
||||
isInResolutionMode?: boolean;
|
||||
}
|
||||
|
||||
export const SubAgentUpdateBar: React.FC<SubAgentUpdateBarProps> = ({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isCompatible,
|
||||
incompatibilities,
|
||||
onUpdate,
|
||||
isInResolutionMode = false,
|
||||
}) => {
|
||||
if (isInResolutionMode) {
|
||||
return <ResolutionModeBar incompatibilities={incompatibilities} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Update available (v{currentVersion} → v{latestVersion})
|
||||
</span>
|
||||
{!isCompatible && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="font-medium">Incompatible changes detected</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Click Update to see details
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCompatible ? "default" : "outline"}
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"h-7 text-xs",
|
||||
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
|
||||
)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResolutionModeBarProps {
|
||||
incompatibilities: IncompatibilityInfo | null;
|
||||
}
|
||||
|
||||
const ResolutionModeBar: React.FC<ResolutionModeBarProps> = ({
|
||||
incompatibilities,
|
||||
}) => {
|
||||
const formatIncompatibilities = () => {
|
||||
if (!incompatibilities) return "No incompatibilities";
|
||||
|
||||
const items: string[] = [];
|
||||
|
||||
if (incompatibilities.missingInputs.length > 0) {
|
||||
items.push(
|
||||
`Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (incompatibilities.missingOutputs.length > 0) {
|
||||
items.push(
|
||||
`Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (incompatibilities.newRequiredInputs.length > 0) {
|
||||
items.push(
|
||||
`New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (incompatibilities.inputTypeMismatches.length > 0) {
|
||||
const mismatches = incompatibilities.inputTypeMismatches
|
||||
.map((m) => `${m.name} (${m.oldType} → ${m.newType})`)
|
||||
.join(", ");
|
||||
items.push(`Type changed: ${mismatches}`);
|
||||
}
|
||||
|
||||
return items.join("\n");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Remove incompatible connections
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 cursor-help text-amber-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-line">
|
||||
<p className="font-medium">Incompatible changes:</p>
|
||||
<p className="mt-1 text-xs">{formatIncompatibilities()}</p>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Delete the red connections to continue
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubAgentUpdateBar;
|
||||
@@ -0,0 +1,131 @@
|
||||
.custom-node {
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.custom-node .custom-switch {
|
||||
padding: 0.5rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d9534f;
|
||||
font-size: 13px;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Existing styles */
|
||||
.handle-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-bottom: 0px;
|
||||
padding: 5px;
|
||||
min-height: 44px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
background: transparent;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
position: relative;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.border-error {
|
||||
border: 1px solid #d9534f;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.array-item-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.array-item-input {
|
||||
flex-grow: 1;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.array-item-remove {
|
||||
background: #d9534f;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.array-item-add {
|
||||
background: #5bc0de;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d9534f;
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* Styles for node states */
|
||||
.completed {
|
||||
border-color: #27ae60; /* Green border for completed nodes */
|
||||
}
|
||||
|
||||
.running {
|
||||
border-color: #f39c12; /* Orange border for running nodes */
|
||||
}
|
||||
|
||||
.failed {
|
||||
border-color: #c0392b; /* Red border for failed nodes */
|
||||
}
|
||||
|
||||
.incomplete {
|
||||
border-color: #9f14ab; /* Pink border for incomplete nodes */
|
||||
}
|
||||
|
||||
.queued {
|
||||
border-color: #25e6e6; /* Cyan border for queued nodes */
|
||||
}
|
||||
|
||||
.custom-switch {
|
||||
padding-left: 2px;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Clipboard, Maximize2 } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../../../components/__legacy__/ui/table";
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useToast } from "../../../../../components/molecules/Toast/use-toast";
|
||||
import ExpandableOutputDialog from "./ExpandableOutputDialog";
|
||||
|
||||
type DataTableProps = {
|
||||
title?: string;
|
||||
truncateLongData?: boolean;
|
||||
data: { [key: string]: Array<any> };
|
||||
};
|
||||
|
||||
export default function DataTable({
|
||||
title,
|
||||
truncateLongData,
|
||||
data,
|
||||
}: DataTableProps) {
|
||||
const { toast } = useToast();
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
const [expandedDialog, setExpandedDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
execId: string;
|
||||
pinName: string;
|
||||
data: any[];
|
||||
} | null>(null);
|
||||
|
||||
// Prepare renderers for each item when enhanced mode is enabled
|
||||
const getItemRenderer = useMemo(() => {
|
||||
if (!enableEnhancedOutputHandling) return null;
|
||||
return (item: unknown) => {
|
||||
const metadata: OutputMetadata = {};
|
||||
return globalRegistry.getRenderer(item, metadata);
|
||||
};
|
||||
}, [enableEnhancedOutputHandling]);
|
||||
|
||||
const copyData = (pin: string, data: string) => {
|
||||
navigator.clipboard.writeText(data).then(() => {
|
||||
toast({
|
||||
title: `"${pin}" data copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const openExpandedView = (pinName: string, pinData: any[]) => {
|
||||
setExpandedDialog({
|
||||
isOpen: true,
|
||||
execId: title || "Unknown Execution",
|
||||
pinName,
|
||||
data: pinData,
|
||||
});
|
||||
};
|
||||
|
||||
const closeExpandedView = () => {
|
||||
setExpandedDialog(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <strong className="mt-2 flex justify-center">{title}</strong>}
|
||||
<Table className="cursor-default select-text">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Pin</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<TableRow className="group" key={key}>
|
||||
<TableCell className="cursor-text">
|
||||
{beautifyString(key)}
|
||||
</TableCell>
|
||||
<TableCell className="cursor-text">
|
||||
<div className="flex min-h-9 items-center whitespace-pre-wrap">
|
||||
<div className="absolute right-1 top-auto m-1 hidden gap-1 group-hover:flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openExpandedView(key, value)}
|
||||
title="Expand Full View"
|
||||
>
|
||||
<Maximize2 size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyData(
|
||||
beautifyString(key),
|
||||
value
|
||||
.map((i) =>
|
||||
typeof i === "object"
|
||||
? JSON.stringify(i, null, 2)
|
||||
: String(i),
|
||||
)
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
title="Copy Data"
|
||||
>
|
||||
<Clipboard size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
{value.map((item, index) => {
|
||||
const renderer = getItemRenderer?.(item);
|
||||
if (enableEnhancedOutputHandling && renderer) {
|
||||
const metadata: OutputMetadata = {};
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<OutputItem
|
||||
value={item}
|
||||
metadata={metadata}
|
||||
renderer={renderer}
|
||||
/>
|
||||
{index < value.length - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={truncateLongData}
|
||||
/>
|
||||
{index < value.length - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{expandedDialog && (
|
||||
<ExpandableOutputDialog
|
||||
isOpen={expandedDialog.isOpen}
|
||||
onClose={closeExpandedView}
|
||||
execId={expandedDialog.execId}
|
||||
pinName={expandedDialog.pinName}
|
||||
data={expandedDialog.data}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Clipboard, Maximize2 } from "lucide-react";
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||
import { ScrollArea } from "../../../../../components/__legacy__/ui/scroll-area";
|
||||
import { Separator } from "../../../../../components/__legacy__/ui/separator";
|
||||
import { Switch } from "../../../../../components/atoms/Switch/Switch";
|
||||
import { useToast } from "../../../../../components/molecules/Toast/use-toast";
|
||||
|
||||
interface ExpandableOutputDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
execId: string;
|
||||
pinName: string;
|
||||
data: any[];
|
||||
}
|
||||
|
||||
const ExpandableOutputDialog: FC<ExpandableOutputDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
execId,
|
||||
pinName,
|
||||
data,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
const [useEnhancedRenderer, setUseEnhancedRenderer] = useState(false);
|
||||
|
||||
// Prepare items for the enhanced renderer system
|
||||
const outputItems = useMemo(() => {
|
||||
if (!data || !useEnhancedRenderer) return [];
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: any;
|
||||
}> = [];
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const metadata: OutputMetadata = {};
|
||||
|
||||
// Extract metadata from the value if it's an object
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!React.isValidElement(value)
|
||||
) {
|
||||
const objValue = value as any;
|
||||
if (objValue.type) metadata.type = objValue.type;
|
||||
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
|
||||
if (objValue.filename) metadata.filename = objValue.filename;
|
||||
if (objValue.language) metadata.language = objValue.language;
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(value, metadata);
|
||||
if (renderer) {
|
||||
items.push({
|
||||
key: `item-${index}`,
|
||||
label: index === 0 ? beautifyString(pinName) : "",
|
||||
value,
|
||||
metadata,
|
||||
renderer,
|
||||
});
|
||||
} else {
|
||||
// Fallback to text renderer
|
||||
const textRenderer = globalRegistry
|
||||
.getAllRenderers()
|
||||
.find((r) => r.name === "TextRenderer");
|
||||
if (textRenderer) {
|
||||
items.push({
|
||||
key: `item-${index}`,
|
||||
label: index === 0 ? beautifyString(pinName) : "",
|
||||
value:
|
||||
typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2),
|
||||
metadata,
|
||||
renderer: textRenderer,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [data, useEnhancedRenderer, pinName]);
|
||||
|
||||
const copyData = () => {
|
||||
const formattedData = data
|
||||
.map((item) =>
|
||||
typeof item === "object" ? JSON.stringify(item, null, 2) : String(item),
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
navigator.clipboard.writeText(formattedData).then(() => {
|
||||
toast({
|
||||
title: `"${beautifyString(pinName)}" data copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 size={20} />
|
||||
Full Output Preview
|
||||
</div>
|
||||
{enableEnhancedOutputHandling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="enhanced-rendering-toggle"
|
||||
className="cursor-pointer select-none text-sm font-normal text-gray-600"
|
||||
>
|
||||
Enhanced Rendering
|
||||
</label>
|
||||
<Switch
|
||||
id="enhanced-rendering-toggle"
|
||||
checked={useEnhancedRenderer}
|
||||
onCheckedChange={setUseEnhancedRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
height: "90vh",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pb-4">
|
||||
<p className="text-sm text-zinc-600">
|
||||
Execution ID: <span className="font-mono text-xs">{execId}</span>
|
||||
<br />
|
||||
Pin:{" "}
|
||||
<span className="font-semibold">{beautifyString(pinName)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{useEnhancedRenderer && outputItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{data.length > 0 ? (
|
||||
useEnhancedRenderer ? (
|
||||
<div className="space-y-4">
|
||||
{outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Item {index + 1} of {data.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemData =
|
||||
typeof item === "object"
|
||||
? JSON.stringify(item, null, 2)
|
||||
: String(item);
|
||||
navigator.clipboard
|
||||
.writeText(itemData)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Item ${index + 1} copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
Copy Item
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer className="flex justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.length} item{data.length !== 1 ? "s" : ""} total
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!useEnhancedRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyData}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
Copy All
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandableOutputDialog;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
/* flow.css or index.css */
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
margin-right: -50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.modal button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal form div {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -600px;
|
||||
width: 350px;
|
||||
height: calc(100vh - 68px); /* Full height minus top offset */
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
padding: 20px;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
margin-top: 68px; /* Margin to push content below the top fixed area */
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.sidebar input {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.sidebarNodeRowStyle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #e2e2e2;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sidebarNodeRowStyle.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { GraphSearchContent } from "../NewControlPanel/NewSearchGraph/GraphMenuContent/GraphContent";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useGraphMenu } from "../NewControlPanel/NewSearchGraph/GraphMenu/useGraphMenu";
|
||||
|
||||
interface GraphSearchControlProps {
|
||||
nodes: CustomNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export function GraphSearchControl({
|
||||
nodes,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
}: GraphSearchControlProps) {
|
||||
// Use the same hook as GraphSearchMenu for consistency
|
||||
const {
|
||||
open,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredNodes,
|
||||
handleNodeSelect,
|
||||
handleOpenChange,
|
||||
} = useGraphMenu({
|
||||
nodes,
|
||||
blockMenuSelected: "", // We don't need to track this in the old control panel
|
||||
setBlockMenuSelected: () => {}, // Not needed in this context
|
||||
onNodeSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="graph-search-control-trigger"
|
||||
data-testid="graph-search-control-button"
|
||||
name="Search"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Search Graph</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
alignOffset={-50} // Offset upward to align with control panel top
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="graph-search-popover-content"
|
||||
>
|
||||
<GraphSearchContent
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filteredNodes={filteredNodes}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { Textarea } from "../../../../../components/__legacy__/ui/textarea";
|
||||
import { Maximize2, Minimize2, Clipboard } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "../../../../../components/molecules/Toast/use-toast";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (value: string) => void;
|
||||
title?: string;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
const InputModalComponent: FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
title,
|
||||
defaultValue,
|
||||
}) => {
|
||||
const [tempValue, setTempValue] = useState(defaultValue);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempValue(defaultValue);
|
||||
setIsMaximized(false);
|
||||
}
|
||||
}, [isOpen, defaultValue]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(tempValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleSize = () => {
|
||||
setIsMaximized(!isMaximized);
|
||||
};
|
||||
|
||||
const copyValue = () => {
|
||||
navigator.clipboard.writeText(tempValue).then(() => {
|
||||
toast({
|
||||
title: "Input value copied to clipboard!",
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
id="modal-content"
|
||||
className={`fixed rounded-lg border-[1.5px] bg-white p-5 ${
|
||||
isMaximized ? "inset-[128px] flex flex-col" : `w-[90%] max-w-[800px]`
|
||||
}`}
|
||||
>
|
||||
<h2 className="mb-4 text-center text-lg font-semibold">
|
||||
{title || "Enter input text"}
|
||||
</h2>
|
||||
<div className="nowheel relative flex-grow">
|
||||
<Textarea
|
||||
className="h-full min-h-[200px] w-full resize-none"
|
||||
value={tempValue}
|
||||
onChange={(e) => setTempValue(e.target.value)}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2">
|
||||
<Button onClick={copyValue} size="icon" variant="outline">
|
||||
<Clipboard size={18} />
|
||||
</Button>
|
||||
<Button onClick={toggleSize} size="icon" variant="outline">
|
||||
{isMaximized ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMaximized ? (
|
||||
createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
|
||||
{modalContent}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
) : (
|
||||
<div className="nodrag fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
|
||||
{modalContent}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputModalComponent;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
cn,
|
||||
beautifyString,
|
||||
getTypeBgColor,
|
||||
getTypeTextColor,
|
||||
getEffectiveType,
|
||||
} from "@/lib/utils";
|
||||
import { FC, memo, useCallback } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
|
||||
type HandleProps = {
|
||||
keyName: string;
|
||||
schema: BlockIOSubSchema;
|
||||
isConnected: boolean;
|
||||
isRequired?: boolean;
|
||||
side: "left" | "right";
|
||||
title?: string;
|
||||
className?: string;
|
||||
isBroken?: boolean;
|
||||
};
|
||||
|
||||
// Move the constant out of the component to avoid re-creation on every render.
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
string: "text",
|
||||
number: "number",
|
||||
integer: "integer",
|
||||
boolean: "true/false",
|
||||
object: "object",
|
||||
array: "list",
|
||||
null: "null",
|
||||
};
|
||||
|
||||
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
|
||||
const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
|
||||
memo(({ isConnected, type, isBroken }) => {
|
||||
const color = isBroken
|
||||
? "border-red-500 bg-red-100 dark:bg-red-900/30"
|
||||
: isConnected
|
||||
? getTypeBgColor(type || "any")
|
||||
: "border-gray-300 dark:border-gray-600";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700",
|
||||
color,
|
||||
isBroken && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Dot.displayName = "Dot";
|
||||
|
||||
const NodeHandle: FC<HandleProps> = ({
|
||||
keyName,
|
||||
schema,
|
||||
isConnected,
|
||||
isRequired,
|
||||
side,
|
||||
title,
|
||||
className,
|
||||
isBroken = false,
|
||||
}) => {
|
||||
// Extract effective type from schema (handles anyOf/oneOf/allOf wrappers)
|
||||
const effectiveType = getEffectiveType(schema);
|
||||
|
||||
const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
|
||||
side === "left" ? "text-left" : "text-right"
|
||||
}`;
|
||||
|
||||
const label = (
|
||||
<div className={cn("flex flex-grow flex-row", isBroken && "opacity-50")}>
|
||||
<span
|
||||
className={cn(
|
||||
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
|
||||
className,
|
||||
isBroken && "text-red-500 line-through",
|
||||
)}
|
||||
>
|
||||
{title || schema.title || beautifyString(keyName.toLowerCase())}
|
||||
{isRequired ? "*" : ""}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
`${typeClass} data-sentry-unmask flex items-end`,
|
||||
isBroken && "text-red-400",
|
||||
)}
|
||||
>
|
||||
({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use a native HTML onContextMenu handler instead of wrapping a large node with a Radix ContextMenu trigger.
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
// Optionally, you can trigger a custom, lightweight context menu here.
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (side === "left") {
|
||||
return (
|
||||
<div
|
||||
key={keyName}
|
||||
className={cn("handle-container", isBroken && "pointer-events-none")}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
data-testid={`input-handle-${keyName}`}
|
||||
position={Position.Left}
|
||||
id={keyName}
|
||||
className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")}
|
||||
isConnectable={!isBroken}
|
||||
>
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<Dot
|
||||
isConnected={isConnected}
|
||||
type={effectiveType}
|
||||
isBroken={isBroken}
|
||||
/>
|
||||
{label}
|
||||
</div>
|
||||
</Handle>
|
||||
<InformationTooltip description={schema.description} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={keyName}
|
||||
className={cn(
|
||||
"handle-container justify-end",
|
||||
isBroken && "pointer-events-none",
|
||||
)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Handle
|
||||
type="source"
|
||||
data-testid={`output-handle-${keyName}`}
|
||||
position={Position.Right}
|
||||
id={keyName}
|
||||
className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")}
|
||||
isConnectable={!isBroken}
|
||||
>
|
||||
<div className="pointer-events-none flex items-center">
|
||||
{label}
|
||||
<Dot
|
||||
isConnected={isConnected}
|
||||
type={effectiveType}
|
||||
isBroken={isBroken}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(NodeHandle);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import * as Separator from "@radix-ui/react-separator";
|
||||
import { ContentRenderer } from "@/components/__legacy__/ui/render";
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
|
||||
import { BuilderContext } from "./Flow/Flow";
|
||||
import ExpandableOutputDialog from "./ExpandableOutputDialog";
|
||||
|
||||
type NodeOutputsProps = {
|
||||
title?: string;
|
||||
truncateLongData?: boolean;
|
||||
data: { [key: string]: Array<any> };
|
||||
};
|
||||
|
||||
export default function NodeOutputs({
|
||||
title,
|
||||
truncateLongData,
|
||||
data,
|
||||
}: NodeOutputsProps) {
|
||||
const builderContext = useContext(BuilderContext);
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
|
||||
const [expandedDialog, setExpandedDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
execId: string;
|
||||
pinName: string;
|
||||
data: any[];
|
||||
} | null>(null);
|
||||
|
||||
if (!builderContext) {
|
||||
throw new Error(
|
||||
"BuilderContext consumer must be inside FlowEditor component",
|
||||
);
|
||||
}
|
||||
|
||||
const { getNodeTitle } = builderContext;
|
||||
|
||||
// Prepare renderers for each item when enhanced mode is enabled
|
||||
const getItemRenderer = useMemo(() => {
|
||||
if (!enableEnhancedOutputHandling) return null;
|
||||
return (item: unknown) => {
|
||||
const metadata: OutputMetadata = {};
|
||||
return globalRegistry.getRenderer(item, metadata);
|
||||
};
|
||||
}, [enableEnhancedOutputHandling]);
|
||||
|
||||
const getBeautifiedPinName = (pin: string) => {
|
||||
if (!pin.startsWith("tools_^_")) {
|
||||
return beautifyString(pin);
|
||||
}
|
||||
// Special handling for tool pins: replace node ID with node title
|
||||
const toolNodeID = pin.slice(8).split("_~_")[0]; // tools_^_{node_id}_~_{field}
|
||||
const toolNodeTitle = getNodeTitle(toolNodeID);
|
||||
return toolNodeTitle
|
||||
? beautifyString(pin.replace(toolNodeID, toolNodeTitle))
|
||||
: beautifyString(pin);
|
||||
};
|
||||
|
||||
const openExpandedView = (pinName: string, pinData: any[]) => {
|
||||
setExpandedDialog({
|
||||
isOpen: true,
|
||||
execId: title || "Node Output",
|
||||
pinName,
|
||||
data: pinData,
|
||||
});
|
||||
};
|
||||
|
||||
const closeExpandedView = () => {
|
||||
setExpandedDialog(null);
|
||||
};
|
||||
return (
|
||||
<div className="m-4 space-y-4">
|
||||
{title && <strong className="mt-2flex">{title}</strong>}
|
||||
{Object.entries(data).map(([pin, dataArray]) => (
|
||||
<div key={pin} className="group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<strong className="mr-2">Pin:</strong>
|
||||
<span>{getBeautifiedPinName(pin)}</span>
|
||||
</div>
|
||||
{(truncateLongData || dataArray.length > 10) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openExpandedView(pin, dataArray)}
|
||||
className="hidden items-center gap-1 group-hover:flex"
|
||||
title="Expand Full View"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
Expand
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong className="mr-2">Data:</strong>
|
||||
<div className="mt-1">
|
||||
{dataArray.slice(0, 10).map((item, index) => {
|
||||
const renderer = getItemRenderer?.(item);
|
||||
if (enableEnhancedOutputHandling && renderer) {
|
||||
const metadata: OutputMetadata = {};
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<OutputItem
|
||||
value={item}
|
||||
metadata={metadata}
|
||||
renderer={renderer}
|
||||
/>
|
||||
{index < Math.min(dataArray.length, 10) - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={truncateLongData}
|
||||
/>
|
||||
{index < Math.min(dataArray.length, 10) - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{dataArray.length > 10 && (
|
||||
<span style={{ color: "#888" }}>
|
||||
<br />
|
||||
<b>⋮</b>
|
||||
<br />
|
||||
<span>and {dataArray.length - 10} more</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator.Root className="my-4 h-[1px] bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{expandedDialog && (
|
||||
<ExpandableOutputDialog
|
||||
isOpen={expandedDialog.isOpen}
|
||||
onClose={closeExpandedView}
|
||||
execId={expandedDialog.execId}
|
||||
pinName={expandedDialog.pinName}
|
||||
data={expandedDialog.data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||
import type {
|
||||
BlockIOTableSubSchema,
|
||||
TableCellValue,
|
||||
TableRow,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import type { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
interface NodeTableInputProps {
|
||||
/** Unique identifier for the node in the builder graph */
|
||||
nodeId: string;
|
||||
/** Key identifier for this specific input field within the node */
|
||||
selfKey: string;
|
||||
/** Schema definition for the table structure */
|
||||
schema: BlockIOTableSubSchema;
|
||||
/** Column headers for the table */
|
||||
headers: string[];
|
||||
/** Initial row data for the table */
|
||||
rows?: TableRow[];
|
||||
/** Validation errors mapped by field key */
|
||||
errors: { [key: string]: string | undefined };
|
||||
/** Graph connections between nodes in the builder */
|
||||
connections: ConnectedEdge[];
|
||||
/** Callback when table data changes */
|
||||
handleInputChange: (key: string, value: TableRow[]) => void;
|
||||
/** Callback when input field is clicked (for builder selection) */
|
||||
handleInputClick: (key: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Display name for the input field */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table input component for the workflow builder interface.
|
||||
*
|
||||
* This component is specifically designed for use in the agent builder where users
|
||||
* design workflows with connected nodes. It includes graph connection capabilities
|
||||
* via NodeHandle and is tightly integrated with the builder's state management.
|
||||
*
|
||||
* @warning Do NOT use this component in runtime/execution contexts (like RunAgentInputs).
|
||||
* For runtime table inputs, use a simpler implementation without builder-specific features.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <NodeTableInput
|
||||
* nodeId="node-123"
|
||||
* selfKey="table_data"
|
||||
* schema={tableSchema}
|
||||
* headers={["Name", "Value"]}
|
||||
* rows={existingData}
|
||||
* connections={graphConnections}
|
||||
* handleInputChange={handleChange}
|
||||
* handleInputClick={handleClick}
|
||||
* errors={{}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @see Used exclusively in: `/app/(platform)/build/components/legacy-builder/NodeInputs.tsx`
|
||||
*/
|
||||
export const NodeTableInput: FC<NodeTableInputProps> = ({
|
||||
nodeId,
|
||||
selfKey,
|
||||
schema,
|
||||
headers,
|
||||
rows = [],
|
||||
errors,
|
||||
connections,
|
||||
handleInputChange,
|
||||
handleInputClick: _handleInputClick,
|
||||
className,
|
||||
displayName,
|
||||
}) => {
|
||||
const [tableData, setTableData] = useState<TableRow[]>(rows);
|
||||
|
||||
// Sync with parent state when rows change
|
||||
useEffect(() => {
|
||||
setTableData(rows);
|
||||
}, [rows]);
|
||||
|
||||
const isConnected = (key: string) =>
|
||||
connections.some((c) => c.targetHandle === key && c.target === nodeId);
|
||||
|
||||
const updateTableData = useCallback(
|
||||
(newData: TableRow[]) => {
|
||||
setTableData(newData);
|
||||
handleInputChange(selfKey, newData);
|
||||
},
|
||||
[selfKey, handleInputChange],
|
||||
);
|
||||
|
||||
const updateCell = (
|
||||
rowIndex: number,
|
||||
header: string,
|
||||
value: TableCellValue,
|
||||
) => {
|
||||
const newData = [...tableData];
|
||||
if (!newData[rowIndex]) {
|
||||
newData[rowIndex] = {};
|
||||
}
|
||||
newData[rowIndex][header] = value;
|
||||
updateTableData(newData);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
if (!headers || headers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const newRow: TableRow = {};
|
||||
headers.forEach((header) => {
|
||||
newRow[header] = "";
|
||||
});
|
||||
updateTableData([...tableData, newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newData = tableData.filter((_, i) => i !== index);
|
||||
updateTableData(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2", className)}>
|
||||
<NodeHandle
|
||||
title={displayName || selfKey}
|
||||
keyName={selfKey}
|
||||
schema={schema}
|
||||
isConnected={isConnected(selfKey)}
|
||||
isRequired={false}
|
||||
side="left"
|
||||
/>
|
||||
|
||||
{!isConnected(selfKey) && (
|
||||
<div className="nodrag overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className="border border-gray-300 bg-gray-100 px-2 py-1 text-left text-sm font-medium dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{headers.map((header, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="border border-gray-300 p-1 dark:border-gray-600"
|
||||
>
|
||||
<Input
|
||||
id={`${selfKey}-${rowIndex}-${header}`}
|
||||
label={header}
|
||||
type="text"
|
||||
value={String(row[header] || "")}
|
||||
onChange={(e) =>
|
||||
updateCell(rowIndex, header, e.target.value)
|
||||
}
|
||||
className="h-8 w-full"
|
||||
placeholder={`Enter ${header}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
className="mt-2 bg-gray-200 font-normal text-black hover:text-white dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
||||
onClick={addRow}
|
||||
size="small"
|
||||
>
|
||||
<PlusIcon className="mr-2" /> Add Row
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors[selfKey] && (
|
||||
<span className="text-sm text-red-500">{errors[selfKey]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import type { GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import { askOtto } from "@/app/(platform)/build/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
interface Message {
|
||||
type: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function OttoChatWidget({
|
||||
graphID,
|
||||
className,
|
||||
}: {
|
||||
graphID?: GraphID;
|
||||
className?: string;
|
||||
}): React.ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [includeGraphData, setIncludeGraphData] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Add welcome message when component mounts
|
||||
if (messages.length === 0) {
|
||||
setMessages([
|
||||
{
|
||||
type: "assistant",
|
||||
content: "Hello, I am Otto! Ask me anything about AutoGPT!",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to bottom whenever messages change
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim() || isProcessing) return;
|
||||
|
||||
const userMessage = inputValue.trim();
|
||||
setInputValue("");
|
||||
setIsProcessing(true);
|
||||
|
||||
// Add user message to chat
|
||||
setMessages((prev) => [...prev, { type: "user", content: userMessage }]);
|
||||
|
||||
// Add temporary processing message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ type: "assistant", content: "Processing your question..." },
|
||||
]);
|
||||
|
||||
const conversationHistory = messages.reduce<
|
||||
{ query: string; response: string }[]
|
||||
>((acc, msg, i, arr) => {
|
||||
if (
|
||||
msg.type === "user" &&
|
||||
i + 1 < arr.length &&
|
||||
arr[i + 1].type === "assistant" &&
|
||||
arr[i + 1].content !== "Processing your question..."
|
||||
) {
|
||||
acc.push({
|
||||
query: msg.content,
|
||||
response: arr[i + 1].content,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const data = await askOtto(
|
||||
userMessage,
|
||||
conversationHistory,
|
||||
includeGraphData,
|
||||
graphID,
|
||||
);
|
||||
|
||||
// Check if the response contains an error
|
||||
if ("error" in data && data.error === true) {
|
||||
// Handle different error types
|
||||
let errorMessage =
|
||||
"Sorry, there was an error processing your message. Please try again.";
|
||||
|
||||
if (data.answer === "Authentication required") {
|
||||
errorMessage = "Please sign in to use the chat feature.";
|
||||
} else if (data.answer === "Failed to connect to Otto service") {
|
||||
errorMessage =
|
||||
"Otto service is currently unavailable. Please try again later.";
|
||||
} else if (data.answer.includes("timed out")) {
|
||||
errorMessage = "Request timed out. Please try again later.";
|
||||
}
|
||||
|
||||
// Remove processing message and add error message
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ type: "assistant", content: errorMessage },
|
||||
]);
|
||||
} else {
|
||||
// Remove processing message and add actual response
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ type: "assistant", content: data.answer },
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unexpected error in chat widget:", error);
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
type: "assistant",
|
||||
content:
|
||||
"An unexpected error occurred. Please refresh the page and try again.",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setIncludeGraphData(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render the chat widget if we're not on the build page or in local mode
|
||||
if (environment.isLocal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex h-14 w-14 items-center justify-center whitespace-nowrap rounded-2xl bg-[rgba(65,65,64,1)] text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
|
||||
aria-label="Open chat widget"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-6 w-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl",
|
||||
className,
|
||||
"z-40",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="font-semibold">Otto Assistant</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.type === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${
|
||||
message.type === "user"
|
||||
? "ml-4 bg-black text-white"
|
||||
: "mr-4 bg-[#8b5cf6] text-white"
|
||||
}`}
|
||||
>
|
||||
{message.type === "user" ? (
|
||||
message.content
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
className="prose prose-sm dark:prose-invert max-w-none"
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
code(props) {
|
||||
const { children, className, node: _, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<pre className="overflow-x-auto rounded-md bg-muted-foreground/20 p-3">
|
||||
<code className="font-mono text-sm" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
) : (
|
||||
<code
|
||||
className="rounded-md bg-muted-foreground/20 px-1 py-0.5 font-mono text-sm"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-2 list-disc pl-4 last:mb-0">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-2 list-decimal pl-4 last:mb-0">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 last:mb-0">{children}</li>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSubmit} className="border-t p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{graphID && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIncludeGraphData((prev) => !prev);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm transition-all duration-200 ${
|
||||
includeGraphData
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:shadow-[0_0_10px_3px_rgba(139,92,246,0.3)]"
|
||||
: "border-transparent bg-muted text-muted-foreground hover:bg-muted/80 hover:shadow-[0_0_10px_3px_rgba(139,92,246,0.15)]"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{includeGraphData
|
||||
? "Graph data will be included"
|
||||
: "Include graph data"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { FC } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { NodeExecutionResult } from "@/lib/autogpt-server-api/types";
|
||||
import DataTable from "./DataTable";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
|
||||
interface OutputModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
executionResults: {
|
||||
execId: string;
|
||||
data: NodeExecutionResult["output_data"];
|
||||
}[];
|
||||
}
|
||||
|
||||
const OutputModalComponent: FC<OutputModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
executionResults,
|
||||
}) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nodrag nowheel fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
|
||||
<div className="w-[500px] max-w-[90%] rounded-lg border-[1.5px] bg-white p-5">
|
||||
<strong>Output Data History</strong>
|
||||
<div className="my-2 max-h-[384px] flex-grow overflow-y-auto rounded-md p-2">
|
||||
{executionResults.map((data, i) => (
|
||||
<>
|
||||
<DataTable
|
||||
key={i}
|
||||
title={data.execId}
|
||||
data={data.data}
|
||||
truncateLongData={true}
|
||||
/>
|
||||
<Separator />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2.5 flex justify-end gap-2.5">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutputModalComponent;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { AgentRunDraftView } from "@/app/(platform)/build/components/legacy-builder/agent-run-draft-view";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import type {
|
||||
CredentialsMetaInput,
|
||||
Graph,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
|
||||
interface RunInputDialogProps {
|
||||
isOpen: boolean;
|
||||
doClose: () => void;
|
||||
graph: Graph;
|
||||
doRun?: (
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => Promise<void> | void;
|
||||
doCreateSchedule?: (
|
||||
cronExpression: string,
|
||||
scheduleName: string,
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function RunnerInputDialog({
|
||||
isOpen,
|
||||
doClose,
|
||||
graph,
|
||||
doRun,
|
||||
doCreateSchedule,
|
||||
}: RunInputDialogProps) {
|
||||
const handleRun = useCallback(
|
||||
doRun
|
||||
? async (
|
||||
inputs: Record<string, any>,
|
||||
credentials_inputs: Record<string, CredentialsMetaInput>,
|
||||
) => {
|
||||
await doRun(inputs, credentials_inputs);
|
||||
doClose();
|
||||
}
|
||||
: async () => {},
|
||||
[doRun, doClose],
|
||||
);
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
doCreateSchedule
|
||||
? async (
|
||||
cronExpression: string,
|
||||
scheduleName: string,
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => {
|
||||
await doCreateSchedule(
|
||||
cronExpression,
|
||||
scheduleName,
|
||||
inputs,
|
||||
credentialsInputs,
|
||||
);
|
||||
doClose();
|
||||
}
|
||||
: async () => {},
|
||||
[doCreateSchedule, doClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Run your agent"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) doClose();
|
||||
},
|
||||
}}
|
||||
onClose={doClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col p-10">
|
||||
<p className="mt-2 text-sm text-zinc-600">{graph.name}</p>
|
||||
<AgentRunDraftView
|
||||
className="p-0"
|
||||
graph={graph}
|
||||
doRun={doRun ? handleRun : undefined}
|
||||
onRun={doRun ? undefined : doClose}
|
||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||
onCreateSchedule={doCreateSchedule ? undefined : doClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/__legacy__/ui/sheet";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Clipboard } from "lucide-react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export type OutputNodeInfo = {
|
||||
metadata: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
result?: any;
|
||||
};
|
||||
|
||||
interface OutputModalProps {
|
||||
isOpen: boolean;
|
||||
doClose: () => void;
|
||||
outputs: OutputNodeInfo[];
|
||||
graphExecutionError?: string | null;
|
||||
}
|
||||
|
||||
const formatOutput = (output: any): string => {
|
||||
if (typeof output === "object") {
|
||||
try {
|
||||
if (
|
||||
Array.isArray(output) &&
|
||||
output.every((item) => typeof item === "string")
|
||||
) {
|
||||
return output.join("\n").replace(/\\n/g, "\n");
|
||||
}
|
||||
return JSON.stringify(output, null, 2);
|
||||
} catch (error) {
|
||||
return `Error formatting output: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
if (typeof output === "string") {
|
||||
return output.replace(/\\n/g, "\n");
|
||||
}
|
||||
return String(output);
|
||||
};
|
||||
|
||||
export function RunnerOutputUI({
|
||||
isOpen,
|
||||
doClose,
|
||||
outputs,
|
||||
graphExecutionError,
|
||||
}: OutputModalProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const copyOutput = (name: string, output: any) => {
|
||||
const formattedOutput = formatOutput(output);
|
||||
navigator.clipboard.writeText(formattedOutput).then(() => {
|
||||
toast({
|
||||
title: `"${name}" output copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = (textarea: HTMLTextAreaElement) => {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={doClose}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full w-full flex-col overflow-hidden sm:max-w-[600px]"
|
||||
>
|
||||
<SheetHeader className="px-2 py-2">
|
||||
<SheetTitle className="text-xl">Run Outputs</SheetTitle>
|
||||
<SheetDescription className="mt-1 text-sm">
|
||||
View the outputs from your agent run.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-grow overflow-y-auto px-2 py-2">
|
||||
<ScrollArea className="h-full overflow-auto pr-4">
|
||||
<div className="space-y-4">
|
||||
{graphExecutionError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
<strong>Error:</strong> {graphExecutionError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{outputs && outputs.length > 0 ? (
|
||||
outputs.map((output, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<Label className="text-base font-semibold">
|
||||
{output.metadata.name || "Unnamed Output"}
|
||||
</Label>
|
||||
|
||||
{output.metadata.description && (
|
||||
<Label className="block text-sm text-gray-600">
|
||||
{output.metadata.description}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="group relative rounded-md bg-gray-100 p-2">
|
||||
<Button
|
||||
className="absolute right-1 top-1 z-10 m-1 hidden p-2 group-hover:block"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyOutput(
|
||||
output.metadata.name || "Unnamed Output",
|
||||
output.result,
|
||||
)
|
||||
}
|
||||
title="Copy Output"
|
||||
>
|
||||
<Clipboard size={18} />
|
||||
</Button>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={formatOutput(output.result ?? "No output yet")}
|
||||
className="w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent text-sm"
|
||||
style={{
|
||||
height: "auto",
|
||||
minHeight: "2.5rem",
|
||||
maxHeight: "400px",
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
adjustTextareaHeight(el);
|
||||
if (el.scrollHeight > 400) {
|
||||
el.style.height = "400px";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>No output blocks available.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default RunnerOutputUI;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, {
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Node } from "@xyflow/react";
|
||||
import { CustomNodeData } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import {
|
||||
BlockUIType,
|
||||
CredentialsMetaInput,
|
||||
Graph,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import RunnerOutputUI, { OutputNodeInfo } from "./RunnerOutputUI";
|
||||
import { RunnerInputDialog } from "./RunnerInputUI";
|
||||
|
||||
interface RunnerUIWrapperProps {
|
||||
graph: Graph;
|
||||
nodes: Node<CustomNodeData>[];
|
||||
graphExecutionError?: string | null;
|
||||
saveAndRun: (
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => void;
|
||||
createRunSchedule: (
|
||||
cronExpression: string,
|
||||
scheduleName: string,
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunnerUIWrapperRef {
|
||||
openRunInputDialog: () => void;
|
||||
openRunnerOutput: () => void;
|
||||
runOrOpenInput: () => void;
|
||||
}
|
||||
|
||||
const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
|
||||
(
|
||||
{ graph, nodes, graphExecutionError, saveAndRun, createRunSchedule },
|
||||
ref,
|
||||
) => {
|
||||
const [isRunInputDialogOpen, setIsRunInputDialogOpen] = useState(false);
|
||||
const [isRunnerOutputOpen, setIsRunnerOutputOpen] = useState(false);
|
||||
|
||||
const graphInputs = graph.input_schema.properties;
|
||||
|
||||
const graphOutputs = useMemo((): OutputNodeInfo[] => {
|
||||
const outputNodes = nodes.filter(
|
||||
(node) => node.data.uiType === BlockUIType.OUTPUT,
|
||||
);
|
||||
|
||||
return outputNodes.map(
|
||||
(node) =>
|
||||
({
|
||||
metadata: {
|
||||
name: node.data.hardcodedValues.name || "Output",
|
||||
description:
|
||||
node.data.hardcodedValues.description ||
|
||||
"Output from the agent",
|
||||
},
|
||||
result:
|
||||
(node.data.executionResults as any)
|
||||
?.map((result: any) => result?.data?.output)
|
||||
.join("\n--\n") || "No output yet",
|
||||
}) satisfies OutputNodeInfo,
|
||||
);
|
||||
}, [nodes]);
|
||||
|
||||
const openRunInputDialog = () => setIsRunInputDialogOpen(true);
|
||||
const openRunnerOutput = () => setIsRunnerOutputOpen(true);
|
||||
|
||||
const runOrOpenInput = () => {
|
||||
if (
|
||||
Object.keys(graphInputs).length > 0 ||
|
||||
Object.keys(graph.credentials_input_schema.properties).length > 0
|
||||
) {
|
||||
openRunInputDialog();
|
||||
} else {
|
||||
saveAndRun({}, {});
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() =>
|
||||
({
|
||||
openRunInputDialog,
|
||||
openRunnerOutput,
|
||||
runOrOpenInput,
|
||||
}) satisfies RunnerUIWrapperRef,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RunnerInputDialog
|
||||
isOpen={isRunInputDialogOpen}
|
||||
doClose={() => setIsRunInputDialogOpen(false)}
|
||||
graph={graph}
|
||||
doRun={saveAndRun}
|
||||
doCreateSchedule={createRunSchedule}
|
||||
/>
|
||||
<RunnerOutputUI
|
||||
isOpen={isRunnerOutputOpen}
|
||||
doClose={() => setIsRunnerOutputOpen(false)}
|
||||
outputs={graphOutputs}
|
||||
graphExecutionError={graphExecutionError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RunnerUIWrapper.displayName = "RunnerUIWrapper";
|
||||
|
||||
export default RunnerUIWrapper;
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { IconSave } from "@/components/__legacy__/ui/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getGetV2ListMySubmissionsQueryKey } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { CronExpressionDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { CalendarClockIcon } from "lucide-react";
|
||||
|
||||
interface SaveControlProps {
|
||||
agentMeta: GraphMeta | null;
|
||||
agentName: string;
|
||||
agentDescription: string;
|
||||
agentRecommendedScheduleCron: string;
|
||||
canSave: boolean;
|
||||
onSave: () => Promise<void>;
|
||||
onNameChange: (name: string) => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
onRecommendedScheduleCronChange: (cron: string) => void;
|
||||
pinSavePopover: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SaveControl component to be used within the ControlPanel. It allows the user to save the agent.
|
||||
* @param {Object} SaveControlProps - The properties of the SaveControl component.
|
||||
* @param {GraphMeta | null} SaveControlProps.agentMeta - The agent's metadata, or null if creating a new agent.
|
||||
* @param {string} SaveControlProps.agentName - The agent's name.
|
||||
* @param {string} SaveControlProps.agentDescription - The agent's description.
|
||||
* @param {boolean} SaveControlProps.canSave - Whether the button to save the agent should be enabled.
|
||||
* @param {() => void} SaveControlProps.onSave - Function to save the agent.
|
||||
* @param {(name: string) => void} SaveControlProps.onNameChange - Function to handle name changes.
|
||||
* @param {(description: string) => void} SaveControlProps.onDescriptionChange - Function to handle description changes.
|
||||
* @returns The SaveControl component.
|
||||
*/
|
||||
export const SaveControl = ({
|
||||
agentMeta,
|
||||
canSave,
|
||||
onSave,
|
||||
agentName,
|
||||
onNameChange,
|
||||
agentDescription,
|
||||
onDescriptionChange,
|
||||
agentRecommendedScheduleCron,
|
||||
onRecommendedScheduleCronChange,
|
||||
pinSavePopover,
|
||||
}: SaveControlProps) => {
|
||||
/**
|
||||
* Note for improvement:
|
||||
* At the moment we are leveraging onDescriptionChange and onNameChange to handle the changes in the description and name of the agent.
|
||||
* We should migrate this to be handled with form controls and a form library.
|
||||
*/
|
||||
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false);
|
||||
|
||||
const handleScheduleChange = (cronExpression: string) => {
|
||||
onRecommendedScheduleCronChange(cronExpression);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||
event.preventDefault(); // Stop the browser default action
|
||||
await onSave(); // Call your save function
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListMySubmissionsQueryKey(),
|
||||
});
|
||||
toast({
|
||||
duration: 2000,
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onSave, toast]);
|
||||
|
||||
return (
|
||||
<Popover open={pinSavePopover ? true : undefined}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="save-control-popover-trigger"
|
||||
data-testid="blocks-control-save-button"
|
||||
name="Save"
|
||||
>
|
||||
<IconSave className="dark:text-gray-300" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Save</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={15}
|
||||
align="start"
|
||||
data-id="save-control-popover-content"
|
||||
className="w-96 max-w-[400px]"
|
||||
>
|
||||
<Card className="border-none shadow-none dark:bg-slate-900">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="name" className="dark:text-gray-300">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Enter your agent name"
|
||||
value={agentName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
data-id="save-control-name-input"
|
||||
data-testid="save-control-name-input"
|
||||
maxLength={100}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="dark:text-gray-300">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Your agent description"
|
||||
value={agentDescription}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
data-id="save-control-description-input"
|
||||
data-testid="save-control-description-input"
|
||||
maxLength={500}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="dark:text-gray-300">
|
||||
Recommended Schedule
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCronScheduleDialogOpen(true)}
|
||||
className="mt-1 w-full min-w-0 justify-start text-sm"
|
||||
data-id="save-control-recommended-schedule-button"
|
||||
data-testid="save-control-recommended-schedule-button"
|
||||
>
|
||||
<CalendarClockIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{agentRecommendedScheduleCron
|
||||
? humanizeCronExpression(agentRecommendedScheduleCron)
|
||||
: "Set schedule"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{agentMeta?.version && (
|
||||
<div>
|
||||
<Label htmlFor="version" className="dark:text-gray-300">
|
||||
Version
|
||||
</Label>
|
||||
<Input
|
||||
id="version"
|
||||
placeholder="Version"
|
||||
value={agentMeta?.version || "-"}
|
||||
disabled
|
||||
data-testid="save-control-version-output"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||
<Button
|
||||
className="w-full dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={onSave}
|
||||
data-id="save-control-save-agent"
|
||||
data-testid="save-control-save-agent-button"
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save Agent
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
<CronExpressionDialog
|
||||
open={cronScheduleDialogOpen}
|
||||
setOpen={setCronScheduleDialogOpen}
|
||||
onSubmit={handleScheduleChange}
|
||||
defaultCronExpression={agentRecommendedScheduleCron}
|
||||
title="Recommended Schedule"
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CustomNodeData } from "./CustomNode/CustomNode";
|
||||
import { CustomEdgeData } from "./CustomEdge/CustomEdge";
|
||||
import { Edge } from "@xyflow/react";
|
||||
|
||||
type ActionType =
|
||||
| "ADD_NODE"
|
||||
| "DELETE_NODE"
|
||||
| "ADD_EDGE"
|
||||
| "DELETE_EDGE"
|
||||
| "UPDATE_NODE"
|
||||
| "MOVE_NODE"
|
||||
| "UPDATE_INPUT"
|
||||
| "UPDATE_NODE_POSITION";
|
||||
|
||||
type AddNodePayload = { node: CustomNodeData };
|
||||
type DeleteNodePayload = { nodeId: string };
|
||||
type AddEdgePayload = { edge: Edge<CustomEdgeData> };
|
||||
type DeleteEdgePayload = { edgeId: string };
|
||||
type UpdateNodePayload = { nodeId: string; newData: Partial<CustomNodeData> };
|
||||
type MoveNodePayload = { nodeId: string; position: { x: number; y: number } };
|
||||
type UpdateInputPayload = {
|
||||
nodeId: string;
|
||||
oldValues: { [key: string]: any };
|
||||
newValues: { [key: string]: any };
|
||||
};
|
||||
type UpdateNodePositionPayload = {
|
||||
nodeId: string;
|
||||
oldPosition: { x: number; y: number };
|
||||
newPosition: { x: number; y: number };
|
||||
};
|
||||
|
||||
type ActionPayload =
|
||||
| AddNodePayload
|
||||
| DeleteNodePayload
|
||||
| AddEdgePayload
|
||||
| DeleteEdgePayload
|
||||
| UpdateNodePayload
|
||||
| MoveNodePayload
|
||||
| UpdateInputPayload
|
||||
| UpdateNodePositionPayload;
|
||||
|
||||
type Action = {
|
||||
type: ActionType;
|
||||
payload: ActionPayload;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
};
|
||||
|
||||
class History {
|
||||
private past: Action[] = [];
|
||||
private future: Action[] = [];
|
||||
|
||||
push(action: Action) {
|
||||
this.past.push(action);
|
||||
this.future = [];
|
||||
}
|
||||
|
||||
undo() {
|
||||
const action = this.past.pop();
|
||||
if (action) {
|
||||
action.undo();
|
||||
this.future.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
const action = this.future.pop();
|
||||
if (action) {
|
||||
action.redo();
|
||||
this.past.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.past.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.future.length > 0;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.past = [];
|
||||
this.future = [];
|
||||
}
|
||||
|
||||
getHistoryState() {
|
||||
return {
|
||||
past: [...this.past],
|
||||
future: [...this.future],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const history = new History();
|
||||
@@ -0,0 +1,569 @@
|
||||
import Shepherd from "shepherd.js";
|
||||
import "shepherd.js/dist/css/shepherd.css";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { analytics } from "@/services/analytics";
|
||||
|
||||
export const startTutorial = (
|
||||
emptyNodeList: (forceEmpty: boolean) => boolean,
|
||||
setPinBlocksPopover: (value: boolean) => void,
|
||||
setPinSavePopover: (value: boolean) => void,
|
||||
) => {
|
||||
const tour = new Shepherd.Tour({
|
||||
useModalOverlay: true,
|
||||
defaultStepOptions: {
|
||||
cancelIcon: { enabled: true },
|
||||
scrollTo: { behavior: "smooth", block: "center" },
|
||||
},
|
||||
});
|
||||
|
||||
// CSS classes for disabling and highlighting blocks
|
||||
const disableClass = "disable-blocks";
|
||||
const highlightClass = "highlight-block";
|
||||
let isConnecting = false;
|
||||
|
||||
// Helper function to disable all blocks except the target block
|
||||
const disableOtherBlocks = (targetBlockSelector: string) => {
|
||||
document.querySelectorAll('[data-id^="block-card-"]').forEach((block) => {
|
||||
block.classList.toggle(disableClass, !block.matches(targetBlockSelector));
|
||||
block.classList.toggle(
|
||||
highlightClass,
|
||||
block.matches(targetBlockSelector),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to enable all blocks
|
||||
const enableAllBlocks = () => {
|
||||
document.querySelectorAll('[data-id^="block-card-"]').forEach((block) => {
|
||||
block.classList.remove(disableClass, highlightClass);
|
||||
});
|
||||
};
|
||||
|
||||
// Inject CSS for disabling and highlighting blocks
|
||||
const injectStyles = () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.${disableClass} {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.${highlightClass} {
|
||||
background-color: #ffeb3b;
|
||||
border: 2px solid #fbc02d;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// Helper function to check if an element is present in the DOM
|
||||
const waitForElement = (selector: string): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
const checkElement = () => {
|
||||
if (document.querySelector(selector)) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkElement, 10);
|
||||
}
|
||||
};
|
||||
checkElement();
|
||||
});
|
||||
};
|
||||
|
||||
// Function to detect the correct connection and advance the tour
|
||||
const detectConnection = () => {
|
||||
const checkForConnection = () => {
|
||||
const correctConnection = document.querySelector(
|
||||
'[data-testid^="rf__edge-"]',
|
||||
);
|
||||
if (correctConnection) {
|
||||
tour.show("press-run-again");
|
||||
} else {
|
||||
setTimeout(checkForConnection, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkForConnection();
|
||||
};
|
||||
|
||||
// Define state management functions to handle connection state
|
||||
function startConnecting() {
|
||||
isConnecting = true;
|
||||
}
|
||||
|
||||
function stopConnecting() {
|
||||
isConnecting = false;
|
||||
}
|
||||
|
||||
// Reset connection state when revisiting the step
|
||||
function resetConnectionState() {
|
||||
stopConnecting();
|
||||
}
|
||||
|
||||
// Event handlers for mouse down and up to manage connection state
|
||||
function handleMouseDown() {
|
||||
startConnecting();
|
||||
setTimeout(() => {
|
||||
if (isConnecting) {
|
||||
tour.next();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
// Event handler for mouse up to check if the connection was successful
|
||||
function handleMouseUp(event: { target: any }) {
|
||||
const target = event.target;
|
||||
const validConnectionPoint = document.querySelector(
|
||||
'[data-testid^="rf__node-"]:nth-child(2) [data-id$="-a-target"]',
|
||||
);
|
||||
|
||||
if (validConnectionPoint && !validConnectionPoint.contains(target)) {
|
||||
setTimeout(() => {
|
||||
if (!document.querySelector('[data-testid^="rf__edge-"]')) {
|
||||
stopConnecting();
|
||||
tour.show("connect-blocks-output");
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
stopConnecting();
|
||||
}
|
||||
}
|
||||
|
||||
// Define the fitViewToScreen function
|
||||
const fitViewToScreen = () => {
|
||||
const fitViewButton = document.querySelector(
|
||||
".react-flow__controls-fitview",
|
||||
) as HTMLButtonElement;
|
||||
if (fitViewButton) {
|
||||
fitViewButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
injectStyles();
|
||||
|
||||
const warningText = emptyNodeList(false)
|
||||
? ""
|
||||
: "<br/><br/><b>Caution: Clicking next will start a tutorial and will clear the current flow.</b>";
|
||||
|
||||
tour.addStep({
|
||||
id: "starting-step",
|
||||
title: "Welcome to the Tutorial",
|
||||
text: `This is the AutoGPT builder! ${warningText}`,
|
||||
buttons: [
|
||||
{
|
||||
text: "Skip Tutorial",
|
||||
action: () => {
|
||||
tour.cancel(); // Ends the tour
|
||||
storage.set(Key.SHEPHERD_TOUR, "skipped"); // Set the tutorial as skipped in local storage
|
||||
},
|
||||
classes: "shepherd-button-secondary", // Optionally add a class for styling the skip button differently
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: () => {
|
||||
emptyNodeList(true);
|
||||
tour.next();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "open-block-step",
|
||||
title: "Open Blocks Menu",
|
||||
text: "Please click the block button to open the blocks menu.",
|
||||
attachTo: {
|
||||
element: '[data-id="blocks-control-popover-trigger"]',
|
||||
on: "right",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-id="blocks-control-popover-trigger"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "scroll-block-menu",
|
||||
title: "Scroll Down or Search",
|
||||
text: 'Scroll down or search in the blocks menu for the "Calculator Block" and press the block to add it.',
|
||||
attachTo: {
|
||||
element: '[data-id="blocks-control-popover-content"]',
|
||||
on: "right",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-id="blocks-control-popover-content"]').then(() => {
|
||||
disableOtherBlocks(
|
||||
'[data-id="block-card-b1ab9b19-67a6-406d-abf5-2dba76d00c79"]',
|
||||
);
|
||||
}),
|
||||
advanceOn: {
|
||||
selector: '[data-id="block-card-b1ab9b19-67a6-406d-abf5-2dba76d00c79"]',
|
||||
event: "click",
|
||||
},
|
||||
when: {
|
||||
show: () => setPinBlocksPopover(true),
|
||||
hide: enableAllBlocks,
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "focus-new-block",
|
||||
title: "New Block",
|
||||
text: "This is the Calculator Block! Let's go over how it works.",
|
||||
attachTo: { element: `[data-id="custom-node-1"]`, on: "left" },
|
||||
beforeShowPromise: () => waitForElement('[data-id="custom-node-1"]'),
|
||||
buttons: [
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
show: () => {
|
||||
setPinBlocksPopover(false);
|
||||
setTimeout(() => {
|
||||
fitViewToScreen();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "input-to-block",
|
||||
title: "Input to the Block",
|
||||
text: "This is the input pin for the block. You can input the output of other blocks here; this block takes numbers as input.",
|
||||
attachTo: { element: '[data-nodeid="1"]', on: "left" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "output-from-block",
|
||||
title: "Output from the Block",
|
||||
text: "This is the output pin for the block. You can connect this to another block to pass the output along.",
|
||||
attachTo: { element: '[data-handlepos="right"]', on: "right" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "select-operation-and-input",
|
||||
title: "Select Operation and Input Numbers",
|
||||
text: "Select any mathematical operation you'd like to perform, and enter numbers in both input fields.",
|
||||
attachTo: { element: '[data-id="input-handles"]', on: "right" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "press-initial-save-button",
|
||||
title: "Press Save",
|
||||
text: "First we need to save the flow before we can run it!",
|
||||
attachTo: {
|
||||
element: '[data-id="save-control-popover-trigger"]',
|
||||
on: "left",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-id="save-control-popover-trigger"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
hide: () => setPinSavePopover(true),
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "save-agent-details",
|
||||
title: "Save the Agent",
|
||||
text: "Enter a name for your agent, add an optional description, and then click 'Save agent' to save your flow.",
|
||||
attachTo: {
|
||||
element: '[data-id="save-control-popover-content"]',
|
||||
on: "top",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-id="save-control-popover-content"]'),
|
||||
advanceOn: {
|
||||
selector: '[data-id="save-control-save-agent"]',
|
||||
event: "click",
|
||||
},
|
||||
when: {
|
||||
hide: () => setPinSavePopover(false),
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "press-run",
|
||||
title: "Press Run",
|
||||
text: "Start your first flow by pressing the Run button!",
|
||||
attachTo: {
|
||||
element: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
on: "top",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
|
||||
when: {
|
||||
hide: () => {
|
||||
setTimeout(() => {
|
||||
fitViewToScreen();
|
||||
}, 500);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "wait-for-processing",
|
||||
title: "Processing",
|
||||
text: "Let's wait for the block to finish being processed...",
|
||||
attachTo: {
|
||||
element: '[data-id^="badge-"][data-id$="-QUEUED"]',
|
||||
on: "bottom",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-id^="badge-"][data-id$="-QUEUED"]').then(
|
||||
fitViewToScreen,
|
||||
),
|
||||
when: {
|
||||
show: () => {
|
||||
waitForElement('[data-id^="badge-"][data-id$="-COMPLETED"]').then(
|
||||
() => {
|
||||
tour.next();
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "check-output",
|
||||
title: "Check the Output",
|
||||
text: "Check here to see the output of the block after running the flow.",
|
||||
attachTo: { element: '[data-id="latest-output"]', on: "top" },
|
||||
beforeShowPromise: () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
waitForElement('[data-id="latest-output"]').then(resolve);
|
||||
}, 100);
|
||||
}),
|
||||
buttons: [
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
show: () => {
|
||||
fitViewToScreen();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "copy-paste-block",
|
||||
title: "Copy and Paste the Block",
|
||||
text: "Let’s duplicate this block. Click and hold the block with your mouse, then press Ctrl+C (Cmd+C on Mac) to copy and Ctrl+V (Cmd+V on Mac) to paste.",
|
||||
attachTo: { element: '[data-testid^="rf__node-"]', on: "top" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
show: () => {
|
||||
fitViewToScreen();
|
||||
waitForElement('[data-testid^="rf__node-"]:nth-child(2)').then(() => {
|
||||
tour.next();
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "focus-second-block",
|
||||
title: "Focus on the New Block",
|
||||
text: "This is your copied Calculator Block. Now, let’s move it to the side of the first block.",
|
||||
attachTo: { element: '[data-testid^="rf__node-"]:nth-child(2)', on: "top" },
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-testid^="rf__node-"]:nth-child(2)'),
|
||||
buttons: [
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "connect-blocks-output",
|
||||
title: "Connect the Blocks: Output",
|
||||
text: "Now, let's connect the output of the first Calculator Block to the input of the second Calculator Block. Drag from the output pin of the first block to the input pin (A) of the second block.",
|
||||
attachTo: {
|
||||
element:
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
on: "bottom",
|
||||
},
|
||||
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
],
|
||||
beforeShowPromise: () => {
|
||||
return waitForElement(
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
);
|
||||
},
|
||||
when: {
|
||||
show: () => {
|
||||
fitViewToScreen();
|
||||
resetConnectionState(); // Reset state when revisiting this step
|
||||
tour.modal.show();
|
||||
const outputPin = document.querySelector(
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
);
|
||||
if (outputPin) {
|
||||
outputPin.addEventListener("mousedown", handleMouseDown);
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
const outputPin = document.querySelector(
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
);
|
||||
if (outputPin) {
|
||||
outputPin.removeEventListener("mousedown", handleMouseDown);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "connect-blocks-input",
|
||||
title: "Connect the Blocks: Input",
|
||||
text: "Now, connect the output to the input pin of the second block (A).",
|
||||
attachTo: {
|
||||
element: '[data-testid^="rf__node-"]:nth-child(2) [data-id$="-a-target"]',
|
||||
on: "top",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () => {
|
||||
return waitForElement(
|
||||
'[data-testid^="rf__node-"]:nth-child(2) [data-id$="-a-target"]',
|
||||
).then(() => {
|
||||
detectConnection();
|
||||
});
|
||||
},
|
||||
when: {
|
||||
show: () => {
|
||||
tour.modal.show();
|
||||
document.addEventListener("mouseup", handleMouseUp, true);
|
||||
},
|
||||
hide: () => {
|
||||
tour.modal.hide();
|
||||
document.removeEventListener("mouseup", handleMouseUp, true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "press-run-again",
|
||||
title: "Press Run Again",
|
||||
text: "Now, press the Run button again to execute the flow with the new Calculator Block added!",
|
||||
attachTo: {
|
||||
element: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
on: "top",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
|
||||
when: {
|
||||
hide: () => {
|
||||
setTimeout(() => {
|
||||
fitViewToScreen();
|
||||
}, 500);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "congratulations",
|
||||
title: "Congratulations!",
|
||||
text: "You have successfully created your first flow. Watch for the outputs in the blocks!",
|
||||
beforeShowPromise: () => waitForElement('[data-id="latest-output"]'),
|
||||
when: {
|
||||
show: () => tour.modal.hide(),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: "Finish",
|
||||
action: tour.complete,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Unpin blocks and save menu when the tour is completed or canceled
|
||||
tour.on("complete", () => {
|
||||
setPinBlocksPopover(false);
|
||||
setPinSavePopover(false);
|
||||
storage.set(Key.SHEPHERD_TOUR, "completed"); // Optionally mark the tutorial as completed
|
||||
});
|
||||
|
||||
for (const step of tour.steps) {
|
||||
step.on("show", () => {
|
||||
"use client";
|
||||
console.debug("sendTutorialStep");
|
||||
|
||||
analytics.sendGAEvent("event", "tutorial_step_shown", { value: step.id });
|
||||
});
|
||||
}
|
||||
|
||||
tour.on("cancel", () => {
|
||||
setPinBlocksPopover(false);
|
||||
setPinSavePopover(false);
|
||||
storage.set(Key.SHEPHERD_TOUR, "canceled"); // Optionally mark the tutorial as canceled
|
||||
});
|
||||
|
||||
tour.start();
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback } from "react";
|
||||
import { Node, Edge, useReactFlow } from "@xyflow/react";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||
|
||||
interface CopyableData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export function useCopyPaste(getNextNodeId: () => string) {
|
||||
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
|
||||
useReactFlow();
|
||||
|
||||
const handleCopyPaste = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === "c" || event.key === "C") {
|
||||
const selectedNodes = getNodes().filter((node) => node.selected);
|
||||
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
|
||||
|
||||
// Only copy edges where both source and target nodes are selected
|
||||
const selectedEdges = getEdges().filter(
|
||||
(edge) =>
|
||||
edge.selected &&
|
||||
selectedNodeIds.has(edge.source) &&
|
||||
selectedNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
const copiedData: CopyableData = {
|
||||
nodes: selectedNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
connections: node.data.connections || [], // Preserve connections
|
||||
},
|
||||
})),
|
||||
edges: selectedEdges,
|
||||
};
|
||||
|
||||
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
|
||||
}
|
||||
if (event.key === "v" || event.key === "V") {
|
||||
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
|
||||
if (copiedDataString) {
|
||||
const copiedData = JSON.parse(copiedDataString) as CopyableData;
|
||||
const oldToNewIdMap: Record<string, string> = {};
|
||||
|
||||
// Get fresh viewport values at paste time to ensure correct positioning
|
||||
const { x, y, zoom } = getViewport();
|
||||
const viewportCenter = {
|
||||
x: (window.innerWidth / 2 - x) / zoom,
|
||||
y: (window.innerHeight / 2 - y) / zoom,
|
||||
};
|
||||
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
copiedData.nodes.forEach((node: Node) => {
|
||||
minX = Math.min(minX, node.position.x);
|
||||
minY = Math.min(minY, node.position.y);
|
||||
maxX = Math.max(maxX, node.position.x);
|
||||
maxY = Math.max(maxY, node.position.y);
|
||||
});
|
||||
|
||||
const offsetX = viewportCenter.x - (minX + maxX) / 2;
|
||||
const offsetY = viewportCenter.y - (minY + maxY) / 2;
|
||||
|
||||
const pastedNodes = copiedData.nodes.map((node: Node) => {
|
||||
const newNodeId = getNextNodeId();
|
||||
oldToNewIdMap[node.id] = newNodeId;
|
||||
return {
|
||||
...node,
|
||||
id: newNodeId, // Generate unique ID for the pasted node
|
||||
selected: true, // Select the pasted nodes so they're visible
|
||||
position: {
|
||||
x: node.position.x + offsetX,
|
||||
y: node.position.y + offsetY,
|
||||
},
|
||||
data: {
|
||||
...node.data,
|
||||
backend_id: undefined, // Clear backend_id so the new node.id is used when saving
|
||||
connections: node.data.connections || [], // Preserve connections
|
||||
status: undefined,
|
||||
executionResults: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const pastedEdges = copiedData.edges.map((edge) => {
|
||||
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
|
||||
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
|
||||
return {
|
||||
...edge,
|
||||
id: `${newSourceId}_${edge.sourceHandle}_${newTargetId}_${edge.targetHandle}_${Date.now()}`,
|
||||
source: newSourceId,
|
||||
target: newTargetId,
|
||||
};
|
||||
});
|
||||
|
||||
setNodes((existingNodes) => [
|
||||
...existingNodes.map((node) => ({ ...node, selected: false })),
|
||||
...pastedNodes,
|
||||
]);
|
||||
addEdges(pastedEdges);
|
||||
|
||||
setNodes((nodes) => {
|
||||
return nodes.map((node) => {
|
||||
const nodeConnections = getEdges()
|
||||
.filter(
|
||||
(edge: Edge) =>
|
||||
edge.source === node.id || edge.target === node.id,
|
||||
)
|
||||
.map(
|
||||
(edge: Edge): ConnectedEdge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle!,
|
||||
targetHandle: edge.targetHandle!,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
connections: nodeConnections,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setNodes, addEdges, getNodes, getEdges, getNextNodeId, getViewport],
|
||||
);
|
||||
|
||||
return handleCopyPaste;
|
||||
}
|
||||
@@ -58,7 +58,6 @@ function toToolInput(rawArguments: unknown): unknown {
|
||||
export function convertChatSessionMessagesToUiMessages(
|
||||
sessionId: string,
|
||||
rawMessages: unknown[],
|
||||
options?: { isComplete?: boolean },
|
||||
): UIMessage<unknown, UIDataTypes, UITools>[] {
|
||||
const messages = coerceSessionChatMessages(rawMessages);
|
||||
const toolOutputsByCallId = new Map<string, unknown>();
|
||||
@@ -105,16 +104,6 @@ export function convertChatSessionMessagesToUiMessages(
|
||||
input,
|
||||
output: typeof output === "string" ? safeJsonParse(output) : output,
|
||||
});
|
||||
} else if (options?.isComplete) {
|
||||
// Session is complete (no active stream) but this tool call has
|
||||
// no output in the DB — mark as completed to stop stale spinners.
|
||||
parts.push({
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId,
|
||||
state: "output-available",
|
||||
input,
|
||||
output: "",
|
||||
});
|
||||
} else {
|
||||
parts.push({
|
||||
type: `tool-${toolName}`,
|
||||
|
||||
@@ -11,11 +11,6 @@ import {
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
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 { CreateAgentTool } from "../tools/CreateAgent/CreateAgent";
|
||||
import { EditAgentTool } from "../tools/EditAgent/EditAgent";
|
||||
@@ -102,65 +97,6 @@ function uid() {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -618,80 +554,45 @@ export default function StyleguidePage() {
|
||||
/>
|
||||
</SubSection>
|
||||
|
||||
<SubSection label="Setup requirements — no credentials (add new)">
|
||||
<CredentialsProvidersContext.Provider
|
||||
value={MOCK_PROVIDERS_WITHOUT_CREDENTIALS}
|
||||
>
|
||||
<RunBlockTool
|
||||
part={{
|
||||
type: "tool-run_block",
|
||||
toolCallId: uid(),
|
||||
state: "output-available",
|
||||
input: { block_id: "weather-block-123" },
|
||||
output: {
|
||||
type: ResponseType.setup_requirements,
|
||||
message:
|
||||
"This block requires API credentials to run. Please configure them below.",
|
||||
setup_info: {
|
||||
agent_id: "agent-weather-1",
|
||||
agent_name: "Weather Agent",
|
||||
requirements: {
|
||||
inputs: [
|
||||
{
|
||||
name: "city",
|
||||
title: "City",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "The city to get weather for",
|
||||
},
|
||||
],
|
||||
},
|
||||
user_readiness: {
|
||||
missing_credentials: {
|
||||
openweathermap_key: {
|
||||
provider: "openweathermap",
|
||||
types: ["api_key"],
|
||||
},
|
||||
<SubSection label="Output available (setup requirements)">
|
||||
<RunBlockTool
|
||||
part={{
|
||||
type: "tool-run_block",
|
||||
toolCallId: uid(),
|
||||
state: "output-available",
|
||||
input: { block_id: "weather-block-123" },
|
||||
output: {
|
||||
type: ResponseType.setup_requirements,
|
||||
message:
|
||||
"This block requires API credentials to run. Please configure them below.",
|
||||
setup_info: {
|
||||
agent_name: "Weather Agent",
|
||||
requirements: {
|
||||
inputs: [
|
||||
{
|
||||
name: "city",
|
||||
title: "City",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "The city to get weather for",
|
||||
},
|
||||
],
|
||||
},
|
||||
user_readiness: {
|
||||
missing_credentials: {
|
||||
openweathermap: {
|
||||
provider: "openweathermap",
|
||||
credentials_type: "api_key",
|
||||
title: "OpenWeatherMap API Key",
|
||||
description:
|
||||
"Required to access weather data. Get your key at openweathermap.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</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 label="Output available (error)">
|
||||
@@ -948,71 +849,34 @@ export default function StyleguidePage() {
|
||||
/>
|
||||
</SubSection>
|
||||
|
||||
<SubSection label="Setup requirements — no credentials (add new)">
|
||||
<CredentialsProvidersContext.Provider
|
||||
value={MOCK_PROVIDERS_WITHOUT_CREDENTIALS}
|
||||
>
|
||||
<RunAgentTool
|
||||
part={{
|
||||
type: "tool-run_agent",
|
||||
toolCallId: uid(),
|
||||
state: "output-available",
|
||||
input: { username_agent_slug: "creator/weather-agent" },
|
||||
output: {
|
||||
type: ResponseType.setup_requirements,
|
||||
message:
|
||||
"This agent requires an API key. Add your credentials below.",
|
||||
setup_info: {
|
||||
agent_id: "agent-weather-1",
|
||||
agent_name: "Weather Agent",
|
||||
requirements: {},
|
||||
user_readiness: {
|
||||
missing_credentials: {
|
||||
openweathermap_key: {
|
||||
provider: "openweathermap",
|
||||
types: ["api_key"],
|
||||
},
|
||||
<SubSection label="Output available (setup requirements)">
|
||||
<RunAgentTool
|
||||
part={{
|
||||
type: "tool-run_agent",
|
||||
toolCallId: uid(),
|
||||
state: "output-available",
|
||||
input: { username_agent_slug: "creator/my-agent" },
|
||||
output: {
|
||||
type: ResponseType.setup_requirements,
|
||||
message: "This agent requires additional setup.",
|
||||
setup_info: {
|
||||
agent_name: "YouTube Summarizer",
|
||||
requirements: {},
|
||||
user_readiness: {
|
||||
missing_credentials: {
|
||||
youtube_api: {
|
||||
provider: "youtube",
|
||||
credentials_type: "api_key",
|
||||
title: "YouTube Data API Key",
|
||||
description:
|
||||
"Required to access YouTube video data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</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 label="Output available (need login)">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ContentCardDescription,
|
||||
ContentCodeBlock,
|
||||
ContentGrid,
|
||||
ContentHint,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
@@ -23,8 +24,8 @@ import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
} from "./components/ClarificationQuestionsCard";
|
||||
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
|
||||
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||
import sparklesImg from "./components/MiniGame/assets/sparkles.png";
|
||||
import { MiniGame } from "./components/MiniGame/MiniGame";
|
||||
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
|
||||
import {
|
||||
AccordionIcon,
|
||||
@@ -92,7 +93,9 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
|
||||
) {
|
||||
return {
|
||||
icon,
|
||||
title: output.message || "Agent creation started",
|
||||
title:
|
||||
"Creating agent, this may take a few minutes. Play while you wait.",
|
||||
expanded: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -166,22 +169,15 @@ export function CreateAgentTool({ part }: Props) {
|
||||
/>
|
||||
</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 && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && output.message && (
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
{isAgentSavedOutput(output) && (
|
||||
|
||||
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -4,15 +4,17 @@ import { WarningDiamondIcon } from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import {
|
||||
ContentCardDescription,
|
||||
ContentCodeBlock,
|
||||
ContentGrid,
|
||||
ContentHint,
|
||||
ContentLink,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
|
||||
import {
|
||||
ClarificationQuestionsCard,
|
||||
ClarifyingQuestion,
|
||||
@@ -79,8 +81,9 @@ function getAccordionMeta(output: EditAgentToolOutput): {
|
||||
isOperationInProgressOutput(output)
|
||||
) {
|
||||
return {
|
||||
icon,
|
||||
title: output.message || "Agent editing started",
|
||||
icon: <OrbitLoader size={32} />,
|
||||
title: "Editing agent, this may take a few minutes. Play while you wait.",
|
||||
expanded: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -145,22 +148,15 @@ export function EditAgentTool({ part }: Props) {
|
||||
/>
|
||||
</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 && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isOperating && output.message && (
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
{isOperating && (
|
||||
<ContentGrid>
|
||||
<MiniGame />
|
||||
<ContentHint>
|
||||
This could take a few minutes — play while you wait!
|
||||
</ContentHint>
|
||||
</ContentGrid>
|
||||
)}
|
||||
|
||||
{isAgentSavedOutput(output) && (
|
||||
|
||||
@@ -704,7 +704,6 @@ export function GenericTool({ part }: Props) {
|
||||
title={accordionData.title}
|
||||
description={accordionData.description}
|
||||
titleClassName={isError ? "text-red-500" : undefined}
|
||||
defaultExpanded={category === "todo"}
|
||||
>
|
||||
{accordionData.content}
|
||||
</ToolAccordion>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ContentHint,
|
||||
ContentMessage,
|
||||
} from "../../components/ToolAccordion/AccordionContent";
|
||||
import { MiniGame } from "../../components/MiniGame/MiniGame";
|
||||
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
|
||||
import {
|
||||
getAccordionMeta,
|
||||
getAnimationText,
|
||||
@@ -47,25 +47,14 @@ export function RunAgentTool({ part }: Props) {
|
||||
const isError =
|
||||
part.state === "output-error" ||
|
||||
(!!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 =
|
||||
isOutputAvailable &&
|
||||
!setupRequirementsOutput &&
|
||||
!agentDetailsOutput &&
|
||||
!needLoginOutput &&
|
||||
(isRunAgentExecutionStartedOutput(output) || isRunAgentErrorOutput(output));
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
(isRunAgentExecutionStartedOutput(output) ||
|
||||
isRunAgentAgentDetailsOutput(output) ||
|
||||
isRunAgentSetupRequirementsOutput(output) ||
|
||||
isRunAgentNeedLoginOutput(output) ||
|
||||
isRunAgentErrorOutput(output));
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
@@ -92,30 +81,24 @@ export function RunAgentTool({ part }: Props) {
|
||||
</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 && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isRunAgentExecutionStartedOutput(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} />}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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 { 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 { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import {
|
||||
ContentBadge,
|
||||
@@ -39,40 +38,40 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
setInputCredentials((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const needsCredentials = credentialFields.length > 0;
|
||||
const isAllCredentialsComplete =
|
||||
needsCredentials &&
|
||||
const isAllComplete =
|
||||
credentialFields.length > 0 &&
|
||||
[...requiredCredentials].every((key) => !!inputCredentials[key]);
|
||||
|
||||
const canProceed =
|
||||
!hasSent && (!needsCredentials || isAllCredentialsComplete);
|
||||
|
||||
function handleProceed() {
|
||||
setHasSent(true);
|
||||
const message = needsCredentials
|
||||
? "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);
|
||||
onSend(
|
||||
"I've configured the required credentials. Please check if everything is ready and proceed with running the agent.",
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
|
||||
{needsCredentials && (
|
||||
{credentialFields.length > 0 && (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<Text variant="small" className="w-fit border-b text-zinc-500">
|
||||
Agent credentials
|
||||
</Text>
|
||||
<div className="mt-6">
|
||||
<CredentialsGroupedView
|
||||
credentialFields={credentialFields}
|
||||
requiredCredentials={requiredCredentials}
|
||||
inputCredentials={inputCredentials}
|
||||
inputValues={{}}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
</div>
|
||||
<CredentialsGroupedView
|
||||
credentialFields={credentialFields}
|
||||
requiredCredentials={requiredCredentials}
|
||||
inputCredentials={inputCredentials}
|
||||
inputValues={{}}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
{isAllComplete && !hasSent && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="mt-3 w-full"
|
||||
onClick={handleProceed}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -101,18 +100,6 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(needsCredentials || expectedInputs.length > 0) && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="mt-4 w-fit"
|
||||
disabled={!canProceed}
|
||||
onClick={handleProceed}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,19 +39,12 @@ export function RunBlockTool({ part }: Props) {
|
||||
const isError =
|
||||
part.state === "output-error" ||
|
||||
(!!output && isRunBlockErrorOutput(output));
|
||||
const setupRequirementsOutput =
|
||||
part.state === "output-available" &&
|
||||
output &&
|
||||
isRunBlockSetupRequirementsOutput(output)
|
||||
? output
|
||||
: null;
|
||||
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
!setupRequirementsOutput &&
|
||||
(isRunBlockBlockOutput(output) ||
|
||||
isRunBlockDetailsOutput(output) ||
|
||||
isRunBlockSetupRequirementsOutput(output) ||
|
||||
isRunBlockErrorOutput(output));
|
||||
|
||||
return (
|
||||
@@ -64,12 +57,6 @@ export function RunBlockTool({ part }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{setupRequirementsOutput && (
|
||||
<div className="mt-2">
|
||||
<SetupRequirementsCard output={setupRequirementsOutput} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isRunBlockBlockOutput(output) && <BlockOutputCard output={output} />}
|
||||
@@ -78,6 +65,10 @@ export function RunBlockTool({ part }: Props) {
|
||||
<BlockDetailsCard output={output} />
|
||||
)}
|
||||
|
||||
{isRunBlockSetupRequirementsOutput(output) && (
|
||||
<SetupRequirementsCard output={output} />
|
||||
)}
|
||||
|
||||
{isRunBlockErrorOutput(output) && <ErrorCard output={output} />}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,15 @@ import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
|
||||
import {
|
||||
ContentBadge,
|
||||
ContentCardDescription,
|
||||
ContentCardTitle,
|
||||
ContentMessage,
|
||||
} from "../../../../components/ToolAccordion/AccordionContent";
|
||||
import {
|
||||
buildExpectedInputsSchema,
|
||||
coerceCredentialFields,
|
||||
@@ -25,8 +31,10 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
const [inputCredentials, setInputCredentials] = useState<
|
||||
Record<string, CredentialsMetaInput | undefined>
|
||||
>({});
|
||||
const [hasSentCredentials, setHasSentCredentials] = useState(false);
|
||||
|
||||
const [showInputForm, setShowInputForm] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
|
||||
const [hasSent, setHasSent] = useState(false);
|
||||
|
||||
const { credentialFields, requiredCredentials } = coerceCredentialFields(
|
||||
output.setup_info.user_readiness?.missing_credentials,
|
||||
@@ -42,49 +50,27 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
setInputCredentials((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const needsCredentials = credentialFields.length > 0;
|
||||
const isAllCredentialsComplete =
|
||||
needsCredentials &&
|
||||
credentialFields.length > 0 &&
|
||||
[...requiredCredentials].every((key) => !!inputCredentials[key]);
|
||||
|
||||
const needsInputs = inputSchema !== null;
|
||||
const requiredInputNames = expectedInputs
|
||||
.filter((i) => i.required)
|
||||
.map((i) => i.name);
|
||||
const isAllInputsComplete =
|
||||
needsInputs &&
|
||||
requiredInputNames.every((name) => {
|
||||
const v = inputValues[name];
|
||||
return v !== undefined && v !== null && v !== "";
|
||||
});
|
||||
function handleProceedCredentials() {
|
||||
setHasSentCredentials(true);
|
||||
onSend(
|
||||
"I've configured the required credentials. Please re-run the block now.",
|
||||
);
|
||||
}
|
||||
|
||||
const canRun =
|
||||
!hasSent &&
|
||||
(!needsCredentials || isAllCredentialsComplete) &&
|
||||
(!needsInputs || isAllInputsComplete);
|
||||
|
||||
function handleRun() {
|
||||
setHasSent(true);
|
||||
|
||||
const parts: string[] = [];
|
||||
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(" "));
|
||||
function handleRunWithInputs() {
|
||||
const nonEmpty = Object.fromEntries(
|
||||
Object.entries(inputValues).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== "",
|
||||
),
|
||||
);
|
||||
onSend(
|
||||
`Run the block with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
|
||||
);
|
||||
setShowInputForm(false);
|
||||
setInputValues({});
|
||||
}
|
||||
|
||||
@@ -92,54 +78,119 @@ export function SetupRequirementsCard({ output }: Props) {
|
||||
<div className="grid gap-2">
|
||||
<ContentMessage>{output.message}</ContentMessage>
|
||||
|
||||
{needsCredentials && (
|
||||
{credentialFields.length > 0 && (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<Text variant="small" className="w-fit border-b text-zinc-500">
|
||||
Block credentials
|
||||
</Text>
|
||||
<div className="mt-6">
|
||||
<CredentialsGroupedView
|
||||
credentialFields={credentialFields}
|
||||
requiredCredentials={requiredCredentials}
|
||||
inputCredentials={inputCredentials}
|
||||
inputValues={{}}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
</div>
|
||||
<CredentialsGroupedView
|
||||
credentialFields={credentialFields}
|
||||
requiredCredentials={requiredCredentials}
|
||||
inputCredentials={inputCredentials}
|
||||
inputValues={{}}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
/>
|
||||
{isAllCredentialsComplete && !hasSentCredentials && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="mt-3 w-full"
|
||||
onClick={handleProceedCredentials}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputSchema && (
|
||||
<div className="rounded-2xl border bg-background p-3 pt-4">
|
||||
<Text variant="small" className="w-fit border-b text-zinc-500">
|
||||
Block inputs
|
||||
</Text>
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema}
|
||||
className="mb-3 mt-3"
|
||||
handleChange={(v) => setInputValues(v.formData ?? {})}
|
||||
uiSchema={{
|
||||
"ui:submitButtonOptions": { norender: true },
|
||||
}}
|
||||
initialValues={inputValues}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "small",
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
onClick={() => setShowInputForm((prev) => !prev)}
|
||||
>
|
||||
{showInputForm ? "Hide inputs" : "Fill in inputs"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(needsCredentials || needsInputs) && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
className="w-fit"
|
||||
disabled={!canRun}
|
||||
onClick={handleRun}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
<AnimatePresence initial={false}>
|
||||
{showInputForm && inputSchema && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
|
||||
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
|
||||
transition={{
|
||||
height: { type: "spring", bounce: 0.15, duration: 0.5 },
|
||||
opacity: { duration: 0.25 },
|
||||
filter: { duration: 0.2 },
|
||||
}}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,16 @@ export function useChatSession() {
|
||||
}
|
||||
}, [sessionId, queryClient]);
|
||||
|
||||
// Memoize so the effect in useCopilotPage doesn't infinite-loop on a new
|
||||
// array reference every render. Re-derives only when query data changes.
|
||||
const hydratedMessages = useMemo(() => {
|
||||
if (sessionQuery.data?.status !== 200 || !sessionId) return undefined;
|
||||
return convertChatSessionMessagesToUiMessages(
|
||||
sessionId,
|
||||
sessionQuery.data.data.messages ?? [],
|
||||
);
|
||||
}, [sessionQuery.data, sessionId]);
|
||||
|
||||
// Expose active_stream info so the caller can trigger manual resume
|
||||
// after hydration completes (rather than relying on AI SDK's built-in
|
||||
// resume which fires before hydration).
|
||||
@@ -48,19 +58,6 @@ export function useChatSession() {
|
||||
return !!sessionQuery.data.data.active_stream;
|
||||
}, [sessionQuery.data]);
|
||||
|
||||
// Memoize so the effect in useCopilotPage doesn't infinite-loop on a new
|
||||
// array reference every render. Re-derives only when query data changes.
|
||||
// When the session is complete (no active stream), mark dangling tool
|
||||
// calls as completed so stale spinners don't persist after refresh.
|
||||
const hydratedMessages = useMemo(() => {
|
||||
if (sessionQuery.data?.status !== 200 || !sessionId) return undefined;
|
||||
return convertChatSessionMessagesToUiMessages(
|
||||
sessionId,
|
||||
sessionQuery.data.data.messages ?? [],
|
||||
{ isComplete: !hasActiveStream },
|
||||
);
|
||||
}, [sessionQuery.data, sessionId, hasActiveStream]);
|
||||
|
||||
const { mutateAsync: createSessionMutation, isPending: isCreatingSession } =
|
||||
usePostV2CreateSession({
|
||||
mutation: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
getGetV2ListSessionsQueryKey,
|
||||
postV2CancelSessionTask,
|
||||
useDeleteV2DeleteSession,
|
||||
@@ -10,8 +9,8 @@ import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import type { UIMessage } from "ai";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChatSession } from "./useChatSession";
|
||||
import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling";
|
||||
@@ -188,35 +187,11 @@ export function useCopilotPage() {
|
||||
});
|
||||
}, [hydratedMessages, setMessages, status]);
|
||||
|
||||
// Ref: tracks whether we've already resumed for a given session.
|
||||
// Reset when the stream ends so re-resume is possible if the backend
|
||||
// task is still running (SSE dropped but executor didn't finish).
|
||||
const hasResumedRef = useRef<string | null>(null);
|
||||
|
||||
// When the stream ends (or drops), invalidate the session cache so the
|
||||
// next hydration fetches fresh messages from the backend. Without this,
|
||||
// staleTime: Infinity means the cache keeps the pre-stream data forever,
|
||||
// and any messages added during streaming are lost on remount/navigation.
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current;
|
||||
prevStatusRef.current = status;
|
||||
|
||||
const wasActive = prev === "streaming" || prev === "submitted";
|
||||
const isIdle = status === "ready" || status === "error";
|
||||
if (wasActive && isIdle && sessionId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(sessionId),
|
||||
});
|
||||
// Allow re-resume if the backend task is still running.
|
||||
hasResumedRef.current = null;
|
||||
}
|
||||
}, [status, sessionId, queryClient]);
|
||||
|
||||
// Resume an active stream AFTER hydration completes.
|
||||
// The backend returns active_stream info when a task is still running.
|
||||
// We wait for hydration so the AI SDK has the conversation history
|
||||
// before the resumed stream appends the in-progress assistant message.
|
||||
const hasResumedRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!hasActiveStream || !sessionId) return;
|
||||
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||
@@ -227,6 +202,18 @@ export function useCopilotPage() {
|
||||
resumeStream();
|
||||
}, [hasActiveStream, sessionId, hydratedMessages, status, resumeStream]);
|
||||
|
||||
// When the stream finishes, resolve any tool parts still showing spinners.
|
||||
// This can happen if the backend didn't emit StreamToolOutputAvailable for
|
||||
// a tool call before sending StreamFinish (e.g. SDK built-in tools).
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current;
|
||||
prevStatusRef.current = status;
|
||||
if (prev === "streaming" && status === "ready") {
|
||||
setMessages((msgs) => resolveInProgressTools(msgs, "completed"));
|
||||
}
|
||||
}, [status, setMessages]);
|
||||
|
||||
// Poll session endpoint when a long-running tool (create_agent, edit_agent)
|
||||
// is in progress. When the backend completes, the session data will contain
|
||||
// the final tool output — this hook detects the change and updates messages.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { TextRenderer } from "@/components/__legacy__/ui/render";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
import { ChevronDownIcon, EnterIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { DialogTitle } from "@/components/__legacy__/ui/dialog";
|
||||
import { AgentImportForm } from "./AgentImportForm";
|
||||
|
||||
export const AgentFlowList = ({
|
||||
flows,
|
||||
executions,
|
||||
selectedFlow,
|
||||
onSelectFlow,
|
||||
className,
|
||||
}: {
|
||||
flows: LibraryAgent[];
|
||||
executions?: GraphExecutionMeta[];
|
||||
selectedFlow: LibraryAgent | null;
|
||||
onSelectFlow: (f: LibraryAgent) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex-row items-center justify-between space-x-3 space-y-0">
|
||||
<CardTitle>Agents</CardTitle>
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* Split "Create" button */}
|
||||
<Button variant="outline" className="rounded-r-none">
|
||||
<Link href="/build">Create</Link>
|
||||
</Button>
|
||||
<Dialog>
|
||||
{/* https://ui.shadcn.com/docs/components/dialog#notes */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={"rounded-l-none border-l-0 px-2"}
|
||||
data-testid="create-agent-dropdown"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem data-testid="import-agent-from-file">
|
||||
<EnterIcon className="mr-2" /> Import from file
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Import Agent</DialogTitle>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Import an Agent from a file
|
||||
</h2>
|
||||
</DialogHeader>
|
||||
<AgentImportForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Status</TableHead> */}
|
||||
{/* <TableHead>Last updated</TableHead> */}
|
||||
{executions && (
|
||||
<TableHead className="md:hidden lg:table-cell">
|
||||
# of runs
|
||||
</TableHead>
|
||||
)}
|
||||
{executions && <TableHead>Last run</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody data-testid="agent-flow-list-body">
|
||||
{flows
|
||||
.map((flow) => {
|
||||
let runCount = 0,
|
||||
lastRun: GraphExecutionMeta | null = null;
|
||||
if (executions) {
|
||||
const _flowRuns = executions.filter(
|
||||
(r) => r.graph_id == flow.graph_id,
|
||||
);
|
||||
runCount = _flowRuns.length;
|
||||
lastRun =
|
||||
runCount == 0
|
||||
? null
|
||||
: _flowRuns.reduce((a, c) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const cTime = c.started_at?.getTime() ?? 0;
|
||||
return aTime > cTime ? a : c;
|
||||
});
|
||||
}
|
||||
return { flow, runCount, lastRun };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (!a.lastRun && !b.lastRun) return 0;
|
||||
if (!a.lastRun) return 1;
|
||||
if (!b.lastRun) return -1;
|
||||
const bTime = b.lastRun.started_at?.getTime() ?? 0;
|
||||
const aTime = a.lastRun.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map(({ flow, runCount, lastRun }) => (
|
||||
<TableRow
|
||||
key={flow.id}
|
||||
data-testid={flow.id}
|
||||
data-name={flow.name}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectFlow(flow)}
|
||||
data-state={selectedFlow?.id == flow.id ? "selected" : null}
|
||||
>
|
||||
<TableCell>
|
||||
<TextRenderer value={flow.name} truncateLengthLimit={30} />
|
||||
</TableCell>
|
||||
{/* <TableCell><FlowStatusBadge status={flow.status ?? "active"} /></TableCell> */}
|
||||
{/* <TableCell>
|
||||
{flow.updatedAt ?? "???"}
|
||||
</TableCell> */}
|
||||
{executions && (
|
||||
<TableCell className="md:hidden lg:table-cell">
|
||||
{runCount}
|
||||
</TableCell>
|
||||
)}
|
||||
{executions &&
|
||||
(!lastRun ? (
|
||||
<TableCell />
|
||||
) : (
|
||||
<TableCell title={lastRun.started_at?.toString() ?? ""}>
|
||||
{lastRun.started_at
|
||||
? formatDistanceToNow(lastRun.started_at, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "—"}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default AgentFlowList;
|
||||
@@ -0,0 +1,175 @@
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
import { EnterIcon } from "@radix-ui/react-icons";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import {
|
||||
Graph,
|
||||
GraphCreatable,
|
||||
sanitizeImportedGraph,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
|
||||
// Add this custom schema for File type
|
||||
const fileSchema = z.custom<File>((val) => val instanceof File, {
|
||||
message: "Must be a File object",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
agentFile: fileSchema,
|
||||
agentName: z.string().min(1, "Agent name is required"),
|
||||
agentDescription: z.string(),
|
||||
importAsTemplate: z.boolean(),
|
||||
});
|
||||
|
||||
export const AgentImportForm: React.FC<
|
||||
React.FormHTMLAttributes<HTMLFormElement>
|
||||
> = ({ className, ...props }) => {
|
||||
const [agentObject, setAgentObject] = useState<GraphCreatable | null>(null);
|
||||
const api = useBackendAPI();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
agentName: "",
|
||||
agentDescription: "",
|
||||
importAsTemplate: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
if (!agentObject) {
|
||||
form.setError("root", { message: "No Agent object to save" });
|
||||
return;
|
||||
}
|
||||
const payload: GraphCreatable = {
|
||||
...agentObject,
|
||||
name: values.agentName,
|
||||
description: values.agentDescription,
|
||||
is_active: !values.importAsTemplate,
|
||||
};
|
||||
|
||||
api
|
||||
.createGraph(payload, "upload")
|
||||
.then((response) => {
|
||||
const qID = "flowID";
|
||||
window.location.href = `/build?${qID}=${response.id}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
const entity_type = "agent";
|
||||
form.setError("root", {
|
||||
message: `Could not create ${entity_type}: ${error}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn("space-y-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agentFile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Agent file</FormLabel>
|
||||
<FormControl className="cursor-pointer">
|
||||
<Input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
data-testid="import-agent-file-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
field.onChange(file);
|
||||
const reader = new FileReader();
|
||||
// Attach parser to file reader
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const obj = JSON.parse(
|
||||
event.target?.result as string,
|
||||
);
|
||||
if (
|
||||
!["name", "description", "nodes", "links"].every(
|
||||
(key) => key in obj && obj[key] != null,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid agent object in file: " +
|
||||
JSON.stringify(obj, null, 2),
|
||||
);
|
||||
}
|
||||
const graph = obj as Graph;
|
||||
sanitizeImportedGraph(graph);
|
||||
setAgentObject(graph);
|
||||
form.setValue("agentName", graph.name);
|
||||
form.setValue("agentDescription", graph.description);
|
||||
} catch (error) {
|
||||
console.error("Error loading agent file:", error);
|
||||
}
|
||||
};
|
||||
// Load file
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agentName"
|
||||
disabled={!agentObject}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Agent name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="agent-name-input" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agentDescription"
|
||||
disabled={!agentObject}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Agent description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} data-testid="agent-description-input" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!agentObject}
|
||||
data-testid="import-agent-submit"
|
||||
>
|
||||
<EnterIcon className="mr-2" /> Import & Edit
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Graph,
|
||||
GraphExecutionMeta,
|
||||
LibraryAgent,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
import { Button, buttonVariants } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
ClockIcon,
|
||||
ExitIcon,
|
||||
Pencil2Icon,
|
||||
PlayIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { exportAsJSONFile } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import useAgentGraph from "@/hooks/useAgentGraph";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { FlowRunsStatus } from "./FlowRunsStatus";
|
||||
import { RunnerInputDialog } from "../../build/components/legacy-builder/RunnerInputUI";
|
||||
|
||||
export const FlowInfo: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
flow: LibraryAgent;
|
||||
executions: GraphExecutionMeta[];
|
||||
flowVersion?: number | "all";
|
||||
refresh: () => void;
|
||||
}
|
||||
> = ({ flow, executions, flowVersion, refresh, ...props }) => {
|
||||
const { savedAgent, saveAndRun, stopRun, isRunning } = useAgentGraph(
|
||||
flow.graph_id,
|
||||
flow.graph_version,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
const api = useBackendAPI();
|
||||
|
||||
const [flowVersions, setFlowVersions] = useState<Graph[] | null>(null);
|
||||
const [selectedVersion, setSelectedFlowVersion] = useState(
|
||||
flowVersion ?? "all",
|
||||
);
|
||||
const selectedFlowVersion: Graph | undefined = flowVersions?.find(
|
||||
(v) =>
|
||||
v.version ==
|
||||
(selectedVersion == "all" ? flow.graph_version : selectedVersion),
|
||||
);
|
||||
|
||||
const hasInputs = Object.keys(flow.input_schema.properties).length > 0;
|
||||
const hasCredentialsInputs =
|
||||
Object.keys(flow.credentials_input_schema.properties).length > 0;
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
|
||||
const isDisabled = !selectedFlowVersion;
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getGraphAllVersions(flow.graph_id)
|
||||
.then((result) => setFlowVersions(result));
|
||||
}, [flow.graph_id, api]);
|
||||
|
||||
const openRunDialog = () => setIsRunDialogOpen(true);
|
||||
|
||||
const runOrOpenInput = () => {
|
||||
if (hasInputs || hasCredentialsInputs) {
|
||||
openRunDialog();
|
||||
} else {
|
||||
saveAndRun({}, {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<CardHeader className="">
|
||||
<CardTitle>
|
||||
{flow.name} <span className="font-light">v{flow.graph_version}</span>
|
||||
</CardTitle>
|
||||
<div className="flex flex-col space-y-2 py-6">
|
||||
{(flowVersions?.length ?? 0) > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<ClockIcon className="mr-2" />
|
||||
{selectedVersion == "all"
|
||||
? "All versions"
|
||||
: `Version ${selectedVersion}`}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>Choose a version</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={String(selectedVersion)}
|
||||
onValueChange={(choice: string) =>
|
||||
setSelectedFlowVersion(
|
||||
choice == "all" ? choice : Number(choice),
|
||||
)
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All versions
|
||||
</DropdownMenuRadioItem>
|
||||
{flowVersions?.map((v) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={v.version}
|
||||
value={v.version.toString()}
|
||||
>
|
||||
Version {v.version}
|
||||
{v.is_active ? " (active)" : ""}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{flow.can_access_graph && (
|
||||
<Link
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
href={`/build?flowID=${flow.graph_id}&flowVersion=${flow.graph_version}`}
|
||||
>
|
||||
<Pencil2Icon className="mr-2" />
|
||||
Open in Builder
|
||||
</Link>
|
||||
)}
|
||||
{flow.can_access_graph && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2.5"
|
||||
title="Export to a JSON-file"
|
||||
data-testid="export-button"
|
||||
onClick={() =>
|
||||
api
|
||||
.getGraph(flow.graph_id, selectedFlowVersion!.version, true)
|
||||
.then((graph) =>
|
||||
exportAsJSONFile(
|
||||
graph,
|
||||
`${flow.name}_v${selectedFlowVersion!.version}.json`,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExitIcon className="mr-2" /> Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-purple-500 text-white hover:bg-purple-700"
|
||||
onClick={!isRunning ? runOrOpenInput : stopRun}
|
||||
disabled={isDisabled}
|
||||
title={!isRunning ? "Run Agent" : "Stop Agent"}
|
||||
>
|
||||
<PlayIcon className="mr-2" />
|
||||
{isRunning ? "Stop Agent" : "Run Agent"}
|
||||
</Button>
|
||||
{flow.can_access_graph && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
<TrashIcon className="mr-2" />
|
||||
Delete Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FlowRunsStatus
|
||||
flows={[flow]}
|
||||
executions={executions.filter(
|
||||
(execution) =>
|
||||
execution.graph_id == flow.graph_id &&
|
||||
(selectedVersion == "all" ||
|
||||
execution.graph_version == selectedVersion),
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this agent? <br />
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
api.deleteLibraryAgent(flow.id).then(() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{savedAgent && (
|
||||
<RunnerInputDialog
|
||||
isOpen={isRunDialogOpen}
|
||||
doClose={() => setIsRunDialogOpen(false)}
|
||||
graph={savedAgent}
|
||||
doRun={saveAndRun}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default FlowInfo;
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import Link from "next/link";
|
||||
import { Button, buttonVariants } from "@/components/__legacy__/ui/button";
|
||||
import { IconSquare } from "@/components/__legacy__/ui/icons";
|
||||
import { ExitIcon, Pencil2Icon } from "@radix-ui/react-icons";
|
||||
import { format } from "date-fns";
|
||||
import { FlowRunStatusBadge } from "@/app/(platform)/monitoring/components/FlowRunStatusBadge";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import RunnerOutputUI, {
|
||||
OutputNodeInfo,
|
||||
} from "../../build/components/legacy-builder/RunnerOutputUI";
|
||||
|
||||
export const FlowRunInfo: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
agent: LibraryAgent;
|
||||
execution: GraphExecutionMeta;
|
||||
}
|
||||
> = ({ agent, execution, ...props }) => {
|
||||
const [isOutputOpen, setIsOutputOpen] = useState(false);
|
||||
const [blockOutputs, setBlockOutputs] = useState<OutputNodeInfo[]>([]);
|
||||
const api = useBackendAPI();
|
||||
|
||||
const fetchBlockResults = useCallback(async () => {
|
||||
const graph = await api.getGraph(agent.graph_id, agent.graph_version);
|
||||
const graphExecution = await api.getGraphExecutionInfo(
|
||||
agent.graph_id,
|
||||
execution.id,
|
||||
);
|
||||
|
||||
// Transform results to BlockOutput format
|
||||
setBlockOutputs(
|
||||
Object.entries(graphExecution.outputs).flatMap(([key, values]) =>
|
||||
values.map(
|
||||
(value) =>
|
||||
({
|
||||
metadata: {
|
||||
name: graph.output_schema.properties[key].title || "Output",
|
||||
description:
|
||||
graph.output_schema.properties[key].description ||
|
||||
"Output from the agent",
|
||||
},
|
||||
result: value,
|
||||
}) satisfies OutputNodeInfo,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, [api, agent.graph_id, agent.graph_version, execution.id]);
|
||||
|
||||
// Fetch graph and execution data
|
||||
useEffect(() => {
|
||||
if (!isOutputOpen) return;
|
||||
fetchBlockResults();
|
||||
}, [isOutputOpen, fetchBlockResults]);
|
||||
|
||||
if (execution.graph_id != agent.graph_id) {
|
||||
throw new Error(
|
||||
`FlowRunInfo can't be used with non-matching execution.graph_id and flow.id`,
|
||||
);
|
||||
}
|
||||
|
||||
const handleStopRun = useCallback(() => {
|
||||
api.stopGraphExecution(agent.graph_id, execution.id);
|
||||
}, [api, agent.graph_id, execution.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...props}>
|
||||
<CardHeader className="flex-row items-center justify-between space-x-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{agent.name}{" "}
|
||||
<span className="font-light">v{execution.graph_version}</span>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{execution.status === "RUNNING" && (
|
||||
<Button onClick={handleStopRun} variant="destructive">
|
||||
<IconSquare className="mr-2" /> Stop Run
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setIsOutputOpen(true)} variant="outline">
|
||||
<ExitIcon className="mr-2" /> View Outputs
|
||||
</Button>
|
||||
{agent.can_access_graph && (
|
||||
<Link
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
href={`/build?flowID=${execution.graph_id}&flowVersion=${execution.graph_version}&flowExecutionID=${execution.id}`}
|
||||
>
|
||||
<Pencil2Icon className="mr-2" /> Open in Builder
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="hidden">
|
||||
<strong>Agent ID:</strong> <code>{agent.graph_id}</code>
|
||||
</p>
|
||||
<p className="hidden">
|
||||
<strong>Run ID:</strong> <code>{execution.id}</code>
|
||||
</p>
|
||||
<div>
|
||||
<strong>Status:</strong>{" "}
|
||||
<FlowRunStatusBadge status={execution.status} />
|
||||
</div>
|
||||
<p>
|
||||
<strong>Started:</strong>{" "}
|
||||
{execution.started_at
|
||||
? format(execution.started_at, "yyyy-MM-dd HH:mm:ss")
|
||||
: "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Finished:</strong>{" "}
|
||||
{execution.ended_at
|
||||
? format(execution.ended_at, "yyyy-MM-dd HH:mm:ss")
|
||||
: "—"}
|
||||
</p>
|
||||
{execution.stats && (
|
||||
<p>
|
||||
<strong>Duration (run time):</strong>{" "}
|
||||
{execution.stats.duration.toFixed(1)} (
|
||||
{execution.stats.node_exec_time.toFixed(1)}) seconds
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RunnerOutputUI
|
||||
isOpen={isOutputOpen}
|
||||
doClose={() => setIsOutputOpen(false)}
|
||||
outputs={blockOutputs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowRunInfo;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GraphExecutionMeta } from "@/lib/autogpt-server-api";
|
||||
|
||||
export const FlowRunStatusBadge: React.FC<{
|
||||
status: GraphExecutionMeta["status"];
|
||||
className?: string;
|
||||
}> = ({ status, className }) => (
|
||||
<Badge
|
||||
variant="default"
|
||||
className={cn(
|
||||
status === "RUNNING"
|
||||
? "bg-blue-500 dark:bg-blue-700"
|
||||
: status === "QUEUED"
|
||||
? "bg-yellow-500 dark:bg-yellow-600"
|
||||
: status === "COMPLETED"
|
||||
? "bg-green-500 dark:bg-green-600"
|
||||
: "bg-red-500 dark:bg-red-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { format } from "date-fns";
|
||||
import { FlowRunStatusBadge } from "@/app/(platform)/monitoring/components/FlowRunStatusBadge";
|
||||
import { TextRenderer } from "../../../../components/__legacy__/ui/render";
|
||||
|
||||
export const FlowRunsList: React.FC<{
|
||||
flows: LibraryAgent[];
|
||||
executions: GraphExecutionMeta[];
|
||||
className?: string;
|
||||
selectedRun?: GraphExecutionMeta | null;
|
||||
onSelectRun: (r: GraphExecutionMeta) => void;
|
||||
}> = ({ flows, executions, selectedRun, onSelectRun, className }) => (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody data-testid="flow-runs-list-body">
|
||||
{executions.map((execution) => (
|
||||
<TableRow
|
||||
key={execution.id}
|
||||
data-testid={`flow-run-${execution.id}-graph-${execution.graph_id}`}
|
||||
data-runid={execution.id}
|
||||
data-graphid={execution.graph_id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectRun(execution)}
|
||||
data-state={selectedRun?.id == execution.id ? "selected" : null}
|
||||
>
|
||||
<TableCell>
|
||||
<TextRenderer
|
||||
value={
|
||||
flows.find((f) => f.graph_id == execution.graph_id)?.name
|
||||
}
|
||||
truncateLengthLimit={30}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.started_at
|
||||
? format(execution.started_at, "HH:mm")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FlowRunStatusBadge
|
||||
status={execution.status}
|
||||
className="w-full justify-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.stats
|
||||
? formatDuration(execution.stats.duration)
|
||||
: ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return (
|
||||
(seconds < 100 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
|
||||
"s"
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowRunsList;
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from "react";
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import { CardTitle } from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
import { FlowRunsTimeline } from "@/app/(platform)/monitoring/components/FlowRunsTimeline";
|
||||
|
||||
export const FlowRunsStatus: React.FC<{
|
||||
flows: LibraryAgent[];
|
||||
executions: GraphExecutionMeta[];
|
||||
title?: string;
|
||||
className?: string;
|
||||
}> = ({ flows, executions: executions, title, className }) => {
|
||||
/* "dateMin": since the first flow in the dataset
|
||||
* number > 0: custom date (unix timestamp)
|
||||
* number < 0: offset relative to Date.now() (in seconds) */
|
||||
const [selected, setSelected] = useState<Date>();
|
||||
const [statsSince, setStatsSince] = useState<number | "dataMin">(-24 * 3600);
|
||||
const statsSinceTimestamp = // unix timestamp or null
|
||||
typeof statsSince == "string"
|
||||
? null
|
||||
: statsSince < 0
|
||||
? Date.now() + statsSince * 1000
|
||||
: statsSince;
|
||||
const filteredFlowRuns =
|
||||
statsSinceTimestamp != null
|
||||
? executions.filter(
|
||||
(fr) =>
|
||||
fr.started_at && fr.started_at.getTime() > statsSinceTimestamp,
|
||||
)
|
||||
: executions;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{title || "Stats"}</CardTitle>
|
||||
<div className="flex flex-wrap space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-2 * 3600)}
|
||||
>
|
||||
2h
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-8 * 3600)}
|
||||
>
|
||||
8h
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-24 * 3600)}
|
||||
>
|
||||
24h
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-7 * 24 * 3600)}
|
||||
>
|
||||
7d
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} size="sm">
|
||||
Custom
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(_, selectedDay) => {
|
||||
setSelected(selectedDay);
|
||||
setStatsSince(selectedDay.getTime());
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince("dataMin")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowRunsTimeline
|
||||
flows={flows}
|
||||
executions={executions}
|
||||
dataMin={statsSince}
|
||||
className="mt-3"
|
||||
/>
|
||||
<hr className="my-4" />
|
||||
<div>
|
||||
<p>
|
||||
<strong>Total runs:</strong> {filteredFlowRuns.length}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Total run time:</strong>{" "}
|
||||
{filteredFlowRuns.reduce(
|
||||
(total, run) => total + (run.stats?.node_exec_time ?? 0),
|
||||
0,
|
||||
)}{" "}
|
||||
seconds
|
||||
</p>
|
||||
{filteredFlowRuns.some((r) => r.stats) && (
|
||||
<p>
|
||||
<strong>Total cost:</strong> $
|
||||
{(
|
||||
filteredFlowRuns.reduce(
|
||||
(total, run) => total + (run.stats?.cost ?? 0),
|
||||
0,
|
||||
) / 100
|
||||
).toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default FlowRunsStatus;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
ComposedChart,
|
||||
DefaultLegendContentProps,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { differenceInHours, format } from "date-fns";
|
||||
import { Card } from "@/components/__legacy__/ui/card";
|
||||
import { cn, hashString } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { FlowRunStatusBadge } from "@/app/(platform)/monitoring/components/FlowRunStatusBadge";
|
||||
|
||||
export const FlowRunsTimeline = ({
|
||||
flows,
|
||||
executions,
|
||||
dataMin,
|
||||
className,
|
||||
}: {
|
||||
flows: LibraryAgent[];
|
||||
executions: GraphExecutionMeta[];
|
||||
dataMin: "dataMin" | number;
|
||||
className?: string;
|
||||
}) => (
|
||||
/* TODO: make logarithmic? */
|
||||
<ResponsiveContainer width="100%" height={120} className={className}>
|
||||
<ComposedChart>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
type="number"
|
||||
domain={[
|
||||
typeof dataMin == "string"
|
||||
? dataMin
|
||||
: dataMin < 0
|
||||
? Date.now() + dataMin * 1000
|
||||
: dataMin,
|
||||
Date.now(),
|
||||
]}
|
||||
allowDataOverflow={true}
|
||||
tickFormatter={(unixTime) => {
|
||||
const now = new Date();
|
||||
const time = new Date(unixTime);
|
||||
return differenceInHours(now, time) < 24
|
||||
? format(time, "HH:mm")
|
||||
: format(time, "yyyy-MM-dd HH:mm");
|
||||
}}
|
||||
name="Time"
|
||||
scale="time"
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="_duration"
|
||||
name="Duration (s)"
|
||||
tickFormatter={(s) => (s > 90 ? `${Math.round(s / 60)}m` : `${s}s`)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const data: GraphExecutionMeta & {
|
||||
time: number;
|
||||
_duration: number;
|
||||
} = payload[0].payload;
|
||||
const flow = flows.find((f) => f.graph_id === data.graph_id);
|
||||
return (
|
||||
<Card className="p-2 text-xs leading-normal">
|
||||
<p>
|
||||
<strong>Agent:</strong> {flow ? flow.name : "Unknown"}
|
||||
</p>
|
||||
<div>
|
||||
<strong>Status:</strong>
|
||||
<FlowRunStatusBadge
|
||||
status={data.status}
|
||||
className="px-1.5 py-0"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Started:</strong>{" "}
|
||||
{data.started_at
|
||||
? format(data.started_at, "yyyy-MM-dd HH:mm:ss")
|
||||
: "—"}
|
||||
</p>
|
||||
{data.stats && (
|
||||
<p>
|
||||
<strong>Duration / run time:</strong>{" "}
|
||||
{formatDuration(data.stats.duration)} /{" "}
|
||||
{formatDuration(data.stats.node_exec_time)}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
{flows.map((flow) => (
|
||||
<Scatter
|
||||
key={flow.id}
|
||||
data={executions
|
||||
.filter((e) => e.graph_id == flow.graph_id && e.started_at)
|
||||
.map((e) => ({
|
||||
...e,
|
||||
time:
|
||||
(e.started_at?.getTime() ?? 0) +
|
||||
(e.stats?.node_exec_time ?? 0) * 1000,
|
||||
_duration: e.stats?.node_exec_time ?? 0,
|
||||
}))}
|
||||
name={flow.name}
|
||||
fill={`hsl(${(hashString(flow.id) * 137.5) % 360}, 70%, 50%)`}
|
||||
/>
|
||||
))}
|
||||
{executions
|
||||
.filter((e) => e.started_at && e.ended_at)
|
||||
.map((execution) => (
|
||||
<Line
|
||||
key={execution.id}
|
||||
type="linear"
|
||||
dataKey="_duration"
|
||||
data={[
|
||||
{
|
||||
...execution,
|
||||
time: execution.started_at!.getTime(),
|
||||
_duration: 0,
|
||||
},
|
||||
{
|
||||
...execution,
|
||||
time: execution.ended_at!.getTime(),
|
||||
_duration: execution.stats?.node_exec_time ?? 0,
|
||||
},
|
||||
]}
|
||||
stroke={`hsl(${(hashString(execution.graph_id) * 137.5) % 360}, 70%, 50%)`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
legendType="none"
|
||||
/>
|
||||
))}
|
||||
<Legend
|
||||
content={<ScrollableLegend />}
|
||||
wrapperStyle={{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
export default FlowRunsTimeline;
|
||||
|
||||
const ScrollableLegend: React.FC<
|
||||
DefaultLegendContentProps & { className?: string }
|
||||
> = ({ payload, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-x-3 overflow-x-auto whitespace-nowrap px-4 text-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{payload?.map((entry, index) => {
|
||||
if (entry.type == "none") return;
|
||||
return (
|
||||
<span key={`item-${index}`} className="inline-flex items-center">
|
||||
<span
|
||||
className="mr-1 inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span>{entry.value}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return (
|
||||
(seconds < 100 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
|
||||
"s"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Card } from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { ClockIcon, Loader2 } from "lucide-react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
|
||||
import {
|
||||
formatScheduleTime,
|
||||
getTimezoneAbbreviation,
|
||||
} from "@/lib/timezone-utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { TextRenderer } from "../../../../components/__legacy__/ui/render";
|
||||
import { Input } from "../../../../components/__legacy__/ui/input";
|
||||
import { Label } from "../../../../components/__legacy__/ui/label";
|
||||
|
||||
interface SchedulesTableProps {
|
||||
schedules: GraphExecutionJobInfo[];
|
||||
agents: LibraryAgent[];
|
||||
onRemoveSchedule: (scheduleId: string, enabled: boolean) => void;
|
||||
sortColumn: keyof GraphExecutionJobInfo;
|
||||
sortDirection: "asc" | "desc";
|
||||
onSort: (column: keyof GraphExecutionJobInfo) => void;
|
||||
}
|
||||
|
||||
export const SchedulesTable = ({
|
||||
schedules,
|
||||
agents,
|
||||
onRemoveSchedule,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: SchedulesTableProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>(""); // Library Agent ID
|
||||
const [selectedVersion, setSelectedVersion] = useState<number>(0); // Graph version
|
||||
const [maxVersion, setMaxVersion] = useState<number>(0);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState<string>(""); // Graph ID
|
||||
|
||||
// Get user's timezone for displaying schedule times
|
||||
const userTimezone = useUserTimezone() ?? "UTC";
|
||||
|
||||
const filteredAndSortedSchedules = [...schedules]
|
||||
.filter(
|
||||
(schedule) => !selectedFilter || schedule.graph_id === selectedFilter,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aValue = a[sortColumn];
|
||||
const bValue = b[sortColumn];
|
||||
if (sortDirection === "asc") {
|
||||
return String(aValue).localeCompare(String(bValue));
|
||||
}
|
||||
return String(bValue).localeCompare(String(aValue));
|
||||
});
|
||||
|
||||
const handleToggleSchedule = (scheduleId: string, enabled: boolean) => {
|
||||
onRemoveSchedule(scheduleId, enabled);
|
||||
if (!enabled) {
|
||||
toast({
|
||||
title: "Schedule Disabled",
|
||||
description: "The schedule has been successfully disabled.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSchedule = () => {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAgentSelect = (agentId: string) => {
|
||||
setSelectedAgent(agentId);
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
setMaxVersion(agent!.graph_version);
|
||||
setSelectedVersion(agent!.graph_version);
|
||||
};
|
||||
|
||||
const handleVersionSelect = (version: string) => {
|
||||
setSelectedVersion(parseInt(version));
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!selectedAgent || !selectedVersion) {
|
||||
toast({
|
||||
title: "Invalid Input",
|
||||
description: "Please select an agent and a version.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedVersion < 1 || selectedVersion > maxVersion) {
|
||||
toast({
|
||||
title: "Invalid Version",
|
||||
description: `Please select a version between 1 and ${maxVersion}.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const agent = agents.find((a) => a.id == selectedAgent)!;
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
router.push(
|
||||
`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}&open_scheduling=true`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Navigation error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-fit p-4">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Agent for New Schedule</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Select onValueChange={handleAgentSelect}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent, i) => (
|
||||
<SelectItem key={agent.id + i} value={agent.id}>
|
||||
<TextRenderer value={agent.name} truncateLengthLimit={30} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="mt-4">
|
||||
Select version between 1 and {maxVersion}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={selectedAgent ? maxVersion : 0}
|
||||
value={selectedVersion}
|
||||
onChange={(e) => handleVersionSelect(e.target.value)}
|
||||
placeholder="Select version"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSchedule}
|
||||
disabled={isLoading || !selectedAgent}
|
||||
className="mt-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Schedule"
|
||||
)}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Schedules</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select onValueChange={setSelectedFilter}>
|
||||
<SelectTrigger className="h-8 w-[180px] rounded-md px-3 text-xs">
|
||||
<SelectValue placeholder="Filter by graph" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="text-xs">
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.graph_id}>
|
||||
{agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={handleNewSchedule}>
|
||||
<ClockIcon className="mr-2 h-4 w-4" />
|
||||
New Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
onClick={() => onSort("graph_id")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Graph Name
|
||||
</TableHead>
|
||||
<TableHead className="cursor-pointer">Graph Version</TableHead>
|
||||
<TableHead
|
||||
onClick={() => onSort("next_run_time")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Next Execution
|
||||
</TableHead>
|
||||
<TableHead
|
||||
onClick={() => onSort("cron")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Schedule
|
||||
</TableHead>
|
||||
<TableHead>Timezone</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedSchedules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-lg text-gray-400"
|
||||
>
|
||||
No schedules are available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedSchedules.map((schedule) => (
|
||||
<TableRow key={schedule.id}>
|
||||
<TableCell className="font-medium">
|
||||
{agents.find((a) => a.graph_id === schedule.graph_id)
|
||||
?.name || schedule.graph_id}
|
||||
</TableCell>
|
||||
<TableCell>{schedule.graph_version}</TableCell>
|
||||
<TableCell>
|
||||
{formatScheduleTime(schedule.next_run_time, userTimezone)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{humanizeCronExpression(schedule.cron)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{schedule.timezone
|
||||
? getTimezoneAbbreviation(schedule.timezone)
|
||||
: userTimezone && getTimezoneAbbreviation(userTimezone)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => handleToggleSchedule(schedule.id, false)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export default function AgentsFlowListSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Agents</h1>
|
||||
<div className="h-10 w-24 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-4 grid grid-cols-3 gap-4 font-medium text-gray-500">
|
||||
<div>Name</div>
|
||||
<div># of runs</div>
|
||||
<div>Last run</div>
|
||||
</div>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className="mb-4 grid grid-cols-3 gap-4">
|
||||
<div className="h-6 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-6 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-6 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function FlowRunsListSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Runs</h2>
|
||||
<div className="mb-4 grid grid-cols-4 gap-4 text-sm font-medium text-gray-500">
|
||||
<div>Agent</div>
|
||||
<div>Started</div>
|
||||
<div>Status</div>
|
||||
<div>Duration</div>
|
||||
</div>
|
||||
{[...Array(4)].map((_, index) => (
|
||||
<div key={index} className="mb-4 grid grid-cols-4 gap-4">
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export default function FlowRunsStatusSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Stats</h2>
|
||||
<div className="flex space-x-2">
|
||||
{["2h", "8h", "24h", "7d", "Custom", "All"].map((btn) => (
|
||||
<div
|
||||
key={btn}
|
||||
className="h-8 w-16 animate-pulse rounded bg-gray-200"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for the line chart */}
|
||||
<div className="mb-6 h-64 w-full animate-pulse rounded bg-gray-200"></div>
|
||||
|
||||
{/* Placeholders for total runs and total run time */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-1/3 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-6 w-1/2 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import AgentFlowListSkeleton from "@/app/(platform)/monitoring/components/skeletons/AgentFlowListSkeleton";
|
||||
import React from "react";
|
||||
import FlowRunsListSkeleton from "@/app/(platform)/monitoring/components/skeletons/FlowRunsListSkeleton";
|
||||
import FlowRunsStatusSkeleton from "@/app/(platform)/monitoring/components/skeletons/FlowRunsStatusSkeleton";
|
||||
|
||||
export default function MonitorLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* Agents Section */}
|
||||
<AgentFlowListSkeleton />
|
||||
|
||||
{/* Runs Section */}
|
||||
<FlowRunsListSkeleton />
|
||||
|
||||
{/* Stats Section */}
|
||||
<FlowRunsStatusSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
autogpt_platform/frontend/src/app/(platform)/monitoring/page.tsx
Normal file
151
autogpt_platform/frontend/src/app/(platform)/monitoring/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import {
|
||||
useGetV1ListExecutionSchedulesForAUser,
|
||||
useDeleteV1DeleteExecutionSchedule,
|
||||
} from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
|
||||
import { Card } from "@/components/__legacy__/ui/card";
|
||||
import { SchedulesTable } from "@/app/(platform)/monitoring/components/SchedulesTable";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import AgentFlowList from "./components/AgentFlowList";
|
||||
import FlowRunsList from "./components/FlowRunsList";
|
||||
import FlowRunInfo from "./components/FlowRunInfo";
|
||||
import FlowInfo from "./components/FlowInfo";
|
||||
import FlowRunsStatus from "./components/FlowRunsStatus";
|
||||
|
||||
const Monitor = () => {
|
||||
const [flows, setFlows] = useState<LibraryAgent[]>([]);
|
||||
const [executions, setExecutions] = useState<GraphExecutionMeta[]>([]);
|
||||
const [selectedFlow, setSelectedFlow] = useState<LibraryAgent | null>(null);
|
||||
const [selectedRun, setSelectedRun] = useState<GraphExecutionMeta | null>(
|
||||
null,
|
||||
);
|
||||
const [sortColumn, setSortColumn] =
|
||||
useState<keyof GraphExecutionJobInfo>("id");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const api = useBackendAPI();
|
||||
|
||||
// Use generated API hooks for schedules
|
||||
const { data: schedulesResponse, refetch: refetchSchedules } =
|
||||
useGetV1ListExecutionSchedulesForAUser();
|
||||
const deleteScheduleMutation = useDeleteV1DeleteExecutionSchedule();
|
||||
|
||||
const schedules = okData(schedulesResponse) ?? [];
|
||||
|
||||
const removeSchedule = useCallback(
|
||||
async (scheduleId: string) => {
|
||||
await deleteScheduleMutation.mutateAsync({ scheduleId });
|
||||
refetchSchedules();
|
||||
},
|
||||
[deleteScheduleMutation, refetchSchedules],
|
||||
);
|
||||
|
||||
const fetchAgents = useCallback(() => {
|
||||
api.listLibraryAgents().then((response) => {
|
||||
setFlows(response.agents);
|
||||
});
|
||||
api.getExecutions().then((executions) => {
|
||||
setExecutions(executions);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => fetchAgents(), 5000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchAgents, flows]);
|
||||
|
||||
const column1 = "md:col-span-2 xl:col-span-3 xxl:col-span-2";
|
||||
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3";
|
||||
const column3 = "col-span-full xl:col-span-4 xxl:col-span-5";
|
||||
|
||||
const handleSort = (column: keyof GraphExecutionJobInfo) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 p-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10"
|
||||
data-testid="monitor-page"
|
||||
>
|
||||
<AgentFlowList
|
||||
className={column1}
|
||||
flows={flows}
|
||||
executions={executions}
|
||||
selectedFlow={selectedFlow}
|
||||
onSelectFlow={(f) => {
|
||||
setSelectedRun(null);
|
||||
setSelectedFlow(f.id == selectedFlow?.id ? null : f);
|
||||
}}
|
||||
/>
|
||||
<FlowRunsList
|
||||
className={column2}
|
||||
flows={flows}
|
||||
executions={[
|
||||
...(selectedFlow
|
||||
? executions.filter((v) => v.graph_id == selectedFlow.graph_id)
|
||||
: executions),
|
||||
].sort((a, b) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const bTime = b.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})}
|
||||
selectedRun={selectedRun}
|
||||
onSelectRun={(r) => setSelectedRun(r.id == selectedRun?.id ? null : r)}
|
||||
/>
|
||||
{(selectedRun && (
|
||||
<FlowRunInfo
|
||||
agent={
|
||||
selectedFlow ||
|
||||
flows.find((f) => f.graph_id == selectedRun.graph_id)!
|
||||
}
|
||||
execution={selectedRun}
|
||||
className={column3}
|
||||
/>
|
||||
)) ||
|
||||
(selectedFlow && (
|
||||
<FlowInfo
|
||||
flow={selectedFlow}
|
||||
executions={executions.filter(
|
||||
(e) => e.graph_id == selectedFlow.graph_id,
|
||||
)}
|
||||
className={column3}
|
||||
refresh={() => {
|
||||
fetchAgents();
|
||||
setSelectedFlow(null);
|
||||
setSelectedRun(null);
|
||||
}}
|
||||
/>
|
||||
)) || (
|
||||
<Card className={`p-6 ${column3}`}>
|
||||
<FlowRunsStatus flows={flows} executions={executions} />
|
||||
</Card>
|
||||
)}
|
||||
<div className="col-span-full xl:col-span-6">
|
||||
<SchedulesTable
|
||||
schedules={schedules} // all schedules
|
||||
agents={flows} // for filtering purpose
|
||||
onRemoveSchedule={removeSchedule}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitor;
|
||||
@@ -119,7 +119,7 @@ export function CredentialsFlatView({
|
||||
) : (
|
||||
!readOnly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onAddCredential}
|
||||
className="w-fit"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
|
||||
import { useMemo } from "react";
|
||||
import { customValidator } from "./utils/custom-validator";
|
||||
import Form from "./registry";
|
||||
import { ExtendedFormContextType } from "./types";
|
||||
import { customValidator } from "./utils/custom-validator";
|
||||
import { generateUiSchemaForCustomFields } from "./utils/generate-ui-schema";
|
||||
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
|
||||
|
||||
type FormRendererProps = {
|
||||
jsonSchema: RJSFSchema;
|
||||
@@ -13,17 +12,15 @@ type FormRendererProps = {
|
||||
uiSchema: any;
|
||||
initialValues: any;
|
||||
formContext: ExtendedFormContextType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FormRenderer({
|
||||
export const FormRenderer = ({
|
||||
jsonSchema,
|
||||
handleChange,
|
||||
uiSchema,
|
||||
initialValues,
|
||||
formContext,
|
||||
className,
|
||||
}: FormRendererProps) {
|
||||
}: FormRendererProps) => {
|
||||
const preprocessedSchema = useMemo(() => {
|
||||
return preprocessInputSchema(jsonSchema);
|
||||
}, [jsonSchema]);
|
||||
@@ -34,10 +31,7 @@ export function FormRenderer({
|
||||
}, [preprocessedSchema, uiSchema]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mb-6 mt-4", className)}
|
||||
data-tutorial-id="input-handles"
|
||||
>
|
||||
<div className={"mb-6 mt-4"} data-tutorial-id="input-handles">
|
||||
<Form
|
||||
formContext={formContext}
|
||||
idPrefix="agpt"
|
||||
@@ -51,4 +45,4 @@ export function FormRenderer({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
1043
autogpt_platform/frontend/src/hooks/useAgentGraph.tsx
Normal file
1043
autogpt_platform/frontend/src/hooks/useAgentGraph.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ export const PROTECTED_PAGES = [
|
||||
"/onboarding",
|
||||
"/profile",
|
||||
"/library",
|
||||
"/monitoring",
|
||||
] as const;
|
||||
|
||||
export const ADMIN_PAGES = ["/admin"] as const;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ClassValue, clsx } from "clsx";
|
||||
import _isEmpty from "lodash/isEmpty";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { NodeDimension } from "@/app/(platform)/build/components/legacy-builder/Flow/Flow";
|
||||
import {
|
||||
BlockIOObjectSubSchema,
|
||||
BlockIORootSchema,
|
||||
@@ -331,6 +332,81 @@ export function getPrimaryCategoryColor(categories: Category[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
function rectanglesOverlap(
|
||||
rect1: { x: number; y: number; width: number; height?: number },
|
||||
rect2: { x: number; y: number; width: number; height?: number },
|
||||
): boolean {
|
||||
const x1 = rect1.x,
|
||||
y1 = rect1.y,
|
||||
w1 = rect1.width,
|
||||
h1 = rect1.height ?? 100;
|
||||
const x2 = rect2.x,
|
||||
y2 = rect2.y,
|
||||
w2 = rect2.width,
|
||||
h2 = rect2.height ?? 100;
|
||||
|
||||
// Check if the rectangles do not overlap
|
||||
return !(x1 + w1 <= x2 || x1 >= x2 + w2 || y1 + h1 <= y2 || y1 >= y2 + h2);
|
||||
}
|
||||
|
||||
export function findNewlyAddedBlockCoordinates(
|
||||
nodeDimensions: NodeDimension,
|
||||
newWidth: number,
|
||||
margin: number,
|
||||
zoom: number,
|
||||
) {
|
||||
const nodeDimensionArray = Object.values(nodeDimensions);
|
||||
|
||||
for (let i = nodeDimensionArray.length - 1; i >= 0; i--) {
|
||||
const lastNode = nodeDimensionArray[i];
|
||||
const lastNodeHeight = lastNode.height ?? 100;
|
||||
|
||||
// Right of the last node
|
||||
let newX = lastNode.x + lastNode.width + margin;
|
||||
let newY = lastNode.y;
|
||||
let newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
|
||||
|
||||
const collisionRight = nodeDimensionArray.some((node) =>
|
||||
rectanglesOverlap(newRect, node),
|
||||
);
|
||||
|
||||
if (!collisionRight) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
|
||||
// Left of the last node
|
||||
newX = lastNode.x - newWidth - margin;
|
||||
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
|
||||
|
||||
const collisionLeft = nodeDimensionArray.some((node) =>
|
||||
rectanglesOverlap(newRect, node),
|
||||
);
|
||||
|
||||
if (!collisionLeft) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
|
||||
// Below the last node
|
||||
newX = lastNode.x;
|
||||
newY = lastNode.y + lastNodeHeight + margin;
|
||||
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
|
||||
|
||||
const collisionBelow = nodeDimensionArray.some((node) =>
|
||||
rectanglesOverlap(newRect, node),
|
||||
);
|
||||
|
||||
if (!collisionBelow) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
}
|
||||
|
||||
// Default position if no space is found
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasNonNullNonObjectValue(obj: any): boolean {
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
return Object.values(obj).some((value) => hasNonNullNonObjectValue(value));
|
||||
|
||||
140
autogpt_platform/frontend/src/tests/monitor.spec.ts
Normal file
140
autogpt_platform/frontend/src/tests/monitor.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import test, { expect, TestInfo } from "@playwright/test";
|
||||
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import { MonitorPage } from "./pages/monitor.page";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import * as fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import {
|
||||
navigateToLibrary,
|
||||
clickFirstAgent,
|
||||
runAgent,
|
||||
waitForAgentPageLoad,
|
||||
} from "./pages/library.page";
|
||||
|
||||
test.describe.configure({
|
||||
mode: "parallel",
|
||||
timeout: 30000,
|
||||
});
|
||||
// --8<-- [start:AttachAgentId]
|
||||
test.beforeEach(async ({ page }, testInfo: TestInfo) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const testUser = await getTestUser();
|
||||
const monitorPage = new MonitorPage(page);
|
||||
|
||||
// Start each test with login using worker auth
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
// Navigate to library and run the first agent
|
||||
await navigateToLibrary(page);
|
||||
await clickFirstAgent(page);
|
||||
await waitForAgentPageLoad(page);
|
||||
await runAgent(page);
|
||||
|
||||
// Navigate to monitoring page
|
||||
await page.goto("/monitoring");
|
||||
await test.expect(monitorPage.isLoaded()).resolves.toBeTruthy();
|
||||
|
||||
// Generate a test ID for tracking
|
||||
const id = uuidv4();
|
||||
testInfo.attach("agent-id", { body: id });
|
||||
});
|
||||
// --8<-- [end:AttachAgentId]
|
||||
|
||||
test.afterAll(async () => {
|
||||
// clear out the downloads folder
|
||||
const downloadsFolder = process.cwd() + "/downloads";
|
||||
console.log(`clearing out the downloads folder ${downloadsFolder}/monitor`);
|
||||
|
||||
await fs.rm(`${downloadsFolder}/monitor`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("user can export and import agents", async ({
|
||||
page,
|
||||
}, testInfo: TestInfo) => {
|
||||
const monitorPage = new MonitorPage(page);
|
||||
const buildPage = new BuildPage(page);
|
||||
|
||||
// --8<-- [start:ReadAgentId]
|
||||
if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) {
|
||||
throw new Error("No agent id attached to the test");
|
||||
}
|
||||
|
||||
const testAttachName = testInfo.attachments[0].body.toString();
|
||||
// --8<-- [end:ReadAgentId]
|
||||
const agents = await monitorPage.listAgents();
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
const agent = agents.find(
|
||||
(a: any) => a.name === `test-agent-${testAttachName}`,
|
||||
);
|
||||
|
||||
if (!agent) throw new Error(`Agent ${testAttachName} not found`);
|
||||
|
||||
await monitorPage.exportToFile(agent);
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||
await download.saveAs(
|
||||
`${monitorPage.downloadsFolder}/monitor/${download.suggestedFilename()}`,
|
||||
);
|
||||
|
||||
console.log(`downloaded file to ${download.suggestedFilename()}`);
|
||||
|
||||
expect(download.suggestedFilename()).toBeDefined();
|
||||
expect(download.suggestedFilename()).toContain("test-agent-");
|
||||
expect(download.suggestedFilename()).toContain("v1.json");
|
||||
|
||||
// import the agent
|
||||
const preImportAgents = await monitorPage.listAgents();
|
||||
|
||||
const filesInFolder = await fs.readdir(
|
||||
`${monitorPage.downloadsFolder}/monitor`,
|
||||
);
|
||||
|
||||
const importFile = filesInFolder.find((f) => f.includes(testAttachName));
|
||||
if (!importFile) {
|
||||
throw new Error(`No import file found for agent ${testAttachName}`);
|
||||
}
|
||||
|
||||
const baseName = importFile.split(".")[0];
|
||||
|
||||
await monitorPage.importFromFile(
|
||||
path.resolve(monitorPage.downloadsFolder, "monitor"),
|
||||
importFile,
|
||||
baseName + "-imported",
|
||||
);
|
||||
|
||||
// You'll be dropped at the build page, so hit run and then go back to monitor
|
||||
await buildPage.runAgent();
|
||||
await monitorPage.navbar.clickMonitorLink();
|
||||
|
||||
const postImportAgents = await monitorPage.listAgents();
|
||||
|
||||
expect(postImportAgents.length).toBeGreaterThan(preImportAgents.length);
|
||||
|
||||
console.log(`postImportAgents: ${JSON.stringify(postImportAgents)}`);
|
||||
|
||||
const importedAgent = postImportAgents.find(
|
||||
(a: any) => a.name === `${baseName}-imported`,
|
||||
);
|
||||
|
||||
expect(importedAgent).toBeDefined();
|
||||
});
|
||||
|
||||
test.skip("user can view runs and agents", async ({ page }) => {
|
||||
const monitorPage = new MonitorPage(page);
|
||||
// const runs = await monitorPage.listRuns();
|
||||
const agents = await monitorPage.listAgents();
|
||||
|
||||
expect(agents.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -252,6 +252,21 @@ export class LibraryPage extends BasePage {
|
||||
]);
|
||||
}
|
||||
|
||||
async clickMonitoringLink(): Promise<void> {
|
||||
console.log(`clicking monitoring link in alert`);
|
||||
await this.page.getByRole("link", { name: "here" }).click();
|
||||
}
|
||||
|
||||
async isMonitoringAlertVisible(): Promise<boolean> {
|
||||
console.log(`checking if monitoring alert is visible`);
|
||||
try {
|
||||
const alertText = this.page.locator("text=/Prefer the old experience/");
|
||||
return await alertText.isVisible();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSearchValue(): Promise<string> {
|
||||
console.log(`getting search input value`);
|
||||
try {
|
||||
@@ -481,19 +496,21 @@ export async function clickRunButton(page: Page): Promise<void> {
|
||||
// Check which button is visible and click it
|
||||
if (await setupTaskButton.isVisible()) {
|
||||
await setupTaskButton.click();
|
||||
await page
|
||||
const startTaskButton = page
|
||||
.getByRole("button", { name: /Start Task/i })
|
||||
.first()
|
||||
.click({ timeout: 10000 });
|
||||
.first();
|
||||
await startTaskButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await startTaskButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await newTaskButton.isVisible()) {
|
||||
await newTaskButton.click();
|
||||
await page
|
||||
const startTaskButton = page
|
||||
.getByRole("button", { name: /Start Task/i })
|
||||
.first()
|
||||
.click({ timeout: 10000 });
|
||||
.first();
|
||||
await startTaskButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await startTaskButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
237
autogpt_platform/frontend/src/tests/pages/monitor.page.ts
Normal file
237
autogpt_platform/frontend/src/tests/pages/monitor.page.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
import path from "path";
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
runCount: number;
|
||||
lastRun: string;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
started: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
graphName: string;
|
||||
nextExecution: string;
|
||||
schedule: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
enum ImportType {
|
||||
AGENT = "agent",
|
||||
TEMPLATE = "template",
|
||||
}
|
||||
|
||||
export class MonitorPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async isLoaded(): Promise<boolean> {
|
||||
console.log(`checking if monitor page is loaded`);
|
||||
try {
|
||||
// Wait for the monitor page
|
||||
await this.page.getByTestId("monitor-page").waitFor({
|
||||
state: "visible",
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Wait for table headers to be visible (indicates table structure is ready)
|
||||
await this.page.locator("thead th").first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Wait for either a table row or an empty tbody to be present
|
||||
await Promise.race([
|
||||
// Wait for at least one row
|
||||
this.page.locator("tbody tr[data-testid]").first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 15_000,
|
||||
}),
|
||||
// OR wait for an empty tbody (indicating no agents but table is loaded)
|
||||
this.page
|
||||
.locator("tbody[data-testid='agent-flow-list-body']:empty")
|
||||
.waitFor({
|
||||
state: "visible",
|
||||
timeout: 15_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listAgents(): Promise<Agent[]> {
|
||||
console.log(`listing agents`);
|
||||
// Wait for table rows to be available
|
||||
const rows = await this.page.locator("tbody tr[data-testid]").all();
|
||||
|
||||
const agents: Agent[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// Get the id from data-testid attribute
|
||||
const id = (await row.getAttribute("data-testid")) || "";
|
||||
|
||||
// Get columns - there are 3 cells per row (name, run count, last run)
|
||||
const cells = await row.locator("td").all();
|
||||
|
||||
// Extract name from first cell
|
||||
const name = (await row.getAttribute("data-name")) || "";
|
||||
|
||||
// Extract run count from second cell
|
||||
const runCountText = (await cells[1].textContent()) || "0";
|
||||
const runCount = parseInt(runCountText, 10);
|
||||
|
||||
// Extract last run from third cell's title attribute (contains full timestamp)
|
||||
// If no title, the cell will be empty indicating no last run
|
||||
const lastRunCell = cells[2];
|
||||
const lastRun = (await lastRunCell.getAttribute("title")) || "";
|
||||
|
||||
agents.push({
|
||||
id,
|
||||
name,
|
||||
runCount,
|
||||
lastRun,
|
||||
});
|
||||
}
|
||||
|
||||
agents.reduce((acc, agent) => {
|
||||
if (!agent.id.includes("flow-run")) {
|
||||
acc.push(agent);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Agent[]);
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
async listRuns(filter?: Agent): Promise<Run[]> {
|
||||
console.log(`listing runs`);
|
||||
// Wait for the runs table to be loaded - look for table header "Agent"
|
||||
await this.page.locator("[data-testid='flow-runs-list-body']").waitFor({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Get all run rows
|
||||
const rows = await this.page
|
||||
.locator('tbody tr[data-testid^="flow-run-"]')
|
||||
.all();
|
||||
|
||||
const runs: Run[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const runId = (await row.getAttribute("data-runid")) || "";
|
||||
const agentId = (await row.getAttribute("data-graphid")) || "";
|
||||
|
||||
// Get columns
|
||||
const cells = await row.locator("td").all();
|
||||
|
||||
// Parse data from cells
|
||||
const agentName = (await cells[0].textContent()) || "";
|
||||
const started = (await cells[1].textContent()) || "";
|
||||
const status = (await cells[2].locator("div").textContent()) || "";
|
||||
const duration = (await cells[3].textContent()) || "";
|
||||
|
||||
// Only add if no filter or if matches filter
|
||||
if (!filter || filter.id === agentId) {
|
||||
runs.push({
|
||||
id: runId,
|
||||
agentId: agentId,
|
||||
agentName: agentName.trim(),
|
||||
started: started.trim(),
|
||||
duration: parseFloat(duration.replace("s", "")),
|
||||
status: status.toLowerCase().trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
async listSchedules(): Promise<Schedule[]> {
|
||||
console.log(`listing schedules`);
|
||||
return [];
|
||||
}
|
||||
|
||||
async clickAgent(id: string) {
|
||||
console.log(`selecting agent ${id}`);
|
||||
await this.page.getByTestId(id).click();
|
||||
}
|
||||
|
||||
async clickCreateAgent(): Promise<void> {
|
||||
console.log(`clicking create agent`);
|
||||
await this.page.getByRole("link", { name: "Create" }).click();
|
||||
}
|
||||
|
||||
async importFromFile(
|
||||
directory: string,
|
||||
file: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
importType: ImportType = ImportType.AGENT,
|
||||
) {
|
||||
console.log(
|
||||
`importing from directory: ${directory} file: ${file} name: ${name} description: ${description} importType: ${importType}`,
|
||||
);
|
||||
await this.page.getByTestId("create-agent-dropdown").click();
|
||||
await this.page.getByTestId("import-agent-from-file").click();
|
||||
|
||||
await this.page
|
||||
.getByTestId("import-agent-file-input")
|
||||
.setInputFiles(path.join(directory, file));
|
||||
if (name) {
|
||||
console.log(`filling agent name: ${name}`);
|
||||
await this.page.getByTestId("agent-name-input").fill(name);
|
||||
}
|
||||
if (description) {
|
||||
console.log(`filling agent description: ${description}`);
|
||||
await this.page.getByTestId("agent-description-input").fill(description);
|
||||
}
|
||||
if (importType === ImportType.TEMPLATE) {
|
||||
console.log(`clicking import as template switch`);
|
||||
await this.page.getByTestId("import-as-template-switch").click();
|
||||
}
|
||||
console.log(`clicking import agent submit`);
|
||||
await this.page.getByTestId("import-agent-submit").click();
|
||||
}
|
||||
|
||||
async deleteAgent(agent: Agent) {
|
||||
console.log(`deleting agent ${agent.id} ${agent.name}`);
|
||||
}
|
||||
|
||||
async clickAllVersions(agent: Agent) {
|
||||
console.log(`clicking all versions for agent ${agent.id} ${agent.name}`);
|
||||
}
|
||||
|
||||
async openInBuilder(agent: Agent) {
|
||||
console.log(`opening agent ${agent.id} ${agent.name} in builder`);
|
||||
}
|
||||
|
||||
async exportToFile(agent: Agent) {
|
||||
await this.clickAgent(agent.id);
|
||||
|
||||
console.log(`exporting agent id: ${agent.id} name: ${agent.name} to file`);
|
||||
await this.page.getByTestId("export-button").click();
|
||||
}
|
||||
|
||||
async selectRun(agent: Agent, run: Run) {
|
||||
console.log(`selecting run ${run.id} for agent ${agent.id} ${agent.name}`);
|
||||
}
|
||||
|
||||
async openOutputs(agent: Agent, run: Run) {
|
||||
console.log(
|
||||
`opening outputs for run ${run.id} of agent ${agent.id} ${agent.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export class NavBar {
|
||||
await this.page.getByRole("link", { name: "Edit profile" }).click();
|
||||
}
|
||||
|
||||
async clickMonitorLink() {
|
||||
await this.page.getByTestId("navbar-link-library").click();
|
||||
}
|
||||
|
||||
async clickBuildLink() {
|
||||
const link = this.page.getByTestId("navbar-link-build");
|
||||
await link.waitFor({ state: "visible", timeout: 15000 });
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
## Advanced Setup
|
||||
|
||||
* [Advanced Setup](advanced_setup.md)
|
||||
* [Deployment Environment Variables](deployment-environment-variables.md)
|
||||
|
||||
## Building Blocks
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
# 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user