mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-12 15:55:03 -05:00
Compare commits
3 Commits
dev
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5348d97437 | ||
|
|
6573d987ea | ||
|
|
ae8ce8b4ca |
@@ -1,154 +0,0 @@
|
|||||||
"""Dummy Agent Generator for testing.
|
|
||||||
|
|
||||||
Returns mock responses matching the format expected from the external service.
|
|
||||||
Enable via AGENTGENERATOR_USE_DUMMY=true in settings.
|
|
||||||
|
|
||||||
WARNING: This is for testing only. Do not use in production.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Dummy decomposition result (instructions type)
|
|
||||||
DUMMY_DECOMPOSITION_RESULT: dict[str, Any] = {
|
|
||||||
"type": "instructions",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"description": "Get input from user",
|
|
||||||
"action": "input",
|
|
||||||
"block_name": "AgentInputBlock",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Process the input",
|
|
||||||
"action": "process",
|
|
||||||
"block_name": "TextFormatterBlock",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Return output to user",
|
|
||||||
"action": "output",
|
|
||||||
"block_name": "AgentOutputBlock",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Block IDs from backend/blocks/io.py
|
|
||||||
AGENT_INPUT_BLOCK_ID = "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b"
|
|
||||||
AGENT_OUTPUT_BLOCK_ID = "363ae599-353e-4804-937e-b2ee3cef3da4"
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_dummy_agent_json() -> dict[str, Any]:
|
|
||||||
"""Generate a minimal valid agent JSON for testing."""
|
|
||||||
input_node_id = str(uuid.uuid4())
|
|
||||||
output_node_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"version": 1,
|
|
||||||
"is_active": True,
|
|
||||||
"name": "Dummy Test Agent",
|
|
||||||
"description": "A dummy agent generated for testing purposes",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": input_node_id,
|
|
||||||
"block_id": AGENT_INPUT_BLOCK_ID,
|
|
||||||
"input_default": {
|
|
||||||
"name": "input",
|
|
||||||
"title": "Input",
|
|
||||||
"description": "Enter your input",
|
|
||||||
"placeholder_values": [],
|
|
||||||
},
|
|
||||||
"metadata": {"position": {"x": 0, "y": 0}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": output_node_id,
|
|
||||||
"block_id": AGENT_OUTPUT_BLOCK_ID,
|
|
||||||
"input_default": {
|
|
||||||
"name": "output",
|
|
||||||
"title": "Output",
|
|
||||||
"description": "Agent output",
|
|
||||||
"format": "{output}",
|
|
||||||
},
|
|
||||||
"metadata": {"position": {"x": 400, "y": 0}},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"source_id": input_node_id,
|
|
||||||
"sink_id": output_node_id,
|
|
||||||
"source_name": "result",
|
|
||||||
"sink_name": "value",
|
|
||||||
"is_static": False,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def decompose_goal_dummy(
|
|
||||||
description: str,
|
|
||||||
context: str = "",
|
|
||||||
library_agents: list[dict[str, Any]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy decomposition result."""
|
|
||||||
logger.info("Using dummy agent generator for decompose_goal")
|
|
||||||
return DUMMY_DECOMPOSITION_RESULT.copy()
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent_dummy(
|
|
||||||
instructions: dict[str, Any],
|
|
||||||
library_agents: list[dict[str, Any]] | None = None,
|
|
||||||
operation_id: str | None = None,
|
|
||||||
task_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy agent JSON after a simulated delay."""
|
|
||||||
logger.info("Using dummy agent generator for generate_agent (30s delay)")
|
|
||||||
await asyncio.sleep(30)
|
|
||||||
return _generate_dummy_agent_json()
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent_patch_dummy(
|
|
||||||
update_request: str,
|
|
||||||
current_agent: dict[str, Any],
|
|
||||||
library_agents: list[dict[str, Any]] | None = None,
|
|
||||||
operation_id: str | None = None,
|
|
||||||
task_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy patched agent (returns the current agent with updated description)."""
|
|
||||||
logger.info("Using dummy agent generator for generate_agent_patch")
|
|
||||||
patched = current_agent.copy()
|
|
||||||
patched["description"] = (
|
|
||||||
f"{current_agent.get('description', '')} (updated: {update_request})"
|
|
||||||
)
|
|
||||||
return patched
|
|
||||||
|
|
||||||
|
|
||||||
async def customize_template_dummy(
|
|
||||||
template_agent: dict[str, Any],
|
|
||||||
modification_request: str,
|
|
||||||
context: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy customized template (returns template with updated description)."""
|
|
||||||
logger.info("Using dummy agent generator for customize_template")
|
|
||||||
customized = template_agent.copy()
|
|
||||||
customized["description"] = (
|
|
||||||
f"{template_agent.get('description', '')} (customized: {modification_request})"
|
|
||||||
)
|
|
||||||
return customized
|
|
||||||
|
|
||||||
|
|
||||||
async def get_blocks_dummy() -> list[dict[str, Any]]:
|
|
||||||
"""Return dummy blocks list."""
|
|
||||||
logger.info("Using dummy agent generator for get_blocks")
|
|
||||||
return [
|
|
||||||
{"id": AGENT_INPUT_BLOCK_ID, "name": "AgentInputBlock"},
|
|
||||||
{"id": AGENT_OUTPUT_BLOCK_ID, "name": "AgentOutputBlock"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def health_check_dummy() -> bool:
|
|
||||||
"""Always returns healthy for dummy service."""
|
|
||||||
return True
|
|
||||||
@@ -12,19 +12,8 @@ import httpx
|
|||||||
|
|
||||||
from backend.util.settings import Settings
|
from backend.util.settings import Settings
|
||||||
|
|
||||||
from .dummy import (
|
|
||||||
customize_template_dummy,
|
|
||||||
decompose_goal_dummy,
|
|
||||||
generate_agent_dummy,
|
|
||||||
generate_agent_patch_dummy,
|
|
||||||
get_blocks_dummy,
|
|
||||||
health_check_dummy,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_dummy_mode_warned = False
|
|
||||||
|
|
||||||
|
|
||||||
def _create_error_response(
|
def _create_error_response(
|
||||||
error_message: str,
|
error_message: str,
|
||||||
@@ -101,26 +90,10 @@ def _get_settings() -> Settings:
|
|||||||
return _settings
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
def _is_dummy_mode() -> bool:
|
|
||||||
"""Check if dummy mode is enabled for testing."""
|
|
||||||
global _dummy_mode_warned
|
|
||||||
settings = _get_settings()
|
|
||||||
is_dummy = bool(settings.config.agentgenerator_use_dummy)
|
|
||||||
if is_dummy and not _dummy_mode_warned:
|
|
||||||
logger.warning(
|
|
||||||
"Agent Generator running in DUMMY MODE - returning mock responses. "
|
|
||||||
"Do not use in production!"
|
|
||||||
)
|
|
||||||
_dummy_mode_warned = True
|
|
||||||
return is_dummy
|
|
||||||
|
|
||||||
|
|
||||||
def is_external_service_configured() -> bool:
|
def is_external_service_configured() -> bool:
|
||||||
"""Check if external Agent Generator service is configured (or dummy mode)."""
|
"""Check if external Agent Generator service is configured."""
|
||||||
settings = _get_settings()
|
settings = _get_settings()
|
||||||
return bool(settings.config.agentgenerator_host) or bool(
|
return bool(settings.config.agentgenerator_host)
|
||||||
settings.config.agentgenerator_use_dummy
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_base_url() -> str:
|
def _get_base_url() -> str:
|
||||||
@@ -164,9 +137,6 @@ async def decompose_goal_external(
|
|||||||
- {"type": "error", "error": "...", "error_type": "..."} on error
|
- {"type": "error", "error": "...", "error_type": "..."} on error
|
||||||
Or None on unexpected error
|
Or None on unexpected error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await decompose_goal_dummy(description, context, library_agents)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
if context:
|
if context:
|
||||||
@@ -256,11 +226,6 @@ async def generate_agent_external(
|
|||||||
Returns:
|
Returns:
|
||||||
Agent JSON dict, {"status": "accepted"} for async, or error dict {"type": "error", ...} on error
|
Agent JSON dict, {"status": "accepted"} for async, or error dict {"type": "error", ...} on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await generate_agent_dummy(
|
|
||||||
instructions, library_agents, operation_id, task_id
|
|
||||||
)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
# Build request payload
|
# Build request payload
|
||||||
@@ -332,11 +297,6 @@ async def generate_agent_patch_external(
|
|||||||
Returns:
|
Returns:
|
||||||
Updated agent JSON, clarifying questions dict, {"status": "accepted"} for async, or error dict on error
|
Updated agent JSON, clarifying questions dict, {"status": "accepted"} for async, or error dict on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await generate_agent_patch_dummy(
|
|
||||||
update_request, current_agent, library_agents, operation_id, task_id
|
|
||||||
)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
# Build request payload
|
# Build request payload
|
||||||
@@ -423,11 +383,6 @@ async def customize_template_external(
|
|||||||
Returns:
|
Returns:
|
||||||
Customized agent JSON, clarifying questions dict, or error dict on error
|
Customized agent JSON, clarifying questions dict, or error dict on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await customize_template_dummy(
|
|
||||||
template_agent, modification_request, context
|
|
||||||
)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
request = modification_request
|
request = modification_request
|
||||||
@@ -490,9 +445,6 @@ async def get_blocks_external() -> list[dict[str, Any]] | None:
|
|||||||
Returns:
|
Returns:
|
||||||
List of block info dicts or None on error
|
List of block info dicts or None on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await get_blocks_dummy()
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -526,9 +478,6 @@ async def health_check() -> bool:
|
|||||||
if not is_external_service_configured():
|
if not is_external_service_configured():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if _is_dummy_mode():
|
|
||||||
return await health_check_dummy()
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
import shlex
|
import shlex
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING, Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from e2b import AsyncSandbox as BaseAsyncSandbox
|
from e2b import AsyncSandbox as BaseAsyncSandbox
|
||||||
from pydantic import SecretStr
|
from pydantic import BaseModel, SecretStr
|
||||||
|
|
||||||
from backend.blocks._base import (
|
from backend.blocks._base import (
|
||||||
Block,
|
Block,
|
||||||
@@ -20,13 +20,6 @@ from backend.data.model import (
|
|||||||
SchemaField,
|
SchemaField,
|
||||||
)
|
)
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
from backend.util.sandbox_files import (
|
|
||||||
SandboxFileOutput,
|
|
||||||
extract_and_store_sandbox_files,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.executor.utils import ExecutionContext
|
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCodeExecutionError(Exception):
|
class ClaudeCodeExecutionError(Exception):
|
||||||
@@ -181,15 +174,22 @@ class ClaudeCodeBlock(Block):
|
|||||||
advanced=True,
|
advanced=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class FileOutput(BaseModel):
|
||||||
|
"""A file extracted from the sandbox."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
relative_path: str # Path relative to working directory (for GitHub, etc.)
|
||||||
|
name: str
|
||||||
|
content: str
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
class Output(BlockSchemaOutput):
|
||||||
response: str = SchemaField(
|
response: str = SchemaField(
|
||||||
description="The output/response from Claude Code execution"
|
description="The output/response from Claude Code execution"
|
||||||
)
|
)
|
||||||
files: list[SandboxFileOutput] = SchemaField(
|
files: list["ClaudeCodeBlock.FileOutput"] = SchemaField(
|
||||||
description=(
|
description=(
|
||||||
"List of text files created/modified by Claude Code during this execution. "
|
"List of text files created/modified by Claude Code during this execution. "
|
||||||
"Each file has 'path', 'relative_path', 'name', 'content', and 'workspace_ref' fields. "
|
"Each file has 'path', 'relative_path', 'name', and 'content' fields."
|
||||||
"workspace_ref contains a workspace:// URI if the file was stored to workspace."
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conversation_history: str = SchemaField(
|
conversation_history: str = SchemaField(
|
||||||
@@ -252,7 +252,6 @@ class ClaudeCodeBlock(Block):
|
|||||||
"relative_path": "index.html",
|
"relative_path": "index.html",
|
||||||
"name": "index.html",
|
"name": "index.html",
|
||||||
"content": "<html>Hello World</html>",
|
"content": "<html>Hello World</html>",
|
||||||
"workspace_ref": None,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -268,12 +267,11 @@ class ClaudeCodeBlock(Block):
|
|||||||
"execute_claude_code": lambda *args, **kwargs: (
|
"execute_claude_code": lambda *args, **kwargs: (
|
||||||
"Created index.html with hello world content", # response
|
"Created index.html with hello world content", # response
|
||||||
[
|
[
|
||||||
SandboxFileOutput(
|
ClaudeCodeBlock.FileOutput(
|
||||||
path="/home/user/index.html",
|
path="/home/user/index.html",
|
||||||
relative_path="index.html",
|
relative_path="index.html",
|
||||||
name="index.html",
|
name="index.html",
|
||||||
content="<html>Hello World</html>",
|
content="<html>Hello World</html>",
|
||||||
workspace_ref=None,
|
|
||||||
)
|
)
|
||||||
], # files
|
], # files
|
||||||
"User: Create a hello world HTML file\n"
|
"User: Create a hello world HTML file\n"
|
||||||
@@ -296,8 +294,7 @@ class ClaudeCodeBlock(Block):
|
|||||||
existing_sandbox_id: str,
|
existing_sandbox_id: str,
|
||||||
conversation_history: str,
|
conversation_history: str,
|
||||||
dispose_sandbox: bool,
|
dispose_sandbox: bool,
|
||||||
execution_context: "ExecutionContext",
|
) -> tuple[str, list["ClaudeCodeBlock.FileOutput"], str, str, str]:
|
||||||
) -> tuple[str, list[SandboxFileOutput], str, str, str]:
|
|
||||||
"""
|
"""
|
||||||
Execute Claude Code in an E2B sandbox.
|
Execute Claude Code in an E2B sandbox.
|
||||||
|
|
||||||
@@ -452,18 +449,14 @@ class ClaudeCodeBlock(Block):
|
|||||||
else:
|
else:
|
||||||
new_conversation_history = turn_entry
|
new_conversation_history = turn_entry
|
||||||
|
|
||||||
# Extract files created/modified during this run and store to workspace
|
# Extract files created/modified during this run
|
||||||
sandbox_files = await extract_and_store_sandbox_files(
|
files = await self._extract_files(
|
||||||
sandbox=sandbox,
|
sandbox, working_directory, start_timestamp
|
||||||
working_directory=working_directory,
|
|
||||||
execution_context=execution_context,
|
|
||||||
since_timestamp=start_timestamp,
|
|
||||||
text_only=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
response,
|
response,
|
||||||
sandbox_files, # Already SandboxFileOutput objects
|
files,
|
||||||
new_conversation_history,
|
new_conversation_history,
|
||||||
current_session_id,
|
current_session_id,
|
||||||
sandbox_id,
|
sandbox_id,
|
||||||
@@ -478,6 +471,140 @@ class ClaudeCodeBlock(Block):
|
|||||||
if dispose_sandbox and sandbox:
|
if dispose_sandbox and sandbox:
|
||||||
await sandbox.kill()
|
await sandbox.kill()
|
||||||
|
|
||||||
|
async def _extract_files(
|
||||||
|
self,
|
||||||
|
sandbox: BaseAsyncSandbox,
|
||||||
|
working_directory: str,
|
||||||
|
since_timestamp: str | None = None,
|
||||||
|
) -> list["ClaudeCodeBlock.FileOutput"]:
|
||||||
|
"""
|
||||||
|
Extract text files created/modified during this Claude Code execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sandbox: The E2B sandbox instance
|
||||||
|
working_directory: Directory to search for files
|
||||||
|
since_timestamp: ISO timestamp - only return files modified after this time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FileOutput objects with path, relative_path, name, and content
|
||||||
|
"""
|
||||||
|
files: list[ClaudeCodeBlock.FileOutput] = []
|
||||||
|
|
||||||
|
# Text file extensions we can safely read as text
|
||||||
|
text_extensions = {
|
||||||
|
".txt",
|
||||||
|
".md",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".css",
|
||||||
|
".js",
|
||||||
|
".ts",
|
||||||
|
".jsx",
|
||||||
|
".tsx",
|
||||||
|
".json",
|
||||||
|
".xml",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".toml",
|
||||||
|
".ini",
|
||||||
|
".cfg",
|
||||||
|
".conf",
|
||||||
|
".py",
|
||||||
|
".rb",
|
||||||
|
".php",
|
||||||
|
".java",
|
||||||
|
".c",
|
||||||
|
".cpp",
|
||||||
|
".h",
|
||||||
|
".hpp",
|
||||||
|
".cs",
|
||||||
|
".go",
|
||||||
|
".rs",
|
||||||
|
".swift",
|
||||||
|
".kt",
|
||||||
|
".scala",
|
||||||
|
".sh",
|
||||||
|
".bash",
|
||||||
|
".zsh",
|
||||||
|
".sql",
|
||||||
|
".graphql",
|
||||||
|
".env",
|
||||||
|
".gitignore",
|
||||||
|
".dockerfile",
|
||||||
|
"Dockerfile",
|
||||||
|
".vue",
|
||||||
|
".svelte",
|
||||||
|
".astro",
|
||||||
|
".mdx",
|
||||||
|
".rst",
|
||||||
|
".tex",
|
||||||
|
".csv",
|
||||||
|
".log",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# List files recursively using find command
|
||||||
|
# Exclude node_modules and .git directories, but allow hidden files
|
||||||
|
# like .env and .gitignore (they're filtered by text_extensions later)
|
||||||
|
# Filter by timestamp to only get files created/modified during this run
|
||||||
|
safe_working_dir = shlex.quote(working_directory)
|
||||||
|
timestamp_filter = ""
|
||||||
|
if since_timestamp:
|
||||||
|
timestamp_filter = f"-newermt {shlex.quote(since_timestamp)} "
|
||||||
|
find_result = await sandbox.commands.run(
|
||||||
|
f"find {safe_working_dir} -type f "
|
||||||
|
f"{timestamp_filter}"
|
||||||
|
f"-not -path '*/node_modules/*' "
|
||||||
|
f"-not -path '*/.git/*' "
|
||||||
|
f"2>/dev/null"
|
||||||
|
)
|
||||||
|
|
||||||
|
if find_result.stdout:
|
||||||
|
for file_path in find_result.stdout.strip().split("\n"):
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if it's a text file we can read
|
||||||
|
is_text = any(
|
||||||
|
file_path.endswith(ext) for ext in text_extensions
|
||||||
|
) or file_path.endswith("Dockerfile")
|
||||||
|
|
||||||
|
if is_text:
|
||||||
|
try:
|
||||||
|
content = await sandbox.files.read(file_path)
|
||||||
|
# Handle bytes or string
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
content = content.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# Extract filename from path
|
||||||
|
file_name = file_path.split("/")[-1]
|
||||||
|
|
||||||
|
# Calculate relative path by stripping working directory
|
||||||
|
relative_path = file_path
|
||||||
|
if file_path.startswith(working_directory):
|
||||||
|
relative_path = file_path[len(working_directory) :]
|
||||||
|
# Remove leading slash if present
|
||||||
|
if relative_path.startswith("/"):
|
||||||
|
relative_path = relative_path[1:]
|
||||||
|
|
||||||
|
files.append(
|
||||||
|
ClaudeCodeBlock.FileOutput(
|
||||||
|
path=file_path,
|
||||||
|
relative_path=relative_path,
|
||||||
|
name=file_name,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Skip files that can't be read
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# If file extraction fails, return empty results
|
||||||
|
pass
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
def _escape_prompt(self, prompt: str) -> str:
|
def _escape_prompt(self, prompt: str) -> str:
|
||||||
"""Escape the prompt for safe shell execution."""
|
"""Escape the prompt for safe shell execution."""
|
||||||
# Use single quotes and escape any single quotes in the prompt
|
# Use single quotes and escape any single quotes in the prompt
|
||||||
@@ -490,7 +617,6 @@ class ClaudeCodeBlock(Block):
|
|||||||
*,
|
*,
|
||||||
e2b_credentials: APIKeyCredentials,
|
e2b_credentials: APIKeyCredentials,
|
||||||
anthropic_credentials: APIKeyCredentials,
|
anthropic_credentials: APIKeyCredentials,
|
||||||
execution_context: "ExecutionContext",
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
try:
|
try:
|
||||||
@@ -511,7 +637,6 @@ class ClaudeCodeBlock(Block):
|
|||||||
existing_sandbox_id=input_data.sandbox_id,
|
existing_sandbox_id=input_data.sandbox_id,
|
||||||
conversation_history=input_data.conversation_history,
|
conversation_history=input_data.conversation_history,
|
||||||
dispose_sandbox=input_data.dispose_sandbox,
|
dispose_sandbox=input_data.dispose_sandbox,
|
||||||
execution_context=execution_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
yield "response", response
|
yield "response", response
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from e2b_code_interpreter import AsyncSandbox
|
from e2b_code_interpreter import AsyncSandbox
|
||||||
from e2b_code_interpreter import Result as E2BExecutionResult
|
from e2b_code_interpreter import Result as E2BExecutionResult
|
||||||
@@ -20,13 +20,6 @@ from backend.data.model import (
|
|||||||
SchemaField,
|
SchemaField,
|
||||||
)
|
)
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
from backend.util.sandbox_files import (
|
|
||||||
SandboxFileOutput,
|
|
||||||
extract_and_store_sandbox_files,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from backend.executor.utils import ExecutionContext
|
|
||||||
|
|
||||||
TEST_CREDENTIALS = APIKeyCredentials(
|
TEST_CREDENTIALS = APIKeyCredentials(
|
||||||
id="01234567-89ab-cdef-0123-456789abcdef",
|
id="01234567-89ab-cdef-0123-456789abcdef",
|
||||||
@@ -92,9 +85,6 @@ class CodeExecutionResult(MainCodeExecutionResult):
|
|||||||
class BaseE2BExecutorMixin:
|
class BaseE2BExecutorMixin:
|
||||||
"""Shared implementation methods for E2B executor blocks."""
|
"""Shared implementation methods for E2B executor blocks."""
|
||||||
|
|
||||||
# Default working directory in E2B sandboxes
|
|
||||||
WORKING_DIR = "/home/user"
|
|
||||||
|
|
||||||
async def execute_code(
|
async def execute_code(
|
||||||
self,
|
self,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
@@ -105,21 +95,14 @@ class BaseE2BExecutorMixin:
|
|||||||
timeout: Optional[int] = None,
|
timeout: Optional[int] = None,
|
||||||
sandbox_id: Optional[str] = None,
|
sandbox_id: Optional[str] = None,
|
||||||
dispose_sandbox: bool = False,
|
dispose_sandbox: bool = False,
|
||||||
execution_context: Optional["ExecutionContext"] = None,
|
|
||||||
extract_files: bool = False,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Unified code execution method that handles all three use cases:
|
Unified code execution method that handles all three use cases:
|
||||||
1. Create new sandbox and execute (ExecuteCodeBlock)
|
1. Create new sandbox and execute (ExecuteCodeBlock)
|
||||||
2. Create new sandbox, execute, and return sandbox_id (InstantiateCodeSandboxBlock)
|
2. Create new sandbox, execute, and return sandbox_id (InstantiateCodeSandboxBlock)
|
||||||
3. Connect to existing sandbox and execute (ExecuteCodeStepBlock)
|
3. Connect to existing sandbox and execute (ExecuteCodeStepBlock)
|
||||||
|
|
||||||
Args:
|
|
||||||
extract_files: If True and execution_context provided, extract files
|
|
||||||
created/modified during execution and store to workspace.
|
|
||||||
""" # noqa
|
""" # noqa
|
||||||
sandbox = None
|
sandbox = None
|
||||||
files: list[SandboxFileOutput] = []
|
|
||||||
try:
|
try:
|
||||||
if sandbox_id:
|
if sandbox_id:
|
||||||
# Connect to existing sandbox (ExecuteCodeStepBlock case)
|
# Connect to existing sandbox (ExecuteCodeStepBlock case)
|
||||||
@@ -135,12 +118,6 @@ class BaseE2BExecutorMixin:
|
|||||||
for cmd in setup_commands:
|
for cmd in setup_commands:
|
||||||
await sandbox.commands.run(cmd)
|
await sandbox.commands.run(cmd)
|
||||||
|
|
||||||
# Capture timestamp before execution to scope file extraction
|
|
||||||
start_timestamp = None
|
|
||||||
if extract_files:
|
|
||||||
ts_result = await sandbox.commands.run("date -u +%Y-%m-%dT%H:%M:%S")
|
|
||||||
start_timestamp = ts_result.stdout.strip() if ts_result.stdout else None
|
|
||||||
|
|
||||||
# Execute the code
|
# Execute the code
|
||||||
execution = await sandbox.run_code(
|
execution = await sandbox.run_code(
|
||||||
code,
|
code,
|
||||||
@@ -156,24 +133,7 @@ class BaseE2BExecutorMixin:
|
|||||||
stdout_logs = "".join(execution.logs.stdout)
|
stdout_logs = "".join(execution.logs.stdout)
|
||||||
stderr_logs = "".join(execution.logs.stderr)
|
stderr_logs = "".join(execution.logs.stderr)
|
||||||
|
|
||||||
# Extract files created/modified during this execution
|
return results, text_output, stdout_logs, stderr_logs, sandbox.sandbox_id
|
||||||
if extract_files and execution_context:
|
|
||||||
files = await extract_and_store_sandbox_files(
|
|
||||||
sandbox=sandbox,
|
|
||||||
working_directory=self.WORKING_DIR,
|
|
||||||
execution_context=execution_context,
|
|
||||||
since_timestamp=start_timestamp,
|
|
||||||
text_only=False, # Include binary files too
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
results,
|
|
||||||
text_output,
|
|
||||||
stdout_logs,
|
|
||||||
stderr_logs,
|
|
||||||
sandbox.sandbox_id,
|
|
||||||
files,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
# Dispose of sandbox if requested to reduce usage costs
|
# Dispose of sandbox if requested to reduce usage costs
|
||||||
if dispose_sandbox and sandbox:
|
if dispose_sandbox and sandbox:
|
||||||
@@ -278,12 +238,6 @@ class ExecuteCodeBlock(Block, BaseE2BExecutorMixin):
|
|||||||
description="Standard output logs from execution"
|
description="Standard output logs from execution"
|
||||||
)
|
)
|
||||||
stderr_logs: str = SchemaField(description="Standard error logs from execution")
|
stderr_logs: str = SchemaField(description="Standard error logs from execution")
|
||||||
files: list[SandboxFileOutput] = SchemaField(
|
|
||||||
description=(
|
|
||||||
"Files created or modified during execution. "
|
|
||||||
"Each file has path, name, content, and workspace_ref (if stored)."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -305,30 +259,23 @@ class ExecuteCodeBlock(Block, BaseE2BExecutorMixin):
|
|||||||
("results", []),
|
("results", []),
|
||||||
("response", "Hello World"),
|
("response", "Hello World"),
|
||||||
("stdout_logs", "Hello World\n"),
|
("stdout_logs", "Hello World\n"),
|
||||||
("files", []),
|
|
||||||
],
|
],
|
||||||
test_mock={
|
test_mock={
|
||||||
"execute_code": lambda api_key, code, language, template_id, setup_commands, timeout, dispose_sandbox, execution_context, extract_files: ( # noqa
|
"execute_code": lambda api_key, code, language, template_id, setup_commands, timeout, dispose_sandbox: ( # noqa
|
||||||
[], # results
|
[], # results
|
||||||
"Hello World", # text_output
|
"Hello World", # text_output
|
||||||
"Hello World\n", # stdout_logs
|
"Hello World\n", # stdout_logs
|
||||||
"", # stderr_logs
|
"", # stderr_logs
|
||||||
"sandbox_id", # sandbox_id
|
"sandbox_id", # sandbox_id
|
||||||
[], # files
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
credentials: APIKeyCredentials,
|
|
||||||
execution_context: "ExecutionContext",
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
try:
|
try:
|
||||||
results, text_output, stdout, stderr, _, files = await self.execute_code(
|
results, text_output, stdout, stderr, _ = await self.execute_code(
|
||||||
api_key=credentials.api_key.get_secret_value(),
|
api_key=credentials.api_key.get_secret_value(),
|
||||||
code=input_data.code,
|
code=input_data.code,
|
||||||
language=input_data.language,
|
language=input_data.language,
|
||||||
@@ -336,8 +283,6 @@ class ExecuteCodeBlock(Block, BaseE2BExecutorMixin):
|
|||||||
setup_commands=input_data.setup_commands,
|
setup_commands=input_data.setup_commands,
|
||||||
timeout=input_data.timeout,
|
timeout=input_data.timeout,
|
||||||
dispose_sandbox=input_data.dispose_sandbox,
|
dispose_sandbox=input_data.dispose_sandbox,
|
||||||
execution_context=execution_context,
|
|
||||||
extract_files=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine result object shape & filter out empty formats
|
# Determine result object shape & filter out empty formats
|
||||||
@@ -351,8 +296,6 @@ class ExecuteCodeBlock(Block, BaseE2BExecutorMixin):
|
|||||||
yield "stdout_logs", stdout
|
yield "stdout_logs", stdout
|
||||||
if stderr:
|
if stderr:
|
||||||
yield "stderr_logs", stderr
|
yield "stderr_logs", stderr
|
||||||
# Always yield files (empty list if none)
|
|
||||||
yield "files", [f.model_dump() for f in files]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield "error", str(e)
|
yield "error", str(e)
|
||||||
|
|
||||||
@@ -450,7 +393,6 @@ class InstantiateCodeSandboxBlock(Block, BaseE2BExecutorMixin):
|
|||||||
"Hello World\n", # stdout_logs
|
"Hello World\n", # stdout_logs
|
||||||
"", # stderr_logs
|
"", # stderr_logs
|
||||||
"sandbox_id", # sandbox_id
|
"sandbox_id", # sandbox_id
|
||||||
[], # files
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -459,7 +401,7 @@ class InstantiateCodeSandboxBlock(Block, BaseE2BExecutorMixin):
|
|||||||
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
|
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
try:
|
try:
|
||||||
_, text_output, stdout, stderr, sandbox_id, _ = await self.execute_code(
|
_, text_output, stdout, stderr, sandbox_id = await self.execute_code(
|
||||||
api_key=credentials.api_key.get_secret_value(),
|
api_key=credentials.api_key.get_secret_value(),
|
||||||
code=input_data.setup_code,
|
code=input_data.setup_code,
|
||||||
language=input_data.language,
|
language=input_data.language,
|
||||||
@@ -558,7 +500,6 @@ class ExecuteCodeStepBlock(Block, BaseE2BExecutorMixin):
|
|||||||
"Hello World\n", # stdout_logs
|
"Hello World\n", # stdout_logs
|
||||||
"", # stderr_logs
|
"", # stderr_logs
|
||||||
sandbox_id, # sandbox_id
|
sandbox_id, # sandbox_id
|
||||||
[], # files
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -567,7 +508,7 @@ class ExecuteCodeStepBlock(Block, BaseE2BExecutorMixin):
|
|||||||
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
|
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
try:
|
try:
|
||||||
results, text_output, stdout, stderr, _, _ = await self.execute_code(
|
results, text_output, stdout, stderr, _ = await self.execute_code(
|
||||||
api_key=credentials.api_key.get_secret_value(),
|
api_key=credentials.api_key.get_secret_value(),
|
||||||
code=input_data.step_code,
|
code=input_data.step_code,
|
||||||
language=input_data.language,
|
language=input_data.language,
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
"""
|
|
||||||
Shared utilities for extracting and storing files from E2B sandboxes.
|
|
||||||
|
|
||||||
This module provides common file extraction and workspace storage functionality
|
|
||||||
for blocks that run code in E2B sandboxes (Claude Code, Code Executor, etc.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import mimetypes
|
|
||||||
import shlex
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from backend.util.file import store_media_file
|
|
||||||
from backend.util.type import MediaFileType
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from e2b import AsyncSandbox as BaseAsyncSandbox
|
|
||||||
|
|
||||||
from backend.executor.utils import ExecutionContext
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Text file extensions that can be safely read and stored as text
|
|
||||||
TEXT_EXTENSIONS = {
|
|
||||||
".txt",
|
|
||||||
".md",
|
|
||||||
".html",
|
|
||||||
".htm",
|
|
||||||
".css",
|
|
||||||
".js",
|
|
||||||
".ts",
|
|
||||||
".jsx",
|
|
||||||
".tsx",
|
|
||||||
".json",
|
|
||||||
".xml",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".toml",
|
|
||||||
".ini",
|
|
||||||
".cfg",
|
|
||||||
".conf",
|
|
||||||
".py",
|
|
||||||
".rb",
|
|
||||||
".php",
|
|
||||||
".java",
|
|
||||||
".c",
|
|
||||||
".cpp",
|
|
||||||
".h",
|
|
||||||
".hpp",
|
|
||||||
".cs",
|
|
||||||
".go",
|
|
||||||
".rs",
|
|
||||||
".swift",
|
|
||||||
".kt",
|
|
||||||
".scala",
|
|
||||||
".sh",
|
|
||||||
".bash",
|
|
||||||
".zsh",
|
|
||||||
".sql",
|
|
||||||
".graphql",
|
|
||||||
".env",
|
|
||||||
".gitignore",
|
|
||||||
".dockerfile",
|
|
||||||
"Dockerfile",
|
|
||||||
".vue",
|
|
||||||
".svelte",
|
|
||||||
".astro",
|
|
||||||
".mdx",
|
|
||||||
".rst",
|
|
||||||
".tex",
|
|
||||||
".csv",
|
|
||||||
".log",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxFileOutput(BaseModel):
|
|
||||||
"""A file extracted from a sandbox and optionally stored in workspace."""
|
|
||||||
|
|
||||||
path: str
|
|
||||||
"""Full path in the sandbox."""
|
|
||||||
|
|
||||||
relative_path: str
|
|
||||||
"""Path relative to the working directory."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
"""Filename only."""
|
|
||||||
|
|
||||||
content: str
|
|
||||||
"""File content as text (for backward compatibility)."""
|
|
||||||
|
|
||||||
workspace_ref: str | None = None
|
|
||||||
"""Workspace reference (workspace://{id}#mime) if stored, None otherwise."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExtractedFile:
|
|
||||||
"""Internal representation of an extracted file before storage."""
|
|
||||||
|
|
||||||
path: str
|
|
||||||
relative_path: str
|
|
||||||
name: str
|
|
||||||
content: bytes
|
|
||||||
is_text: bool
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_sandbox_files(
|
|
||||||
sandbox: "BaseAsyncSandbox",
|
|
||||||
working_directory: str,
|
|
||||||
since_timestamp: str | None = None,
|
|
||||||
text_only: bool = True,
|
|
||||||
) -> list[ExtractedFile]:
|
|
||||||
"""
|
|
||||||
Extract files from an E2B sandbox.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sandbox: The E2B sandbox instance
|
|
||||||
working_directory: Directory to search for files
|
|
||||||
since_timestamp: ISO timestamp - only return files modified after this time
|
|
||||||
text_only: If True, only extract text files (default). If False, extract all files.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of ExtractedFile objects with path, content, and metadata
|
|
||||||
"""
|
|
||||||
files: list[ExtractedFile] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build find command
|
|
||||||
safe_working_dir = shlex.quote(working_directory)
|
|
||||||
timestamp_filter = ""
|
|
||||||
if since_timestamp:
|
|
||||||
timestamp_filter = f"-newermt {shlex.quote(since_timestamp)} "
|
|
||||||
|
|
||||||
find_result = await sandbox.commands.run(
|
|
||||||
f"find {safe_working_dir} -type f "
|
|
||||||
f"{timestamp_filter}"
|
|
||||||
f"-not -path '*/node_modules/*' "
|
|
||||||
f"-not -path '*/.git/*' "
|
|
||||||
f"2>/dev/null"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not find_result.stdout:
|
|
||||||
return files
|
|
||||||
|
|
||||||
for file_path in find_result.stdout.strip().split("\n"):
|
|
||||||
if not file_path:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if it's a text file
|
|
||||||
is_text = any(file_path.endswith(ext) for ext in TEXT_EXTENSIONS)
|
|
||||||
|
|
||||||
# Skip non-text files if text_only mode
|
|
||||||
if text_only and not is_text:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Read file content as bytes
|
|
||||||
content = await sandbox.files.read(file_path, format="bytes")
|
|
||||||
if isinstance(content, str):
|
|
||||||
content = content.encode("utf-8")
|
|
||||||
elif isinstance(content, bytearray):
|
|
||||||
content = bytes(content)
|
|
||||||
|
|
||||||
# Extract filename from path
|
|
||||||
file_name = file_path.split("/")[-1]
|
|
||||||
|
|
||||||
# Calculate relative path
|
|
||||||
relative_path = file_path
|
|
||||||
if file_path.startswith(working_directory):
|
|
||||||
relative_path = file_path[len(working_directory) :]
|
|
||||||
if relative_path.startswith("/"):
|
|
||||||
relative_path = relative_path[1:]
|
|
||||||
|
|
||||||
files.append(
|
|
||||||
ExtractedFile(
|
|
||||||
path=file_path,
|
|
||||||
relative_path=relative_path,
|
|
||||||
name=file_name,
|
|
||||||
content=content,
|
|
||||||
is_text=is_text,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Failed to read file {file_path}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"File extraction failed: {e}")
|
|
||||||
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
async def store_sandbox_files(
|
|
||||||
extracted_files: list[ExtractedFile],
|
|
||||||
execution_context: "ExecutionContext",
|
|
||||||
) -> list[SandboxFileOutput]:
|
|
||||||
"""
|
|
||||||
Store extracted sandbox files to workspace and return output objects.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
extracted_files: List of files extracted from sandbox
|
|
||||||
execution_context: Execution context for workspace storage
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of SandboxFileOutput objects with workspace refs
|
|
||||||
"""
|
|
||||||
outputs: list[SandboxFileOutput] = []
|
|
||||||
|
|
||||||
for file in extracted_files:
|
|
||||||
# Decode content for text files (for backward compat content field)
|
|
||||||
if file.is_text:
|
|
||||||
try:
|
|
||||||
content_str = file.content.decode("utf-8", errors="replace")
|
|
||||||
except Exception:
|
|
||||||
content_str = ""
|
|
||||||
else:
|
|
||||||
content_str = f"[Binary file: {len(file.content)} bytes]"
|
|
||||||
|
|
||||||
# Build data URI (needed for storage and as binary fallback)
|
|
||||||
mime_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
|
||||||
data_uri = f"data:{mime_type};base64,{base64.b64encode(file.content).decode()}"
|
|
||||||
|
|
||||||
# Try to store in workspace
|
|
||||||
workspace_ref: str | None = None
|
|
||||||
try:
|
|
||||||
result = await store_media_file(
|
|
||||||
file=MediaFileType(data_uri),
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
if result.startswith("workspace://"):
|
|
||||||
workspace_ref = result
|
|
||||||
elif not file.is_text:
|
|
||||||
# Non-workspace context (graph execution): store_media_file
|
|
||||||
# returned a data URI — use it as content so binary data isn't lost.
|
|
||||||
content_str = result
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to store file {file.name} to workspace: {e}")
|
|
||||||
# For binary files, fall back to data URI to prevent data loss
|
|
||||||
if not file.is_text:
|
|
||||||
content_str = data_uri
|
|
||||||
|
|
||||||
outputs.append(
|
|
||||||
SandboxFileOutput(
|
|
||||||
path=file.path,
|
|
||||||
relative_path=file.relative_path,
|
|
||||||
name=file.name,
|
|
||||||
content=content_str,
|
|
||||||
workspace_ref=workspace_ref,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return outputs
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_and_store_sandbox_files(
|
|
||||||
sandbox: "BaseAsyncSandbox",
|
|
||||||
working_directory: str,
|
|
||||||
execution_context: "ExecutionContext",
|
|
||||||
since_timestamp: str | None = None,
|
|
||||||
text_only: bool = True,
|
|
||||||
) -> list[SandboxFileOutput]:
|
|
||||||
"""
|
|
||||||
Extract files from sandbox and store them in workspace.
|
|
||||||
|
|
||||||
This is the main entry point combining extraction and storage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sandbox: The E2B sandbox instance
|
|
||||||
working_directory: Directory to search for files
|
|
||||||
execution_context: Execution context for workspace storage
|
|
||||||
since_timestamp: ISO timestamp - only return files modified after this time
|
|
||||||
text_only: If True, only extract text files
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of SandboxFileOutput objects with content and workspace refs
|
|
||||||
"""
|
|
||||||
extracted = await extract_sandbox_files(
|
|
||||||
sandbox=sandbox,
|
|
||||||
working_directory=working_directory,
|
|
||||||
since_timestamp=since_timestamp,
|
|
||||||
text_only=text_only,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await store_sandbox_files(extracted, execution_context)
|
|
||||||
@@ -368,10 +368,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
|||||||
default=600,
|
default=600,
|
||||||
description="The timeout in seconds for Agent Generator service requests (includes retries for rate limits)",
|
description="The timeout in seconds for Agent Generator service requests (includes retries for rate limits)",
|
||||||
)
|
)
|
||||||
agentgenerator_use_dummy: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Use dummy agent generator responses for testing (bypasses external service)",
|
|
||||||
)
|
|
||||||
|
|
||||||
enable_example_blocks: bool = Field(
|
enable_example_blocks: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class TestServiceConfiguration:
|
|||||||
"""Test that external service is not configured when host is empty."""
|
"""Test that external service is not configured when host is empty."""
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.config.agentgenerator_host = ""
|
mock_settings.config.agentgenerator_host = ""
|
||||||
mock_settings.config.agentgenerator_use_dummy = False
|
|
||||||
|
|
||||||
with patch.object(service, "_get_settings", return_value=mock_settings):
|
with patch.object(service, "_get_settings", return_value=mock_settings):
|
||||||
assert service.is_external_service_configured() is False
|
assert service.is_external_service_configured() is False
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export const ChatMessagesContainer = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Conversation className="min-h-0 flex-1">
|
<Conversation className="min-h-0 flex-1">
|
||||||
<ConversationContent className="flex flex-1 flex-col gap-6 px-3 py-6">
|
<ConversationContent className="flex min-h-screen flex-1 flex-col gap-6 px-3 py-6">
|
||||||
{isLoading && messages.length === 0 && (
|
{isLoading && messages.length === 0 && (
|
||||||
<div className="flex min-h-full flex-1 items-center justify-center">
|
<div className="flex min-h-full flex-1 items-center justify-center">
|
||||||
<LoadingSpinner className="text-neutral-600" />
|
<LoadingSpinner className="text-neutral-600" />
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { parseAsString, useQueryState } from "nuqs";
|
||||||
|
|
||||||
|
export function useCopilotSessionId() {
|
||||||
|
const [urlSessionId, setUrlSessionId] = useQueryState(
|
||||||
|
"sessionId",
|
||||||
|
parseAsString,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { urlSessionId, setUrlSessionId };
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { getGetV2GetSessionQueryKey } from "@/app/api/__generated__/endpoints/chat/chat";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { UIDataTypes, UIMessage, UITools } from "ai";
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { convertChatSessionMessagesToUiMessages } from "../helpers/convertChatSessionToUiMessages";
|
|
||||||
|
|
||||||
const OPERATING_TYPES = new Set([
|
|
||||||
"operation_started",
|
|
||||||
"operation_pending",
|
|
||||||
"operation_in_progress",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1_500;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects whether any message contains a tool part whose output indicates
|
|
||||||
* a long-running operation is still in progress.
|
|
||||||
*/
|
|
||||||
function hasOperatingTool(
|
|
||||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
|
||||||
) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
for (const part of msg.parts) {
|
|
||||||
if (!part.type.startsWith("tool-")) continue;
|
|
||||||
const toolPart = part as { output?: unknown };
|
|
||||||
if (!toolPart.output) continue;
|
|
||||||
const output =
|
|
||||||
typeof toolPart.output === "string"
|
|
||||||
? safeParse(toolPart.output)
|
|
||||||
: toolPart.output;
|
|
||||||
if (
|
|
||||||
output &&
|
|
||||||
typeof output === "object" &&
|
|
||||||
"type" in output &&
|
|
||||||
OPERATING_TYPES.has((output as { type: string }).type)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeParse(value: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Polls the session endpoint while any tool is in an "operating" state
|
|
||||||
* (operation_started / operation_pending / operation_in_progress).
|
|
||||||
*
|
|
||||||
* When the session data shows the tool output has changed (e.g. to
|
|
||||||
* agent_saved), it calls `setMessages` with the updated messages.
|
|
||||||
*/
|
|
||||||
export function useLongRunningToolPolling(
|
|
||||||
sessionId: string | null,
|
|
||||||
messages: UIMessage<unknown, UIDataTypes, UITools>[],
|
|
||||||
setMessages: (
|
|
||||||
updater: (
|
|
||||||
prev: UIMessage<unknown, UIDataTypes, UITools>[],
|
|
||||||
) => UIMessage<unknown, UIDataTypes, UITools>[],
|
|
||||||
) => void,
|
|
||||||
) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const poll = useCallback(async () => {
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
// Invalidate the query cache so the next fetch gets fresh data
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV2GetSessionQueryKey(sessionId),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch fresh session data
|
|
||||||
const data = queryClient.getQueryData<{
|
|
||||||
status: number;
|
|
||||||
data: { messages?: unknown[] };
|
|
||||||
}>(getGetV2GetSessionQueryKey(sessionId));
|
|
||||||
|
|
||||||
if (data?.status !== 200 || !data.data.messages) return;
|
|
||||||
|
|
||||||
const freshMessages = convertChatSessionMessagesToUiMessages(
|
|
||||||
sessionId,
|
|
||||||
data.data.messages,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!freshMessages || freshMessages.length === 0) return;
|
|
||||||
|
|
||||||
// Update when the long-running tool completed
|
|
||||||
if (!hasOperatingTool(freshMessages)) {
|
|
||||||
setMessages(() => freshMessages);
|
|
||||||
stopPolling();
|
|
||||||
}
|
|
||||||
}, [sessionId, queryClient, setMessages, stopPolling]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const shouldPoll = hasOperatingTool(messages);
|
|
||||||
|
|
||||||
// Always clear any previous interval first so we never leak timers
|
|
||||||
// when the effect re-runs due to dependency changes (e.g. messages
|
|
||||||
// updating as the LLM streams text after the tool call).
|
|
||||||
stopPolling();
|
|
||||||
|
|
||||||
if (shouldPoll && sessionId) {
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
poll();
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopPolling();
|
|
||||||
};
|
|
||||||
}, [messages, sessionId, poll, stopPolling]);
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { WarningDiamondIcon } from "@phosphor-icons/react";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import {
|
|
||||||
BookOpenIcon,
|
|
||||||
CheckFatIcon,
|
|
||||||
PencilSimpleIcon,
|
|
||||||
WarningDiamondIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import type { ToolUIPart } from "ai";
|
import type { ToolUIPart } from "ai";
|
||||||
import NextLink from "next/link";
|
|
||||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||||
|
import { ProgressBar } from "../../components/ProgressBar/ProgressBar";
|
||||||
import {
|
import {
|
||||||
ContentCardDescription,
|
ContentCardDescription,
|
||||||
ContentCodeBlock,
|
ContentCodeBlock,
|
||||||
ContentGrid,
|
ContentGrid,
|
||||||
ContentHint,
|
ContentHint,
|
||||||
|
ContentLink,
|
||||||
ContentMessage,
|
ContentMessage,
|
||||||
} from "../../components/ToolAccordion/AccordionContent";
|
} from "../../components/ToolAccordion/AccordionContent";
|
||||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||||
|
import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress";
|
||||||
import {
|
import {
|
||||||
ClarificationQuestionsCard,
|
ClarificationQuestionsCard,
|
||||||
ClarifyingQuestion,
|
ClarifyingQuestion,
|
||||||
} from "./components/ClarificationQuestionsCard";
|
} from "./components/ClarificationQuestionsCard";
|
||||||
import { MiniGame } from "./components/MiniGame/MiniGame";
|
|
||||||
import {
|
import {
|
||||||
AccordionIcon,
|
AccordionIcon,
|
||||||
formatMaybeJson,
|
formatMaybeJson,
|
||||||
@@ -58,7 +52,7 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
|
|||||||
const icon = <AccordionIcon />;
|
const icon = <AccordionIcon />;
|
||||||
|
|
||||||
if (isAgentSavedOutput(output)) {
|
if (isAgentSavedOutput(output)) {
|
||||||
return { icon, title: output.agent_name, expanded: true };
|
return { icon, title: output.agent_name };
|
||||||
}
|
}
|
||||||
if (isAgentPreviewOutput(output)) {
|
if (isAgentPreviewOutput(output)) {
|
||||||
return {
|
return {
|
||||||
@@ -84,7 +78,6 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
|
|||||||
return {
|
return {
|
||||||
icon,
|
icon,
|
||||||
title: "Creating agent, this may take a few minutes. Sit back and relax.",
|
title: "Creating agent, this may take a few minutes. Sit back and relax.",
|
||||||
expanded: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -114,6 +107,8 @@ export function CreateAgentTool({ part }: Props) {
|
|||||||
isOperationPendingOutput(output) ||
|
isOperationPendingOutput(output) ||
|
||||||
isOperationInProgressOutput(output));
|
isOperationInProgressOutput(output));
|
||||||
|
|
||||||
|
const progress = useAsymptoticProgress(isOperating);
|
||||||
|
|
||||||
const hasExpandableContent =
|
const hasExpandableContent =
|
||||||
part.state === "output-available" &&
|
part.state === "output-available" &&
|
||||||
!!output &&
|
!!output &&
|
||||||
@@ -157,53 +152,31 @@ export function CreateAgentTool({ part }: Props) {
|
|||||||
<ToolAccordion {...getAccordionMeta(output)}>
|
<ToolAccordion {...getAccordionMeta(output)}>
|
||||||
{isOperating && (
|
{isOperating && (
|
||||||
<ContentGrid>
|
<ContentGrid>
|
||||||
<MiniGame />
|
<ProgressBar value={progress} />
|
||||||
<ContentHint>
|
<ContentHint>
|
||||||
This could take a few minutes — play while you wait!
|
This could take a few minutes, grab a coffee ☕
|
||||||
</ContentHint>
|
</ContentHint>
|
||||||
</ContentGrid>
|
</ContentGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAgentSavedOutput(output) && (
|
{isAgentSavedOutput(output) && (
|
||||||
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
|
<ContentGrid>
|
||||||
<div className="flex items-baseline gap-2">
|
<ContentMessage>{output.message}</ContentMessage>
|
||||||
<CheckFatIcon
|
<div className="flex flex-wrap gap-2">
|
||||||
size={18}
|
<ContentLink href={output.library_agent_link}>
|
||||||
weight="regular"
|
|
||||||
className="relative top-1 text-green-500"
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
variant="body-medium"
|
|
||||||
className="text-blacks mb-2 text-[16px]"
|
|
||||||
>
|
|
||||||
{output.message}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-4">
|
|
||||||
<Button variant="outline" size="small">
|
|
||||||
<NextLink
|
|
||||||
href={output.library_agent_link}
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<BookOpenIcon size={14} weight="regular" />
|
|
||||||
Open in library
|
Open in library
|
||||||
</NextLink>
|
</ContentLink>
|
||||||
</Button>
|
<ContentLink href={output.agent_page_link}>
|
||||||
<Button variant="outline" size="small">
|
|
||||||
<NextLink
|
|
||||||
href={output.agent_page_link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<PencilSimpleIcon size={14} weight="regular" />
|
|
||||||
Open in builder
|
Open in builder
|
||||||
</NextLink>
|
</ContentLink>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ContentCodeBlock>
|
||||||
|
{truncateText(
|
||||||
|
formatMaybeJson({ agent_id: output.agent_id }),
|
||||||
|
800,
|
||||||
|
)}
|
||||||
|
</ContentCodeBlock>
|
||||||
|
</ContentGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAgentPreviewOutput(output) && (
|
{isAgentPreviewOutput(output) && (
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMiniGame } from "./useMiniGame";
|
|
||||||
|
|
||||||
export function MiniGame() {
|
|
||||||
const { canvasRef } = useMiniGame();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full overflow-hidden rounded-md bg-background text-foreground"
|
|
||||||
style={{ border: "1px solid #d17fff" }}
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
tabIndex={0}
|
|
||||||
className="block w-full outline-none"
|
|
||||||
style={{ imageRendering: "pixelated" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Constants */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const CANVAS_HEIGHT = 150;
|
|
||||||
const GRAVITY = 0.55;
|
|
||||||
const JUMP_FORCE = -9.5;
|
|
||||||
const BASE_SPEED = 3;
|
|
||||||
const SPEED_INCREMENT = 0.0008;
|
|
||||||
const SPAWN_MIN = 70;
|
|
||||||
const SPAWN_MAX = 130;
|
|
||||||
const CHAR_SIZE = 18;
|
|
||||||
const CHAR_X = 50;
|
|
||||||
const GROUND_PAD = 20;
|
|
||||||
const STORAGE_KEY = "copilot-minigame-highscore";
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
const COLOR_BG = "#E8EAF6";
|
|
||||||
const COLOR_CHAR = "#263238";
|
|
||||||
const COLOR_BOSS = "#F50057";
|
|
||||||
|
|
||||||
// Boss
|
|
||||||
const BOSS_SIZE = 36;
|
|
||||||
const BOSS_ENTER_SPEED = 2;
|
|
||||||
const BOSS_LEAVE_SPEED = 3;
|
|
||||||
const BOSS_SHOOT_COOLDOWN = 90;
|
|
||||||
const BOSS_SHOTS_TO_EVADE = 5;
|
|
||||||
const BOSS_INTERVAL = 20; // every N score
|
|
||||||
const PROJ_SPEED = 4.5;
|
|
||||||
const PROJ_SIZE = 12;
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Types */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
interface Obstacle {
|
|
||||||
x: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
scored: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Projectile {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
speed: number;
|
|
||||||
evaded: boolean;
|
|
||||||
type: "low" | "high";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BossState {
|
|
||||||
phase: "inactive" | "entering" | "fighting" | "leaving";
|
|
||||||
x: number;
|
|
||||||
targetX: number;
|
|
||||||
shotsEvaded: number;
|
|
||||||
cooldown: number;
|
|
||||||
projectiles: Projectile[];
|
|
||||||
bob: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
charY: number;
|
|
||||||
vy: number;
|
|
||||||
obstacles: Obstacle[];
|
|
||||||
score: number;
|
|
||||||
highScore: number;
|
|
||||||
speed: number;
|
|
||||||
frame: number;
|
|
||||||
nextSpawn: number;
|
|
||||||
running: boolean;
|
|
||||||
over: boolean;
|
|
||||||
groundY: number;
|
|
||||||
boss: BossState;
|
|
||||||
bossThreshold: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Helpers */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function randInt(min: number, max: number) {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readHighScore(): number {
|
|
||||||
try {
|
|
||||||
return parseInt(localStorage.getItem(STORAGE_KEY) || "0", 10) || 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeHighScore(score: number) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, String(score));
|
|
||||||
} catch {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeBoss(): BossState {
|
|
||||||
return {
|
|
||||||
phase: "inactive",
|
|
||||||
x: 0,
|
|
||||||
targetX: 0,
|
|
||||||
shotsEvaded: 0,
|
|
||||||
cooldown: 0,
|
|
||||||
projectiles: [],
|
|
||||||
bob: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeState(groundY: number): GameState {
|
|
||||||
return {
|
|
||||||
charY: groundY - CHAR_SIZE,
|
|
||||||
vy: 0,
|
|
||||||
obstacles: [],
|
|
||||||
score: 0,
|
|
||||||
highScore: readHighScore(),
|
|
||||||
speed: BASE_SPEED,
|
|
||||||
frame: 0,
|
|
||||||
nextSpawn: randInt(SPAWN_MIN, SPAWN_MAX),
|
|
||||||
running: false,
|
|
||||||
over: false,
|
|
||||||
groundY,
|
|
||||||
boss: makeBoss(),
|
|
||||||
bossThreshold: BOSS_INTERVAL,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function gameOver(s: GameState) {
|
|
||||||
s.running = false;
|
|
||||||
s.over = true;
|
|
||||||
if (s.score > s.highScore) {
|
|
||||||
s.highScore = s.score;
|
|
||||||
writeHighScore(s.score);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Projectile collision — shared between fighting & leaving phases */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
/** Returns true if the player died. */
|
|
||||||
function tickProjectiles(s: GameState): boolean {
|
|
||||||
const boss = s.boss;
|
|
||||||
|
|
||||||
for (const p of boss.projectiles) {
|
|
||||||
p.x -= p.speed;
|
|
||||||
|
|
||||||
if (!p.evaded && p.x + PROJ_SIZE < CHAR_X) {
|
|
||||||
p.evaded = true;
|
|
||||||
boss.shotsEvaded++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collision
|
|
||||||
if (
|
|
||||||
!p.evaded &&
|
|
||||||
CHAR_X + CHAR_SIZE > p.x &&
|
|
||||||
CHAR_X < p.x + PROJ_SIZE &&
|
|
||||||
s.charY + CHAR_SIZE > p.y &&
|
|
||||||
s.charY < p.y + PROJ_SIZE
|
|
||||||
) {
|
|
||||||
gameOver(s);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
boss.projectiles = boss.projectiles.filter((p) => p.x + PROJ_SIZE > -20);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Update */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function update(s: GameState, canvasWidth: number) {
|
|
||||||
if (!s.running) return;
|
|
||||||
|
|
||||||
s.frame++;
|
|
||||||
|
|
||||||
// Speed only ramps during regular play
|
|
||||||
if (s.boss.phase === "inactive") {
|
|
||||||
s.speed = BASE_SPEED + s.frame * SPEED_INCREMENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Character physics (always active) ---- //
|
|
||||||
s.vy += GRAVITY;
|
|
||||||
s.charY += s.vy;
|
|
||||||
if (s.charY + CHAR_SIZE >= s.groundY) {
|
|
||||||
s.charY = s.groundY - CHAR_SIZE;
|
|
||||||
s.vy = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Trigger boss ---- //
|
|
||||||
if (s.boss.phase === "inactive" && s.score >= s.bossThreshold) {
|
|
||||||
s.boss.phase = "entering";
|
|
||||||
s.boss.x = canvasWidth + 10;
|
|
||||||
s.boss.targetX = canvasWidth - BOSS_SIZE - 40;
|
|
||||||
s.boss.shotsEvaded = 0;
|
|
||||||
s.boss.cooldown = BOSS_SHOOT_COOLDOWN;
|
|
||||||
s.boss.projectiles = [];
|
|
||||||
s.obstacles = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Boss: entering ---- //
|
|
||||||
if (s.boss.phase === "entering") {
|
|
||||||
s.boss.bob = Math.sin(s.frame * 0.05) * 3;
|
|
||||||
s.boss.x -= BOSS_ENTER_SPEED;
|
|
||||||
if (s.boss.x <= s.boss.targetX) {
|
|
||||||
s.boss.x = s.boss.targetX;
|
|
||||||
s.boss.phase = "fighting";
|
|
||||||
}
|
|
||||||
return; // no obstacles while entering
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Boss: fighting ---- //
|
|
||||||
if (s.boss.phase === "fighting") {
|
|
||||||
s.boss.bob = Math.sin(s.frame * 0.05) * 3;
|
|
||||||
|
|
||||||
// Shoot
|
|
||||||
s.boss.cooldown--;
|
|
||||||
if (s.boss.cooldown <= 0) {
|
|
||||||
const isLow = Math.random() < 0.5;
|
|
||||||
s.boss.projectiles.push({
|
|
||||||
x: s.boss.x - PROJ_SIZE,
|
|
||||||
y: isLow ? s.groundY - 14 : s.groundY - 70,
|
|
||||||
speed: PROJ_SPEED,
|
|
||||||
evaded: false,
|
|
||||||
type: isLow ? "low" : "high",
|
|
||||||
});
|
|
||||||
s.boss.cooldown = BOSS_SHOOT_COOLDOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tickProjectiles(s)) return;
|
|
||||||
|
|
||||||
// Boss defeated?
|
|
||||||
if (s.boss.shotsEvaded >= BOSS_SHOTS_TO_EVADE) {
|
|
||||||
s.boss.phase = "leaving";
|
|
||||||
s.score += 5; // bonus
|
|
||||||
s.bossThreshold = s.score + BOSS_INTERVAL;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Boss: leaving ---- //
|
|
||||||
if (s.boss.phase === "leaving") {
|
|
||||||
s.boss.bob = Math.sin(s.frame * 0.05) * 3;
|
|
||||||
s.boss.x += BOSS_LEAVE_SPEED;
|
|
||||||
|
|
||||||
// Still check in-flight projectiles
|
|
||||||
if (tickProjectiles(s)) return;
|
|
||||||
|
|
||||||
if (s.boss.x > canvasWidth + 50) {
|
|
||||||
s.boss = makeBoss();
|
|
||||||
s.nextSpawn = s.frame + randInt(SPAWN_MIN / 2, SPAWN_MAX / 2);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Regular obstacle play ---- //
|
|
||||||
if (s.frame >= s.nextSpawn) {
|
|
||||||
s.obstacles.push({
|
|
||||||
x: canvasWidth + 10,
|
|
||||||
width: randInt(10, 16),
|
|
||||||
height: randInt(20, 48),
|
|
||||||
scored: false,
|
|
||||||
});
|
|
||||||
s.nextSpawn = s.frame + randInt(SPAWN_MIN, SPAWN_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const o of s.obstacles) {
|
|
||||||
o.x -= s.speed;
|
|
||||||
if (!o.scored && o.x + o.width < CHAR_X) {
|
|
||||||
o.scored = true;
|
|
||||||
s.score++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.obstacles = s.obstacles.filter((o) => o.x + o.width > -20);
|
|
||||||
|
|
||||||
for (const o of s.obstacles) {
|
|
||||||
const oY = s.groundY - o.height;
|
|
||||||
if (
|
|
||||||
CHAR_X + CHAR_SIZE > o.x &&
|
|
||||||
CHAR_X < o.x + o.width &&
|
|
||||||
s.charY + CHAR_SIZE > oY
|
|
||||||
) {
|
|
||||||
gameOver(s);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Drawing */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function drawBoss(ctx: CanvasRenderingContext2D, s: GameState, bg: string) {
|
|
||||||
const bx = s.boss.x;
|
|
||||||
const by = s.groundY - BOSS_SIZE + s.boss.bob;
|
|
||||||
|
|
||||||
// Body
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = COLOR_BOSS;
|
|
||||||
ctx.globalAlpha = 0.9;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(bx, by, BOSS_SIZE, BOSS_SIZE, 4);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Eyes
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = bg;
|
|
||||||
const eyeY = by + 13;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(bx + 10, eyeY, 4, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(bx + 26, eyeY, 4, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Angry eyebrows
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = bg;
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(bx + 5, eyeY - 7);
|
|
||||||
ctx.lineTo(bx + 14, eyeY - 4);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(bx + 31, eyeY - 7);
|
|
||||||
ctx.lineTo(bx + 22, eyeY - 4);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Zigzag mouth
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = bg;
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(bx + 10, by + 27);
|
|
||||||
ctx.lineTo(bx + 14, by + 24);
|
|
||||||
ctx.lineTo(bx + 18, by + 27);
|
|
||||||
ctx.lineTo(bx + 22, by + 24);
|
|
||||||
ctx.lineTo(bx + 26, by + 27);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawProjectiles(ctx: CanvasRenderingContext2D, boss: BossState) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = COLOR_BOSS;
|
|
||||||
ctx.globalAlpha = 0.8;
|
|
||||||
for (const p of boss.projectiles) {
|
|
||||||
if (p.evaded) continue;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(
|
|
||||||
p.x + PROJ_SIZE / 2,
|
|
||||||
p.y + PROJ_SIZE / 2,
|
|
||||||
PROJ_SIZE / 2,
|
|
||||||
0,
|
|
||||||
Math.PI * 2,
|
|
||||||
);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
s: GameState,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
fg: string,
|
|
||||||
started: boolean,
|
|
||||||
) {
|
|
||||||
ctx.fillStyle = COLOR_BG;
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Ground
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = fg;
|
|
||||||
ctx.globalAlpha = 0.15;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, s.groundY);
|
|
||||||
ctx.lineTo(w, s.groundY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Character
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = COLOR_CHAR;
|
|
||||||
ctx.globalAlpha = 0.85;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(CHAR_X, s.charY, CHAR_SIZE, CHAR_SIZE, 3);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Eyes
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = COLOR_BG;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(CHAR_X + 6, s.charY + 7, 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(CHAR_X + 12, s.charY + 7, 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Obstacles
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = fg;
|
|
||||||
ctx.globalAlpha = 0.55;
|
|
||||||
for (const o of s.obstacles) {
|
|
||||||
ctx.fillRect(o.x, s.groundY - o.height, o.width, o.height);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Boss + projectiles
|
|
||||||
if (s.boss.phase !== "inactive") {
|
|
||||||
drawBoss(ctx, s, COLOR_BG);
|
|
||||||
drawProjectiles(ctx, s.boss);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Score HUD
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = fg;
|
|
||||||
ctx.globalAlpha = 0.5;
|
|
||||||
ctx.font = "bold 11px monospace";
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.fillText(`Score: ${s.score}`, w - 12, 20);
|
|
||||||
ctx.fillText(`Best: ${s.highScore}`, w - 12, 34);
|
|
||||||
if (s.boss.phase === "fighting") {
|
|
||||||
ctx.fillText(
|
|
||||||
`Evade: ${s.boss.shotsEvaded}/${BOSS_SHOTS_TO_EVADE}`,
|
|
||||||
w - 12,
|
|
||||||
48,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Prompts
|
|
||||||
if (!started && !s.running && !s.over) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = fg;
|
|
||||||
ctx.globalAlpha = 0.5;
|
|
||||||
ctx.font = "12px sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText("Click or press Space to play while you wait", w / 2, h / 2);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.over) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = fg;
|
|
||||||
ctx.globalAlpha = 0.7;
|
|
||||||
ctx.font = "bold 13px sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText("Game Over", w / 2, h / 2 - 8);
|
|
||||||
ctx.font = "11px sans-serif";
|
|
||||||
ctx.fillText("Click or Space to restart", w / 2, h / 2 + 10);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Hook */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export function useMiniGame() {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const stateRef = useRef<GameState | null>(null);
|
|
||||||
const rafRef = useRef(0);
|
|
||||||
const startedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const container = canvas.parentElement;
|
|
||||||
if (container) {
|
|
||||||
canvas.width = container.clientWidth;
|
|
||||||
canvas.height = CANVAS_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groundY = canvas.height - GROUND_PAD;
|
|
||||||
stateRef.current = makeState(groundY);
|
|
||||||
|
|
||||||
const style = getComputedStyle(canvas);
|
|
||||||
let fg = style.color || "#71717a";
|
|
||||||
|
|
||||||
// -------------------------------------------------------------- //
|
|
||||||
// Jump //
|
|
||||||
// -------------------------------------------------------------- //
|
|
||||||
function jump() {
|
|
||||||
const s = stateRef.current;
|
|
||||||
if (!s) return;
|
|
||||||
|
|
||||||
if (s.over) {
|
|
||||||
const hs = s.highScore;
|
|
||||||
const gy = s.groundY;
|
|
||||||
stateRef.current = makeState(gy);
|
|
||||||
stateRef.current.highScore = hs;
|
|
||||||
stateRef.current.running = true;
|
|
||||||
startedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!s.running) {
|
|
||||||
s.running = true;
|
|
||||||
startedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only jump when on the ground
|
|
||||||
if (s.charY + CHAR_SIZE >= s.groundY) {
|
|
||||||
s.vy = JUMP_FORCE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.code === "Space" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
jump();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick() {
|
|
||||||
canvas?.focus();
|
|
||||||
jump();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------- //
|
|
||||||
// Loop //
|
|
||||||
// -------------------------------------------------------------- //
|
|
||||||
function loop() {
|
|
||||||
const s = stateRef.current;
|
|
||||||
if (!canvas || !s) return;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
update(s, canvas.width);
|
|
||||||
draw(ctx, s, canvas.width, canvas.height, fg, startedRef.current);
|
|
||||||
rafRef.current = requestAnimationFrame(loop);
|
|
||||||
}
|
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(loop);
|
|
||||||
|
|
||||||
canvas.addEventListener("click", onClick);
|
|
||||||
canvas.addEventListener("keydown", onKey);
|
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
canvas.width = entry.contentRect.width;
|
|
||||||
canvas.height = CANVAS_HEIGHT;
|
|
||||||
if (stateRef.current) {
|
|
||||||
stateRef.current.groundY = canvas.height - GROUND_PAD;
|
|
||||||
}
|
|
||||||
const cs = getComputedStyle(canvas);
|
|
||||||
fg = cs.color || fg;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (container) observer.observe(container);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
canvas.removeEventListener("click", onClick);
|
|
||||||
canvas.removeEventListener("keydown", onKey);
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { canvasRef };
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useChatSession } from "./useChatSession";
|
import { useChatSession } from "./useChatSession";
|
||||||
import { useLongRunningToolPolling } from "./hooks/useLongRunningToolPolling";
|
|
||||||
|
|
||||||
const STREAM_START_TIMEOUT_MS = 12_000;
|
|
||||||
|
|
||||||
export function useCopilotPage() {
|
export function useCopilotPage() {
|
||||||
const { isUserLoading, isLoggedIn } = useSupabase();
|
const { isUserLoading, isLoggedIn } = useSupabase();
|
||||||
@@ -56,24 +52,6 @@ export function useCopilotPage() {
|
|||||||
transport: transport ?? undefined,
|
transport: transport ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Abort the stream if the backend doesn't start sending data within 12s.
|
|
||||||
const stopRef = useRef(stop);
|
|
||||||
stopRef.current = stop;
|
|
||||||
useEffect(() => {
|
|
||||||
if (status !== "submitted") return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
stopRef.current();
|
|
||||||
toast({
|
|
||||||
title: "Stream timed out",
|
|
||||||
description: "The server took too long to respond. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}, STREAM_START_TIMEOUT_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
@@ -82,11 +60,6 @@ export function useCopilotPage() {
|
|||||||
});
|
});
|
||||||
}, [hydratedMessages, setMessages]);
|
}, [hydratedMessages, 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.
|
|
||||||
useLongRunningToolPolling(sessionId, messages, setMessages);
|
|
||||||
|
|
||||||
// Clear messages when session is null
|
// Clear messages when session is null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) setMessages([]);
|
if (!sessionId) setMessages([]);
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export function APIKeyCredentialsModal({
|
|||||||
const {
|
const {
|
||||||
form,
|
form,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSubmitting,
|
|
||||||
supportsApiKey,
|
supportsApiKey,
|
||||||
providerName,
|
providerName,
|
||||||
schemaDescription,
|
schemaDescription,
|
||||||
@@ -139,12 +138,7 @@ export function APIKeyCredentialsModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button type="submit" className="min-w-68">
|
||||||
type="submit"
|
|
||||||
className="min-w-68"
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Add API Key
|
Add API Key
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
CredentialsMetaInput,
|
CredentialsMetaInput,
|
||||||
} from "@/lib/autogpt-server-api/types";
|
} from "@/lib/autogpt-server-api/types";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm, type UseFormReturn } from "react-hook-form";
|
import { useForm, type UseFormReturn } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ export function useAPIKeyCredentialsModal({
|
|||||||
}: Args): {
|
}: Args): {
|
||||||
form: UseFormReturn<APIKeyFormValues>;
|
form: UseFormReturn<APIKeyFormValues>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSubmitting: boolean;
|
|
||||||
supportsApiKey: boolean;
|
supportsApiKey: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
providerName?: string;
|
providerName?: string;
|
||||||
@@ -35,7 +33,6 @@ export function useAPIKeyCredentialsModal({
|
|||||||
onSubmit: (values: APIKeyFormValues) => Promise<void>;
|
onSubmit: (values: APIKeyFormValues) => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const credentials = useCredentials(schema, siblingInputs);
|
const credentials = useCredentials(schema, siblingInputs);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
apiKey: z.string().min(1, "API Key is required"),
|
apiKey: z.string().min(1, "API Key is required"),
|
||||||
@@ -43,19 +40,29 @@ export function useAPIKeyCredentialsModal({
|
|||||||
expiresAt: z.string().optional(),
|
expiresAt: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getDefaultExpirationDate(): string {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 0, 0, 0);
|
||||||
|
const year = tomorrow.getFullYear();
|
||||||
|
const month = String(tomorrow.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(tomorrow.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(tomorrow.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(tomorrow.getMinutes()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
const form = useForm<APIKeyFormValues>({
|
const form = useForm<APIKeyFormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
title: "",
|
title: "",
|
||||||
expiresAt: "",
|
expiresAt: getDefaultExpirationDate(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: APIKeyFormValues) {
|
async function onSubmit(values: APIKeyFormValues) {
|
||||||
if (!credentials || credentials.isLoading) return;
|
if (!credentials || credentials.isLoading) return;
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const expiresAt = values.expiresAt
|
const expiresAt = values.expiresAt
|
||||||
? new Date(values.expiresAt).getTime() / 1000
|
? new Date(values.expiresAt).getTime() / 1000
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -70,15 +77,11 @@ export function useAPIKeyCredentialsModal({
|
|||||||
type: "api_key",
|
type: "api_key",
|
||||||
title: newCredentials.title,
|
title: newCredentials.title,
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
isLoading: !credentials || credentials.isLoading,
|
isLoading: !credentials || credentials.isLoading,
|
||||||
isSubmitting,
|
|
||||||
supportsApiKey: !!credentials?.supportsApiKey,
|
supportsApiKey: !!credentials?.supportsApiKey,
|
||||||
provider: credentials?.provider,
|
provider: credentials?.provider,
|
||||||
providerName:
|
providerName:
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ The block supports conversation continuation through three mechanisms:
|
|||||||
|--------|-------------|------|
|
|--------|-------------|------|
|
||||||
| error | Error message if execution failed | str |
|
| error | Error message if execution failed | str |
|
||||||
| response | The output/response from Claude Code execution | str |
|
| response | The output/response from Claude Code execution | str |
|
||||||
| files | List of text files created/modified by Claude Code during this execution. Each file has 'path', 'relative_path', 'name', 'content', and 'workspace_ref' fields. workspace_ref contains a workspace:// URI if the file was stored to workspace. | List[SandboxFileOutput] |
|
| files | List of text files created/modified by Claude Code during this execution. Each file has 'path', 'relative_path', 'name', and 'content' fields. | List[FileOutput] |
|
||||||
| conversation_history | Full conversation history including this turn. Pass this to conversation_history input to continue on a fresh sandbox if the previous sandbox timed out. | str |
|
| conversation_history | Full conversation history including this turn. Pass this to conversation_history input to continue on a fresh sandbox if the previous sandbox timed out. | str |
|
||||||
| session_id | Session ID for this conversation. Pass this back along with sandbox_id to continue the conversation. | str |
|
| session_id | Session ID for this conversation. Pass this back along with sandbox_id to continue the conversation. | str |
|
||||||
| sandbox_id | ID of the sandbox instance. Pass this back along with session_id to continue the conversation. This is None if dispose_sandbox was True (sandbox was disposed). | str |
|
| sandbox_id | ID of the sandbox instance. Pass this back along with session_id to continue the conversation. This is None if dispose_sandbox was True (sandbox was disposed). | str |
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ The sandbox includes pip and npm pre-installed. Set timeout to limit execution t
|
|||||||
| response | Text output (if any) of the main execution result | str |
|
| response | Text output (if any) of the main execution result | str |
|
||||||
| stdout_logs | Standard output logs from execution | str |
|
| stdout_logs | Standard output logs from execution | str |
|
||||||
| stderr_logs | Standard error logs from execution | str |
|
| stderr_logs | Standard error logs from execution | str |
|
||||||
| files | Files created or modified during execution. Each file has path, name, content, and workspace_ref (if stored). | List[SandboxFileOutput] |
|
|
||||||
|
|
||||||
### Possible use case
|
### Possible use case
|
||||||
<!-- MANUAL: use_case -->
|
<!-- MANUAL: use_case -->
|
||||||
|
|||||||
Reference in New Issue
Block a user