mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-06 04:45:10 -05:00
Compare commits
1 Commits
dev
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7a7067ec |
@@ -152,7 +152,6 @@ REPLICATE_API_KEY=
|
|||||||
REVID_API_KEY=
|
REVID_API_KEY=
|
||||||
SCREENSHOTONE_API_KEY=
|
SCREENSHOTONE_API_KEY=
|
||||||
UNREAL_SPEECH_API_KEY=
|
UNREAL_SPEECH_API_KEY=
|
||||||
ELEVENLABS_API_KEY=
|
|
||||||
|
|
||||||
# Data & Search Services
|
# Data & Search Services
|
||||||
E2B_API_KEY=
|
E2B_API_KEY=
|
||||||
|
|||||||
3
autogpt_platform/backend/.gitignore
vendored
3
autogpt_platform/backend/.gitignore
vendored
@@ -19,6 +19,3 @@ load-tests/*.json
|
|||||||
load-tests/*.log
|
load-tests/*.log
|
||||||
load-tests/node_modules/*
|
load-tests/node_modules/*
|
||||||
migrations/*/rollback*.sql
|
migrations/*/rollback*.sql
|
||||||
|
|
||||||
# Workspace files
|
|
||||||
workspaces/
|
|
||||||
|
|||||||
@@ -62,12 +62,10 @@ ENV POETRY_HOME=/opt/poetry \
|
|||||||
DEBIAN_FRONTEND=noninteractive
|
DEBIAN_FRONTEND=noninteractive
|
||||||
ENV PATH=/opt/poetry/bin:$PATH
|
ENV PATH=/opt/poetry/bin:$PATH
|
||||||
|
|
||||||
# Install Python, FFmpeg, and ImageMagick (required for video processing blocks)
|
# Install Python without upgrading system-managed packages
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
python3.13 \
|
python3.13 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
ffmpeg \
|
|
||||||
imagemagick \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy only necessary files from builder
|
# Copy only necessary files from builder
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ChatConfig(BaseSettings):
|
|||||||
|
|
||||||
# OpenAI API Configuration
|
# OpenAI API Configuration
|
||||||
model: str = Field(
|
model: str = Field(
|
||||||
default="anthropic/claude-opus-4.6", description="Default model to use"
|
default="anthropic/claude-opus-4.5", description="Default model to use"
|
||||||
)
|
)
|
||||||
title_model: str = Field(
|
title_model: str = Field(
|
||||||
default="openai/gpt-4o-mini",
|
default="openai/gpt-4o-mini",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from backend.data.understanding import (
|
|||||||
get_business_understanding,
|
get_business_understanding,
|
||||||
)
|
)
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
from backend.util.settings import AppEnvironment, Settings
|
from backend.util.settings import Settings
|
||||||
|
|
||||||
from . import db as chat_db
|
from . import db as chat_db
|
||||||
from . import stream_registry
|
from . import stream_registry
|
||||||
@@ -222,18 +222,8 @@ async def _get_system_prompt_template(context: str) -> str:
|
|||||||
try:
|
try:
|
||||||
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
||||||
# Use asyncio.to_thread to avoid blocking the event loop
|
# Use asyncio.to_thread to avoid blocking the event loop
|
||||||
# In non-production environments, fetch the latest prompt version
|
|
||||||
# instead of the production-labeled version for easier testing
|
|
||||||
label = (
|
|
||||||
None
|
|
||||||
if settings.config.app_env == AppEnvironment.PRODUCTION
|
|
||||||
else "latest"
|
|
||||||
)
|
|
||||||
prompt = await asyncio.to_thread(
|
prompt = await asyncio.to_thread(
|
||||||
langfuse.get_prompt,
|
langfuse.get_prompt, config.langfuse_prompt_name, cache_ttl_seconds=0
|
||||||
config.langfuse_prompt_name,
|
|
||||||
label=label,
|
|
||||||
cache_ttl_seconds=0,
|
|
||||||
)
|
)
|
||||||
return prompt.compile(users_information=context)
|
return prompt.compile(users_information=context)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -628,9 +618,6 @@ async def stream_chat_completion(
|
|||||||
total_tokens=chunk.totalTokens,
|
total_tokens=chunk.totalTokens,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif isinstance(chunk, StreamHeartbeat):
|
|
||||||
# Pass through heartbeat to keep SSE connection alive
|
|
||||||
yield chunk
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
|
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ from typing import Any, NotRequired, TypedDict
|
|||||||
|
|
||||||
from backend.api.features.library import db as library_db
|
from backend.api.features.library import db as library_db
|
||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data.graph import Graph, Link, Node, get_graph, get_store_listed_graphs
|
from backend.data.graph import (
|
||||||
|
Graph,
|
||||||
|
Link,
|
||||||
|
Node,
|
||||||
|
create_graph,
|
||||||
|
get_graph,
|
||||||
|
get_graph_all_versions,
|
||||||
|
get_store_listed_graphs,
|
||||||
|
)
|
||||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||||
|
|
||||||
from .service import (
|
from .service import (
|
||||||
@@ -20,6 +28,8 @@ from .service import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AGENT_EXECUTOR_BLOCK_ID = "e189baac-8c20-45a1-94a7-55177ea42565"
|
||||||
|
|
||||||
|
|
||||||
class ExecutionSummary(TypedDict):
|
class ExecutionSummary(TypedDict):
|
||||||
"""Summary of a single execution for quality assessment."""
|
"""Summary of a single execution for quality assessment."""
|
||||||
@@ -659,6 +669,45 @@ def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reassign_node_ids(graph: Graph) -> None:
|
||||||
|
"""Reassign all node and link IDs to new UUIDs.
|
||||||
|
|
||||||
|
This is needed when creating a new version to avoid unique constraint violations.
|
||||||
|
"""
|
||||||
|
id_map = {node.id: str(uuid.uuid4()) for node in graph.nodes}
|
||||||
|
|
||||||
|
for node in graph.nodes:
|
||||||
|
node.id = id_map[node.id]
|
||||||
|
|
||||||
|
for link in graph.links:
|
||||||
|
link.id = str(uuid.uuid4())
|
||||||
|
if link.source_id in id_map:
|
||||||
|
link.source_id = id_map[link.source_id]
|
||||||
|
if link.sink_id in id_map:
|
||||||
|
link.sink_id = id_map[link.sink_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_agent_executor_user_ids(agent_json: dict[str, Any], user_id: str) -> None:
|
||||||
|
"""Populate user_id in AgentExecutorBlock nodes.
|
||||||
|
|
||||||
|
The external agent generator creates AgentExecutorBlock nodes with empty user_id.
|
||||||
|
This function fills in the actual user_id so sub-agents run with correct permissions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_json: Agent JSON dict (modified in place)
|
||||||
|
user_id: User ID to set
|
||||||
|
"""
|
||||||
|
for node in agent_json.get("nodes", []):
|
||||||
|
if node.get("block_id") == AGENT_EXECUTOR_BLOCK_ID:
|
||||||
|
input_default = node.get("input_default") or {}
|
||||||
|
if not input_default.get("user_id"):
|
||||||
|
input_default["user_id"] = user_id
|
||||||
|
node["input_default"] = input_default
|
||||||
|
logger.debug(
|
||||||
|
f"Set user_id for AgentExecutorBlock node {node.get('id')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def save_agent_to_library(
|
async def save_agent_to_library(
|
||||||
agent_json: dict[str, Any], user_id: str, is_update: bool = False
|
agent_json: dict[str, Any], user_id: str, is_update: bool = False
|
||||||
) -> tuple[Graph, Any]:
|
) -> tuple[Graph, Any]:
|
||||||
@@ -672,10 +721,35 @@ async def save_agent_to_library(
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (created Graph, LibraryAgent)
|
Tuple of (created Graph, LibraryAgent)
|
||||||
"""
|
"""
|
||||||
|
# Populate user_id in AgentExecutorBlock nodes before conversion
|
||||||
|
_populate_agent_executor_user_ids(agent_json, user_id)
|
||||||
|
|
||||||
graph = json_to_graph(agent_json)
|
graph = json_to_graph(agent_json)
|
||||||
|
|
||||||
if is_update:
|
if is_update:
|
||||||
return await library_db.update_graph_in_library(graph, user_id)
|
if graph.id:
|
||||||
return await library_db.create_graph_in_library(graph, user_id)
|
existing_versions = await get_graph_all_versions(graph.id, user_id)
|
||||||
|
if existing_versions:
|
||||||
|
latest_version = max(v.version for v in existing_versions)
|
||||||
|
graph.version = latest_version + 1
|
||||||
|
_reassign_node_ids(graph)
|
||||||
|
logger.info(f"Updating agent {graph.id} to version {graph.version}")
|
||||||
|
else:
|
||||||
|
graph.id = str(uuid.uuid4())
|
||||||
|
graph.version = 1
|
||||||
|
_reassign_node_ids(graph)
|
||||||
|
logger.info(f"Creating new agent with ID {graph.id}")
|
||||||
|
|
||||||
|
created_graph = await create_graph(graph, user_id)
|
||||||
|
|
||||||
|
library_agents = await library_db.create_library_agent(
|
||||||
|
graph=created_graph,
|
||||||
|
user_id=user_id,
|
||||||
|
sensitive_action_safe_mode=True,
|
||||||
|
create_library_agents_for_sub_graphs=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return created_graph, library_agents[0]
|
||||||
|
|
||||||
|
|
||||||
def graph_to_json(graph: Graph) -> dict[str, Any]:
|
def graph_to_json(graph: Graph) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -206,9 +206,9 @@ async def search_agents(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
no_results_msg = (
|
no_results_msg = (
|
||||||
f"No agents found matching '{query}'. Let the user know they can try different keywords or browse the marketplace. Also let them know you can create a custom agent for them based on their needs."
|
f"No agents found matching '{query}'. Try different keywords or browse the marketplace."
|
||||||
if source == "marketplace"
|
if source == "marketplace"
|
||||||
else f"No agents matching '{query}' found in your library. Let the user know you can create a custom agent for them based on their needs."
|
else f"No agents matching '{query}' found in your library."
|
||||||
)
|
)
|
||||||
return NoResultsResponse(
|
return NoResultsResponse(
|
||||||
message=no_results_msg, session_id=session_id, suggestions=suggestions
|
message=no_results_msg, session_id=session_id, suggestions=suggestions
|
||||||
@@ -224,10 +224,10 @@ async def search_agents(
|
|||||||
message = (
|
message = (
|
||||||
"Now you have found some options for the user to choose from. "
|
"Now you have found some options for the user to choose from. "
|
||||||
"You can add a link to a recommended agent at: /marketplace/agent/agent_id "
|
"You can add a link to a recommended agent at: /marketplace/agent/agent_id "
|
||||||
"Please ask the user if they would like to use any of these agents. Let the user know we can create a custom agent for them based on their needs."
|
"Please ask the user if they would like to use any of these agents."
|
||||||
if source == "marketplace"
|
if source == "marketplace"
|
||||||
else "Found agents in the user's library. You can provide a link to view an agent at: "
|
else "Found agents in the user's library. You can provide a link to view an agent at: "
|
||||||
"/library/agents/{agent_id}. Use agent_output to get execution results, or run_agent to execute. Let the user know we can create a custom agent for them based on their needs."
|
"/library/agents/{agent_id}. Use agent_output to get execution results, or run_agent to execute."
|
||||||
)
|
)
|
||||||
|
|
||||||
return AgentsFoundResponse(
|
return AgentsFoundResponse(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.api.features.store.exceptions import AgentNotFoundError
|
from backend.api.features.store.exceptions import AgentNotFoundError
|
||||||
@@ -27,6 +29,23 @@ from .models import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomizeAgentInput(BaseModel):
|
||||||
|
"""Input parameters for the customize_agent tool."""
|
||||||
|
|
||||||
|
agent_id: str = ""
|
||||||
|
modifications: str = ""
|
||||||
|
context: str = ""
|
||||||
|
save: bool = True
|
||||||
|
|
||||||
|
@field_validator("agent_id", "modifications", "context", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_strings(cls, v: Any) -> str:
|
||||||
|
"""Strip whitespace from string fields."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.strip()
|
||||||
|
return v if v is not None else ""
|
||||||
|
|
||||||
|
|
||||||
class CustomizeAgentTool(BaseTool):
|
class CustomizeAgentTool(BaseTool):
|
||||||
"""Tool for customizing marketplace/template agents using natural language."""
|
"""Tool for customizing marketplace/template agents using natural language."""
|
||||||
|
|
||||||
@@ -92,7 +111,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
session: ChatSession,
|
session: ChatSession,
|
||||||
**kwargs,
|
**kwargs: Any,
|
||||||
) -> ToolResponseBase:
|
) -> ToolResponseBase:
|
||||||
"""Execute the customize_agent tool.
|
"""Execute the customize_agent tool.
|
||||||
|
|
||||||
@@ -102,20 +121,17 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
3. Call customize_template with the modification request
|
3. Call customize_template with the modification request
|
||||||
4. Preview or save based on the save parameter
|
4. Preview or save based on the save parameter
|
||||||
"""
|
"""
|
||||||
agent_id = kwargs.get("agent_id", "").strip()
|
params = CustomizeAgentInput(**kwargs)
|
||||||
modifications = kwargs.get("modifications", "").strip()
|
|
||||||
context = kwargs.get("context", "")
|
|
||||||
save = kwargs.get("save", True)
|
|
||||||
session_id = session.session_id if session else None
|
session_id = session.session_id if session else None
|
||||||
|
|
||||||
if not agent_id:
|
if not params.agent_id:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Please provide the marketplace agent ID (e.g., 'creator/agent-name').",
|
message="Please provide the marketplace agent ID (e.g., 'creator/agent-name').",
|
||||||
error="missing_agent_id",
|
error="missing_agent_id",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not modifications:
|
if not params.modifications:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Please describe how you want to customize this agent.",
|
message="Please describe how you want to customize this agent.",
|
||||||
error="missing_modifications",
|
error="missing_modifications",
|
||||||
@@ -123,11 +139,11 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Parse agent_id in format "creator/slug"
|
# Parse agent_id in format "creator/slug"
|
||||||
parts = [p.strip() for p in agent_id.split("/")]
|
parts = params.agent_id.split("/")
|
||||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
f"Invalid agent ID format: '{agent_id}'. "
|
f"Invalid agent ID format: '{params.agent_id}'. "
|
||||||
"Expected format is 'creator/agent-name' "
|
"Expected format is 'creator/agent-name' "
|
||||||
"(e.g., 'autogpt/newsletter-writer')."
|
"(e.g., 'autogpt/newsletter-writer')."
|
||||||
),
|
),
|
||||||
@@ -145,14 +161,14 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
except AgentNotFoundError:
|
except AgentNotFoundError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
f"Could not find marketplace agent '{agent_id}'. "
|
f"Could not find marketplace agent '{params.agent_id}'. "
|
||||||
"Please check the agent ID and try again."
|
"Please check the agent ID and try again."
|
||||||
),
|
),
|
||||||
error="agent_not_found",
|
error="agent_not_found",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching marketplace agent {agent_id}: {e}")
|
logger.error(f"Error fetching marketplace agent {params.agent_id}: {e}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Failed to fetch the marketplace agent. Please try again.",
|
message="Failed to fetch the marketplace agent. Please try again.",
|
||||||
error="fetch_error",
|
error="fetch_error",
|
||||||
@@ -162,7 +178,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
if not agent_details.store_listing_version_id:
|
if not agent_details.store_listing_version_id:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
f"The agent '{agent_id}' does not have an available version. "
|
f"The agent '{params.agent_id}' does not have an available version. "
|
||||||
"Please try a different agent."
|
"Please try a different agent."
|
||||||
),
|
),
|
||||||
error="no_version_available",
|
error="no_version_available",
|
||||||
@@ -174,7 +190,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
graph = await store_db.get_agent(agent_details.store_listing_version_id)
|
graph = await store_db.get_agent(agent_details.store_listing_version_id)
|
||||||
template_agent = graph_to_json(graph)
|
template_agent = graph_to_json(graph)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching agent graph for {agent_id}: {e}")
|
logger.error(f"Error fetching agent graph for {params.agent_id}: {e}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="Failed to fetch the agent configuration. Please try again.",
|
message="Failed to fetch the agent configuration. Please try again.",
|
||||||
error="graph_fetch_error",
|
error="graph_fetch_error",
|
||||||
@@ -185,8 +201,8 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
try:
|
try:
|
||||||
result = await customize_template(
|
result = await customize_template(
|
||||||
template_agent=template_agent,
|
template_agent=template_agent,
|
||||||
modification_request=modifications,
|
modification_request=params.modifications,
|
||||||
context=context,
|
context=params.context,
|
||||||
)
|
)
|
||||||
except AgentGeneratorNotConfiguredError:
|
except AgentGeneratorNotConfiguredError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
@@ -198,7 +214,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calling customize_template for {agent_id}: {e}")
|
logger.error(f"Error calling customize_template for {params.agent_id}: {e}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
"Failed to customize the agent due to a service error. "
|
"Failed to customize the agent due to a service error. "
|
||||||
@@ -219,55 +235,25 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle error response
|
# Handle response using match/case for cleaner pattern matching
|
||||||
if isinstance(result, dict) and result.get("type") == "error":
|
return await self._handle_customization_result(
|
||||||
error_msg = result.get("error", "Unknown error")
|
result=result,
|
||||||
error_type = result.get("error_type", "unknown")
|
params=params,
|
||||||
user_message = get_user_message_for_error(
|
agent_details=agent_details,
|
||||||
error_type,
|
user_id=user_id,
|
||||||
operation="customize the agent",
|
session_id=session_id,
|
||||||
llm_parse_message=(
|
)
|
||||||
"The AI had trouble customizing the agent. "
|
|
||||||
"Please try again or simplify your request."
|
|
||||||
),
|
|
||||||
validation_message=(
|
|
||||||
"The customized agent failed validation. "
|
|
||||||
"Please try rephrasing your request."
|
|
||||||
),
|
|
||||||
error_details=error_msg,
|
|
||||||
)
|
|
||||||
return ErrorResponse(
|
|
||||||
message=user_message,
|
|
||||||
error=f"customization_failed:{error_type}",
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle clarifying questions
|
async def _handle_customization_result(
|
||||||
if isinstance(result, dict) and result.get("type") == "clarifying_questions":
|
self,
|
||||||
questions = result.get("questions") or []
|
result: dict[str, Any],
|
||||||
if not isinstance(questions, list):
|
params: CustomizeAgentInput,
|
||||||
logger.error(
|
agent_details: Any,
|
||||||
f"Unexpected clarifying questions format: {type(questions)}"
|
user_id: str | None,
|
||||||
)
|
session_id: str | None,
|
||||||
questions = []
|
) -> ToolResponseBase:
|
||||||
return ClarificationNeededResponse(
|
"""Handle the result from customize_template using pattern matching."""
|
||||||
message=(
|
# Ensure result is a dict
|
||||||
"I need some more information to customize this agent. "
|
|
||||||
"Please answer the following questions:"
|
|
||||||
),
|
|
||||||
questions=[
|
|
||||||
ClarifyingQuestion(
|
|
||||||
question=q.get("question", ""),
|
|
||||||
keyword=q.get("keyword", ""),
|
|
||||||
example=q.get("example"),
|
|
||||||
)
|
|
||||||
for q in questions
|
|
||||||
if isinstance(q, dict)
|
|
||||||
],
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Result should be the customized agent JSON
|
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
logger.error(f"Unexpected customize_template response type: {type(result)}")
|
logger.error(f"Unexpected customize_template response type: {type(result)}")
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
@@ -276,8 +262,77 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
customized_agent = result
|
result_type = result.get("type")
|
||||||
|
|
||||||
|
match result_type:
|
||||||
|
case "error":
|
||||||
|
error_msg = result.get("error", "Unknown error")
|
||||||
|
error_type = result.get("error_type", "unknown")
|
||||||
|
user_message = get_user_message_for_error(
|
||||||
|
error_type,
|
||||||
|
operation="customize the agent",
|
||||||
|
llm_parse_message=(
|
||||||
|
"The AI had trouble customizing the agent. "
|
||||||
|
"Please try again or simplify your request."
|
||||||
|
),
|
||||||
|
validation_message=(
|
||||||
|
"The customized agent failed validation. "
|
||||||
|
"Please try rephrasing your request."
|
||||||
|
),
|
||||||
|
error_details=error_msg,
|
||||||
|
)
|
||||||
|
return ErrorResponse(
|
||||||
|
message=user_message,
|
||||||
|
error=f"customization_failed:{error_type}",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
case "clarifying_questions":
|
||||||
|
questions_data = result.get("questions") or []
|
||||||
|
if not isinstance(questions_data, list):
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected clarifying questions format: {type(questions_data)}"
|
||||||
|
)
|
||||||
|
questions_data = []
|
||||||
|
|
||||||
|
questions = [
|
||||||
|
ClarifyingQuestion(
|
||||||
|
question=q.get("question", "") if isinstance(q, dict) else "",
|
||||||
|
keyword=q.get("keyword", "") if isinstance(q, dict) else "",
|
||||||
|
example=q.get("example") if isinstance(q, dict) else None,
|
||||||
|
)
|
||||||
|
for q in questions_data
|
||||||
|
if isinstance(q, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
return ClarificationNeededResponse(
|
||||||
|
message=(
|
||||||
|
"I need some more information to customize this agent. "
|
||||||
|
"Please answer the following questions:"
|
||||||
|
),
|
||||||
|
questions=questions,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
# Default case: result is the customized agent JSON
|
||||||
|
return await self._save_or_preview_agent(
|
||||||
|
customized_agent=result,
|
||||||
|
params=params,
|
||||||
|
agent_details=agent_details,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _save_or_preview_agent(
|
||||||
|
self,
|
||||||
|
customized_agent: dict[str, Any],
|
||||||
|
params: CustomizeAgentInput,
|
||||||
|
agent_details: Any,
|
||||||
|
user_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
) -> ToolResponseBase:
|
||||||
|
"""Save or preview the customized agent based on params.save."""
|
||||||
agent_name = customized_agent.get(
|
agent_name = customized_agent.get(
|
||||||
"name", f"Customized {agent_details.agent_name}"
|
"name", f"Customized {agent_details.agent_name}"
|
||||||
)
|
)
|
||||||
@@ -287,7 +342,7 @@ class CustomizeAgentTool(BaseTool):
|
|||||||
node_count = len(nodes) if isinstance(nodes, list) else 0
|
node_count = len(nodes) if isinstance(nodes, list) else 0
|
||||||
link_count = len(links) if isinstance(links, list) else 0
|
link_count = len(links) if isinstance(links, list) else 0
|
||||||
|
|
||||||
if not save:
|
if not params.save:
|
||||||
return AgentPreviewResponse(
|
return AgentPreviewResponse(
|
||||||
message=(
|
message=(
|
||||||
f"I've customized the agent '{agent_details.agent_name}'. "
|
f"I've customized the agent '{agent_details.agent_name}'. "
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ from backend.api.features.library import model as library_model
|
|||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data import graph as graph_db
|
from backend.data import graph as graph_db
|
||||||
from backend.data.graph import GraphModel
|
from backend.data.graph import GraphModel
|
||||||
from backend.data.model import (
|
from backend.data.model import Credentials, CredentialsFieldInfo, CredentialsMetaInput
|
||||||
CredentialsFieldInfo,
|
|
||||||
CredentialsMetaInput,
|
|
||||||
HostScopedCredentials,
|
|
||||||
OAuth2Credentials,
|
|
||||||
)
|
|
||||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
|
|
||||||
@@ -278,14 +273,7 @@ async def match_user_credentials_to_graph(
|
|||||||
for cred in available_creds
|
for cred in available_creds
|
||||||
if cred.provider in credential_requirements.provider
|
if cred.provider in credential_requirements.provider
|
||||||
and cred.type in credential_requirements.supported_types
|
and cred.type in credential_requirements.supported_types
|
||||||
and (
|
and _credential_has_required_scopes(cred, credential_requirements)
|
||||||
cred.type != "oauth2"
|
|
||||||
or _credential_has_required_scopes(cred, credential_requirements)
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
cred.type != "host_scoped"
|
|
||||||
or _credential_is_for_host(cred, credential_requirements)
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -330,10 +318,19 @@ async def match_user_credentials_to_graph(
|
|||||||
|
|
||||||
|
|
||||||
def _credential_has_required_scopes(
|
def _credential_has_required_scopes(
|
||||||
credential: OAuth2Credentials,
|
credential: Credentials,
|
||||||
requirements: CredentialsFieldInfo,
|
requirements: CredentialsFieldInfo,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if an OAuth2 credential has all the scopes required by the input."""
|
"""
|
||||||
|
Check if a credential has all the scopes required by the block.
|
||||||
|
|
||||||
|
For OAuth2 credentials, verifies that the credential's scopes are a superset
|
||||||
|
of the required scopes. For other credential types, returns True (no scope check).
|
||||||
|
"""
|
||||||
|
# Only OAuth2 credentials have scopes to check
|
||||||
|
if credential.type != "oauth2":
|
||||||
|
return True
|
||||||
|
|
||||||
# If no scopes are required, any credential matches
|
# If no scopes are required, any credential matches
|
||||||
if not requirements.required_scopes:
|
if not requirements.required_scopes:
|
||||||
return True
|
return True
|
||||||
@@ -342,22 +339,6 @@ def _credential_has_required_scopes(
|
|||||||
return set(credential.scopes).issuperset(requirements.required_scopes)
|
return set(credential.scopes).issuperset(requirements.required_scopes)
|
||||||
|
|
||||||
|
|
||||||
def _credential_is_for_host(
|
|
||||||
credential: HostScopedCredentials,
|
|
||||||
requirements: CredentialsFieldInfo,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a host-scoped credential matches the host required by the input."""
|
|
||||||
# We need to know the host to match host-scoped credentials to.
|
|
||||||
# Graph.aggregate_credentials_inputs() adds the node's set URL value (if any)
|
|
||||||
# to discriminator_values. No discriminator_values -> no host to match against.
|
|
||||||
if not requirements.discriminator_values:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check that credential host matches required host.
|
|
||||||
# Host-scoped credential inputs are grouped by host, so any item from the set works.
|
|
||||||
return credential.matches_url(list(requirements.discriminator_values)[0])
|
|
||||||
|
|
||||||
|
|
||||||
async def check_user_has_required_credentials(
|
async def check_user_has_required_credentials(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
required_credentials: list[CredentialsMetaInput],
|
required_credentials: list[CredentialsMetaInput],
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ from backend.data.graph import GraphSettings
|
|||||||
from backend.data.includes import AGENT_PRESET_INCLUDE, library_agent_include
|
from backend.data.includes import AGENT_PRESET_INCLUDE, library_agent_include
|
||||||
from backend.data.model import CredentialsMetaInput
|
from backend.data.model import CredentialsMetaInput
|
||||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||||
from backend.integrations.webhooks.graph_lifecycle_hooks import (
|
from backend.integrations.webhooks.graph_lifecycle_hooks import on_graph_activate
|
||||||
on_graph_activate,
|
|
||||||
on_graph_deactivate,
|
|
||||||
)
|
|
||||||
from backend.util.clients import get_scheduler_client
|
from backend.util.clients import get_scheduler_client
|
||||||
from backend.util.exceptions import DatabaseError, InvalidInputError, NotFoundError
|
from backend.util.exceptions import DatabaseError, InvalidInputError, NotFoundError
|
||||||
from backend.util.json import SafeJson
|
from backend.util.json import SafeJson
|
||||||
@@ -540,92 +537,6 @@ async def update_agent_version_in_library(
|
|||||||
return library_model.LibraryAgent.from_db(lib)
|
return library_model.LibraryAgent.from_db(lib)
|
||||||
|
|
||||||
|
|
||||||
async def create_graph_in_library(
|
|
||||||
graph: graph_db.Graph,
|
|
||||||
user_id: str,
|
|
||||||
) -> tuple[graph_db.GraphModel, library_model.LibraryAgent]:
|
|
||||||
"""Create a new graph and add it to the user's library."""
|
|
||||||
graph.version = 1
|
|
||||||
graph_model = graph_db.make_graph_model(graph, user_id)
|
|
||||||
graph_model.reassign_ids(user_id=user_id, reassign_graph_id=True)
|
|
||||||
|
|
||||||
created_graph = await graph_db.create_graph(graph_model, user_id)
|
|
||||||
|
|
||||||
library_agents = await create_library_agent(
|
|
||||||
graph=created_graph,
|
|
||||||
user_id=user_id,
|
|
||||||
sensitive_action_safe_mode=True,
|
|
||||||
create_library_agents_for_sub_graphs=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if created_graph.is_active:
|
|
||||||
created_graph = await on_graph_activate(created_graph, user_id=user_id)
|
|
||||||
|
|
||||||
return created_graph, library_agents[0]
|
|
||||||
|
|
||||||
|
|
||||||
async def update_graph_in_library(
|
|
||||||
graph: graph_db.Graph,
|
|
||||||
user_id: str,
|
|
||||||
) -> tuple[graph_db.GraphModel, library_model.LibraryAgent]:
|
|
||||||
"""Create a new version of an existing graph and update the library entry."""
|
|
||||||
existing_versions = await graph_db.get_graph_all_versions(graph.id, user_id)
|
|
||||||
current_active_version = (
|
|
||||||
next((v for v in existing_versions if v.is_active), None)
|
|
||||||
if existing_versions
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
graph.version = (
|
|
||||||
max(v.version for v in existing_versions) + 1 if existing_versions else 1
|
|
||||||
)
|
|
||||||
|
|
||||||
graph_model = graph_db.make_graph_model(graph, user_id)
|
|
||||||
graph_model.reassign_ids(user_id=user_id, reassign_graph_id=False)
|
|
||||||
|
|
||||||
created_graph = await graph_db.create_graph(graph_model, user_id)
|
|
||||||
|
|
||||||
library_agent = await get_library_agent_by_graph_id(user_id, created_graph.id)
|
|
||||||
if not library_agent:
|
|
||||||
raise NotFoundError(f"Library agent not found for graph {created_graph.id}")
|
|
||||||
|
|
||||||
library_agent = await update_library_agent_version_and_settings(
|
|
||||||
user_id, created_graph
|
|
||||||
)
|
|
||||||
|
|
||||||
if created_graph.is_active:
|
|
||||||
created_graph = await on_graph_activate(created_graph, user_id=user_id)
|
|
||||||
await graph_db.set_graph_active_version(
|
|
||||||
graph_id=created_graph.id,
|
|
||||||
version=created_graph.version,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
if current_active_version:
|
|
||||||
await on_graph_deactivate(current_active_version, user_id=user_id)
|
|
||||||
|
|
||||||
return created_graph, library_agent
|
|
||||||
|
|
||||||
|
|
||||||
async def update_library_agent_version_and_settings(
|
|
||||||
user_id: str, agent_graph: graph_db.GraphModel
|
|
||||||
) -> library_model.LibraryAgent:
|
|
||||||
"""Update library agent to point to new graph version and sync settings."""
|
|
||||||
library = await update_agent_version_in_library(
|
|
||||||
user_id, agent_graph.id, agent_graph.version
|
|
||||||
)
|
|
||||||
updated_settings = GraphSettings.from_graph(
|
|
||||||
graph=agent_graph,
|
|
||||||
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
|
|
||||||
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
|
|
||||||
)
|
|
||||||
if updated_settings != library.settings:
|
|
||||||
library = await update_library_agent(
|
|
||||||
library_agent_id=library.id,
|
|
||||||
user_id=user_id,
|
|
||||||
settings=updated_settings,
|
|
||||||
)
|
|
||||||
return library
|
|
||||||
|
|
||||||
|
|
||||||
async def update_library_agent(
|
async def update_library_agent(
|
||||||
library_agent_id: str,
|
library_agent_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ from backend.util.timezone_utils import (
|
|||||||
from backend.util.virus_scanner import scan_content_safe
|
from backend.util.virus_scanner import scan_content_safe
|
||||||
|
|
||||||
from .library import db as library_db
|
from .library import db as library_db
|
||||||
|
from .library import model as library_model
|
||||||
from .store.model import StoreAgentDetails
|
from .store.model import StoreAgentDetails
|
||||||
|
|
||||||
|
|
||||||
@@ -822,16 +823,18 @@ async def update_graph(
|
|||||||
graph: graph_db.Graph,
|
graph: graph_db.Graph,
|
||||||
user_id: Annotated[str, Security(get_user_id)],
|
user_id: Annotated[str, Security(get_user_id)],
|
||||||
) -> graph_db.GraphModel:
|
) -> graph_db.GraphModel:
|
||||||
|
# Sanity check
|
||||||
if graph.id and graph.id != graph_id:
|
if graph.id and graph.id != graph_id:
|
||||||
raise HTTPException(400, detail="Graph ID does not match ID in URI")
|
raise HTTPException(400, detail="Graph ID does not match ID in URI")
|
||||||
|
|
||||||
|
# Determine new version
|
||||||
existing_versions = await graph_db.get_graph_all_versions(graph_id, user_id=user_id)
|
existing_versions = await graph_db.get_graph_all_versions(graph_id, user_id=user_id)
|
||||||
if not existing_versions:
|
if not existing_versions:
|
||||||
raise HTTPException(404, detail=f"Graph #{graph_id} not found")
|
raise HTTPException(404, detail=f"Graph #{graph_id} not found")
|
||||||
|
latest_version_number = max(g.version for g in existing_versions)
|
||||||
|
graph.version = latest_version_number + 1
|
||||||
|
|
||||||
graph.version = max(g.version for g in existing_versions) + 1
|
|
||||||
current_active_version = next((v for v in existing_versions if v.is_active), None)
|
current_active_version = next((v for v in existing_versions if v.is_active), None)
|
||||||
|
|
||||||
graph = graph_db.make_graph_model(graph, user_id)
|
graph = graph_db.make_graph_model(graph, user_id)
|
||||||
graph.reassign_ids(user_id=user_id, reassign_graph_id=False)
|
graph.reassign_ids(user_id=user_id, reassign_graph_id=False)
|
||||||
graph.validate_graph(for_run=False)
|
graph.validate_graph(for_run=False)
|
||||||
@@ -839,23 +842,27 @@ async def update_graph(
|
|||||||
new_graph_version = await graph_db.create_graph(graph, user_id=user_id)
|
new_graph_version = await graph_db.create_graph(graph, user_id=user_id)
|
||||||
|
|
||||||
if new_graph_version.is_active:
|
if new_graph_version.is_active:
|
||||||
await library_db.update_library_agent_version_and_settings(
|
# Keep the library agent up to date with the new active version
|
||||||
user_id, new_graph_version
|
await _update_library_agent_version_and_settings(user_id, new_graph_version)
|
||||||
)
|
|
||||||
|
# Handle activation of the new graph first to ensure continuity
|
||||||
new_graph_version = await on_graph_activate(new_graph_version, user_id=user_id)
|
new_graph_version = await on_graph_activate(new_graph_version, user_id=user_id)
|
||||||
|
# Ensure new version is the only active version
|
||||||
await graph_db.set_graph_active_version(
|
await graph_db.set_graph_active_version(
|
||||||
graph_id=graph_id, version=new_graph_version.version, user_id=user_id
|
graph_id=graph_id, version=new_graph_version.version, user_id=user_id
|
||||||
)
|
)
|
||||||
if current_active_version:
|
if current_active_version:
|
||||||
|
# Handle deactivation of the previously active version
|
||||||
await on_graph_deactivate(current_active_version, user_id=user_id)
|
await on_graph_deactivate(current_active_version, user_id=user_id)
|
||||||
|
|
||||||
|
# Fetch new graph version *with sub-graphs* (needed for credentials input schema)
|
||||||
new_graph_version_with_subgraphs = await graph_db.get_graph(
|
new_graph_version_with_subgraphs = await graph_db.get_graph(
|
||||||
graph_id,
|
graph_id,
|
||||||
new_graph_version.version,
|
new_graph_version.version,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
include_subgraphs=True,
|
include_subgraphs=True,
|
||||||
)
|
)
|
||||||
assert new_graph_version_with_subgraphs
|
assert new_graph_version_with_subgraphs # make type checker happy
|
||||||
return new_graph_version_with_subgraphs
|
return new_graph_version_with_subgraphs
|
||||||
|
|
||||||
|
|
||||||
@@ -893,15 +900,33 @@ async def set_graph_active_version(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Keep the library agent up to date with the new active version
|
# Keep the library agent up to date with the new active version
|
||||||
await library_db.update_library_agent_version_and_settings(
|
await _update_library_agent_version_and_settings(user_id, new_active_graph)
|
||||||
user_id, new_active_graph
|
|
||||||
)
|
|
||||||
|
|
||||||
if current_active_graph and current_active_graph.version != new_active_version:
|
if current_active_graph and current_active_graph.version != new_active_version:
|
||||||
# Handle deactivation of the previously active version
|
# Handle deactivation of the previously active version
|
||||||
await on_graph_deactivate(current_active_graph, user_id=user_id)
|
await on_graph_deactivate(current_active_graph, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_library_agent_version_and_settings(
|
||||||
|
user_id: str, agent_graph: graph_db.GraphModel
|
||||||
|
) -> library_model.LibraryAgent:
|
||||||
|
library = await library_db.update_agent_version_in_library(
|
||||||
|
user_id, agent_graph.id, agent_graph.version
|
||||||
|
)
|
||||||
|
updated_settings = GraphSettings.from_graph(
|
||||||
|
graph=agent_graph,
|
||||||
|
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
|
||||||
|
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
|
||||||
|
)
|
||||||
|
if updated_settings != library.settings:
|
||||||
|
library = await library_db.update_library_agent(
|
||||||
|
library_agent_id=library.id,
|
||||||
|
user_id=user_id,
|
||||||
|
settings=updated_settings,
|
||||||
|
)
|
||||||
|
return library
|
||||||
|
|
||||||
|
|
||||||
@v1_router.patch(
|
@v1_router.patch(
|
||||||
path="/graphs/{graph_id}/settings",
|
path="/graphs/{graph_id}/settings",
|
||||||
summary="Update graph settings",
|
summary="Update graph settings",
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
"""ElevenLabs integration blocks - test credentials and shared utilities."""
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import SecretStr
|
|
||||||
|
|
||||||
from backend.data.model import APIKeyCredentials, CredentialsMetaInput
|
|
||||||
from backend.integrations.providers import ProviderName
|
|
||||||
|
|
||||||
TEST_CREDENTIALS = APIKeyCredentials(
|
|
||||||
id="01234567-89ab-cdef-0123-456789abcdef",
|
|
||||||
provider="elevenlabs",
|
|
||||||
api_key=SecretStr("mock-elevenlabs-api-key"),
|
|
||||||
title="Mock ElevenLabs API key",
|
|
||||||
expires_at=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
TEST_CREDENTIALS_INPUT = {
|
|
||||||
"provider": TEST_CREDENTIALS.provider,
|
|
||||||
"id": TEST_CREDENTIALS.id,
|
|
||||||
"type": TEST_CREDENTIALS.type,
|
|
||||||
"title": TEST_CREDENTIALS.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
ElevenLabsCredentials = APIKeyCredentials
|
|
||||||
ElevenLabsCredentialsInput = CredentialsMetaInput[
|
|
||||||
Literal[ProviderName.ELEVENLABS], Literal["api_key"]
|
|
||||||
]
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Text encoding block for converting special characters to escape sequences."""
|
|
||||||
|
|
||||||
import codecs
|
|
||||||
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
|
|
||||||
|
|
||||||
class TextEncoderBlock(Block):
|
|
||||||
"""
|
|
||||||
Encodes a string by converting special characters into escape sequences.
|
|
||||||
|
|
||||||
This block is the inverse of TextDecoderBlock. It takes text containing
|
|
||||||
special characters (like newlines, tabs, etc.) and converts them into
|
|
||||||
their escape sequence representations (e.g., newline becomes \\n).
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
"""Input schema for TextEncoderBlock."""
|
|
||||||
|
|
||||||
text: str = SchemaField(
|
|
||||||
description="A string containing special characters to be encoded",
|
|
||||||
placeholder="Your text with newlines and quotes to encode",
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
"""Output schema for TextEncoderBlock."""
|
|
||||||
|
|
||||||
encoded_text: str = SchemaField(
|
|
||||||
description="The encoded text with special characters converted to escape sequences"
|
|
||||||
)
|
|
||||||
error: str = SchemaField(description="Error message if encoding fails")
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="5185f32e-4b65-4ecf-8fbb-873f003f09d6",
|
|
||||||
description="Encodes a string by converting special characters into escape sequences",
|
|
||||||
categories={BlockCategory.TEXT},
|
|
||||||
input_schema=TextEncoderBlock.Input,
|
|
||||||
output_schema=TextEncoderBlock.Output,
|
|
||||||
test_input={
|
|
||||||
"text": """Hello
|
|
||||||
World!
|
|
||||||
This is a "quoted" string."""
|
|
||||||
},
|
|
||||||
test_output=[
|
|
||||||
(
|
|
||||||
"encoded_text",
|
|
||||||
"""Hello\\nWorld!\\nThis is a "quoted" string.""",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
||||||
"""
|
|
||||||
Encode the input text by converting special characters to escape sequences.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_data: The input containing the text to encode.
|
|
||||||
**kwargs: Additional keyword arguments (unused).
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The encoded text with escape sequences, or an error message if encoding fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
encoded_text = codecs.encode(input_data.text, "unicode_escape").decode(
|
|
||||||
"utf-8"
|
|
||||||
)
|
|
||||||
yield "encoded_text", encoded_text
|
|
||||||
except Exception as e:
|
|
||||||
yield "error", f"Encoding error: {str(e)}"
|
|
||||||
@@ -162,16 +162,8 @@ class LinearClient:
|
|||||||
"searchTerm": team_name,
|
"searchTerm": team_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await self.query(query, variables)
|
team_id = await self.query(query, variables)
|
||||||
nodes = result["teams"]["nodes"]
|
return team_id["teams"]["nodes"][0]["id"]
|
||||||
|
|
||||||
if not nodes:
|
|
||||||
raise LinearAPIException(
|
|
||||||
f"Team '{team_name}' not found. Check the team name or key and try again.",
|
|
||||||
status_code=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nodes[0]["id"]
|
|
||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@@ -248,44 +240,17 @@ class LinearClient:
|
|||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
async def try_search_issues(
|
async def try_search_issues(self, term: str) -> list[Issue]:
|
||||||
self,
|
|
||||||
term: str,
|
|
||||||
max_results: int = 10,
|
|
||||||
team_id: str | None = None,
|
|
||||||
) -> list[Issue]:
|
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
query SearchIssues(
|
query SearchIssues($term: String!, $includeComments: Boolean!) {
|
||||||
$term: String!,
|
searchIssues(term: $term, includeComments: $includeComments) {
|
||||||
$first: Int,
|
|
||||||
$teamId: String
|
|
||||||
) {
|
|
||||||
searchIssues(
|
|
||||||
term: $term,
|
|
||||||
first: $first,
|
|
||||||
teamId: $teamId
|
|
||||||
) {
|
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
identifier
|
identifier
|
||||||
title
|
title
|
||||||
description
|
description
|
||||||
priority
|
priority
|
||||||
createdAt
|
|
||||||
state {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
type
|
|
||||||
}
|
|
||||||
project {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
assignee {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,8 +258,7 @@ class LinearClient:
|
|||||||
|
|
||||||
variables: dict[str, Any] = {
|
variables: dict[str, Any] = {
|
||||||
"term": term,
|
"term": term,
|
||||||
"first": max_results,
|
"includeComments": True,
|
||||||
"teamId": team_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
issues = await self.query(query, variables)
|
issues = await self.query(query, variables)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from ._config import (
|
|||||||
LinearScope,
|
LinearScope,
|
||||||
linear,
|
linear,
|
||||||
)
|
)
|
||||||
from .models import CreateIssueResponse, Issue, State
|
from .models import CreateIssueResponse, Issue
|
||||||
|
|
||||||
|
|
||||||
class LinearCreateIssueBlock(Block):
|
class LinearCreateIssueBlock(Block):
|
||||||
@@ -135,20 +135,9 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
description="Linear credentials with read permissions",
|
description="Linear credentials with read permissions",
|
||||||
required_scopes={LinearScope.READ},
|
required_scopes={LinearScope.READ},
|
||||||
)
|
)
|
||||||
max_results: int = SchemaField(
|
|
||||||
description="Maximum number of results to return",
|
|
||||||
default=10,
|
|
||||||
ge=1,
|
|
||||||
le=100,
|
|
||||||
)
|
|
||||||
team_name: str | None = SchemaField(
|
|
||||||
description="Optional team name to filter results (e.g., 'Internal', 'Open Source')",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
class Output(BlockSchemaOutput):
|
||||||
issues: list[Issue] = SchemaField(description="List of issues")
|
issues: list[Issue] = SchemaField(description="List of issues")
|
||||||
error: str = SchemaField(description="Error message if the search failed")
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -156,11 +145,8 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
description="Searches for issues on Linear",
|
description="Searches for issues on Linear",
|
||||||
input_schema=self.Input,
|
input_schema=self.Input,
|
||||||
output_schema=self.Output,
|
output_schema=self.Output,
|
||||||
categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING},
|
|
||||||
test_input={
|
test_input={
|
||||||
"term": "Test issue",
|
"term": "Test issue",
|
||||||
"max_results": 10,
|
|
||||||
"team_name": None,
|
|
||||||
"credentials": TEST_CREDENTIALS_INPUT_OAUTH,
|
"credentials": TEST_CREDENTIALS_INPUT_OAUTH,
|
||||||
},
|
},
|
||||||
test_credentials=TEST_CREDENTIALS_OAUTH,
|
test_credentials=TEST_CREDENTIALS_OAUTH,
|
||||||
@@ -170,14 +156,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
[
|
[
|
||||||
Issue(
|
Issue(
|
||||||
id="abc123",
|
id="abc123",
|
||||||
identifier="TST-123",
|
identifier="abc123",
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
description="Test description",
|
description="Test description",
|
||||||
priority=1,
|
priority=1,
|
||||||
state=State(
|
|
||||||
id="state1", name="In Progress", type="started"
|
|
||||||
),
|
|
||||||
createdAt="2026-01-15T10:00:00.000Z",
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -186,12 +168,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
"search_issues": lambda *args, **kwargs: [
|
"search_issues": lambda *args, **kwargs: [
|
||||||
Issue(
|
Issue(
|
||||||
id="abc123",
|
id="abc123",
|
||||||
identifier="TST-123",
|
identifier="abc123",
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
description="Test description",
|
description="Test description",
|
||||||
priority=1,
|
priority=1,
|
||||||
state=State(id="state1", name="In Progress", type="started"),
|
|
||||||
createdAt="2026-01-15T10:00:00.000Z",
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -201,22 +181,10 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
async def search_issues(
|
async def search_issues(
|
||||||
credentials: OAuth2Credentials | APIKeyCredentials,
|
credentials: OAuth2Credentials | APIKeyCredentials,
|
||||||
term: str,
|
term: str,
|
||||||
max_results: int = 10,
|
|
||||||
team_name: str | None = None,
|
|
||||||
) -> list[Issue]:
|
) -> list[Issue]:
|
||||||
client = LinearClient(credentials=credentials)
|
client = LinearClient(credentials=credentials)
|
||||||
|
response: list[Issue] = await client.try_search_issues(term=term)
|
||||||
# Resolve team name to ID if provided
|
return response
|
||||||
# Raises LinearAPIException with descriptive message if team not found
|
|
||||||
team_id: str | None = None
|
|
||||||
if team_name:
|
|
||||||
team_id = await client.try_get_team_by_name(team_name=team_name)
|
|
||||||
|
|
||||||
return await client.try_search_issues(
|
|
||||||
term=term,
|
|
||||||
max_results=max_results,
|
|
||||||
team_id=team_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -228,10 +196,7 @@ class LinearSearchIssuesBlock(Block):
|
|||||||
"""Execute the issue search"""
|
"""Execute the issue search"""
|
||||||
try:
|
try:
|
||||||
issues = await self.search_issues(
|
issues = await self.search_issues(
|
||||||
credentials=credentials,
|
credentials=credentials, term=input_data.term
|
||||||
term=input_data.term,
|
|
||||||
max_results=input_data.max_results,
|
|
||||||
team_name=input_data.team_name,
|
|
||||||
)
|
)
|
||||||
yield "issues", issues
|
yield "issues", issues
|
||||||
except LinearAPIException as e:
|
except LinearAPIException as e:
|
||||||
|
|||||||
@@ -36,21 +36,12 @@ class Project(BaseModel):
|
|||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class State(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
type: str | None = (
|
|
||||||
None # Workflow state type (e.g., "triage", "backlog", "started", "completed", "canceled")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Issue(BaseModel):
|
class Issue(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
identifier: str
|
identifier: str
|
||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
priority: int
|
priority: int
|
||||||
state: State | None = None
|
|
||||||
project: Project | None = None
|
project: Project | None = None
|
||||||
createdAt: str | None = None
|
createdAt: str | None = None
|
||||||
comments: list[Comment] | None = None
|
comments: list[Comment] | None = None
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
|||||||
CLAUDE_4_5_OPUS = "claude-opus-4-5-20251101"
|
CLAUDE_4_5_OPUS = "claude-opus-4-5-20251101"
|
||||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
|
CLAUDE_4_5_SONNET = "claude-sonnet-4-5-20250929"
|
||||||
CLAUDE_4_5_HAIKU = "claude-haiku-4-5-20251001"
|
CLAUDE_4_5_HAIKU = "claude-haiku-4-5-20251001"
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6"
|
|
||||||
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
||||||
# AI/ML API models
|
# AI/ML API models
|
||||||
AIML_API_QWEN2_5_72B = "Qwen/Qwen2.5-72B-Instruct-Turbo"
|
AIML_API_QWEN2_5_72B = "Qwen/Qwen2.5-72B-Instruct-Turbo"
|
||||||
@@ -271,9 +270,6 @@ MODEL_METADATA = {
|
|||||||
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
|
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
|
||||||
"anthropic", 200000, 64000, "Claude Sonnet 4", "Anthropic", "Anthropic", 2
|
"anthropic", 200000, 64000, "Claude Sonnet 4", "Anthropic", "Anthropic", 2
|
||||||
), # claude-4-sonnet-20250514
|
), # claude-4-sonnet-20250514
|
||||||
LlmModel.CLAUDE_4_6_OPUS: ModelMetadata(
|
|
||||||
"anthropic", 200000, 128000, "Claude Opus 4.6", "Anthropic", "Anthropic", 3
|
|
||||||
), # claude-opus-4-6
|
|
||||||
LlmModel.CLAUDE_4_5_OPUS: ModelMetadata(
|
LlmModel.CLAUDE_4_5_OPUS: ModelMetadata(
|
||||||
"anthropic", 200000, 64000, "Claude Opus 4.5", "Anthropic", "Anthropic", 3
|
"anthropic", 200000, 64000, "Claude Opus 4.5", "Anthropic", "Anthropic", 3
|
||||||
), # claude-opus-4-5-20251101
|
), # claude-opus-4-5-20251101
|
||||||
|
|||||||
246
autogpt_platform/backend/backend/blocks/media.py
Normal file
246
autogpt_platform/backend/backend/blocks/media.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
||||||
|
from moviepy.video.fx.Loop import Loop
|
||||||
|
from moviepy.video.io.VideoFileClip import VideoFileClip
|
||||||
|
|
||||||
|
from backend.data.block import (
|
||||||
|
Block,
|
||||||
|
BlockCategory,
|
||||||
|
BlockOutput,
|
||||||
|
BlockSchemaInput,
|
||||||
|
BlockSchemaOutput,
|
||||||
|
)
|
||||||
|
from backend.data.execution import ExecutionContext
|
||||||
|
from backend.data.model import SchemaField
|
||||||
|
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDurationBlock(Block):
|
||||||
|
|
||||||
|
class Input(BlockSchemaInput):
|
||||||
|
media_in: MediaFileType = SchemaField(
|
||||||
|
description="Media input (URL, data URI, or local path)."
|
||||||
|
)
|
||||||
|
is_video: bool = SchemaField(
|
||||||
|
description="Whether the media is a video (True) or audio (False).",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Output(BlockSchemaOutput):
|
||||||
|
duration: float = SchemaField(
|
||||||
|
description="Duration of the media file (in seconds)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="d8b91fd4-da26-42d4-8ecb-8b196c6d84b6",
|
||||||
|
description="Block to get the duration of a media file.",
|
||||||
|
categories={BlockCategory.MULTIMEDIA},
|
||||||
|
input_schema=MediaDurationBlock.Input,
|
||||||
|
output_schema=MediaDurationBlock.Output,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
input_data: Input,
|
||||||
|
*,
|
||||||
|
execution_context: ExecutionContext,
|
||||||
|
**kwargs,
|
||||||
|
) -> BlockOutput:
|
||||||
|
# 1) Store the input media locally
|
||||||
|
local_media_path = await store_media_file(
|
||||||
|
file=input_data.media_in,
|
||||||
|
execution_context=execution_context,
|
||||||
|
return_format="for_local_processing",
|
||||||
|
)
|
||||||
|
assert execution_context.graph_exec_id is not None
|
||||||
|
media_abspath = get_exec_file_path(
|
||||||
|
execution_context.graph_exec_id, local_media_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Load the clip
|
||||||
|
if input_data.is_video:
|
||||||
|
clip = VideoFileClip(media_abspath)
|
||||||
|
else:
|
||||||
|
clip = AudioFileClip(media_abspath)
|
||||||
|
|
||||||
|
yield "duration", clip.duration
|
||||||
|
|
||||||
|
|
||||||
|
class LoopVideoBlock(Block):
|
||||||
|
"""
|
||||||
|
Block for looping (repeating) a video clip until a given duration or number of loops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Input(BlockSchemaInput):
|
||||||
|
video_in: MediaFileType = SchemaField(
|
||||||
|
description="The input video (can be a URL, data URI, or local path)."
|
||||||
|
)
|
||||||
|
# Provide EITHER a `duration` or `n_loops` or both. We'll demonstrate `duration`.
|
||||||
|
duration: Optional[float] = SchemaField(
|
||||||
|
description="Target duration (in seconds) to loop the video to. If omitted, defaults to no looping.",
|
||||||
|
default=None,
|
||||||
|
ge=0.0,
|
||||||
|
)
|
||||||
|
n_loops: Optional[int] = SchemaField(
|
||||||
|
description="Number of times to repeat the video. If omitted, defaults to 1 (no repeat).",
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Output(BlockSchemaOutput):
|
||||||
|
video_out: str = SchemaField(
|
||||||
|
description="Looped video returned either as a relative path or a data URI."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="8bf9eef6-5451-4213-b265-25306446e94b",
|
||||||
|
description="Block to loop a video to a given duration or number of repeats.",
|
||||||
|
categories={BlockCategory.MULTIMEDIA},
|
||||||
|
input_schema=LoopVideoBlock.Input,
|
||||||
|
output_schema=LoopVideoBlock.Output,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
input_data: Input,
|
||||||
|
*,
|
||||||
|
execution_context: ExecutionContext,
|
||||||
|
**kwargs,
|
||||||
|
) -> BlockOutput:
|
||||||
|
assert execution_context.graph_exec_id is not None
|
||||||
|
assert execution_context.node_exec_id is not None
|
||||||
|
graph_exec_id = execution_context.graph_exec_id
|
||||||
|
node_exec_id = execution_context.node_exec_id
|
||||||
|
|
||||||
|
# 1) Store the input video locally
|
||||||
|
local_video_path = await store_media_file(
|
||||||
|
file=input_data.video_in,
|
||||||
|
execution_context=execution_context,
|
||||||
|
return_format="for_local_processing",
|
||||||
|
)
|
||||||
|
input_abspath = get_exec_file_path(graph_exec_id, local_video_path)
|
||||||
|
|
||||||
|
# 2) Load the clip
|
||||||
|
clip = VideoFileClip(input_abspath)
|
||||||
|
|
||||||
|
# 3) Apply the loop effect
|
||||||
|
looped_clip = clip
|
||||||
|
if input_data.duration:
|
||||||
|
# Loop until we reach the specified duration
|
||||||
|
looped_clip = looped_clip.with_effects([Loop(duration=input_data.duration)])
|
||||||
|
elif input_data.n_loops:
|
||||||
|
looped_clip = looped_clip.with_effects([Loop(n=input_data.n_loops)])
|
||||||
|
else:
|
||||||
|
raise ValueError("Either 'duration' or 'n_loops' must be provided.")
|
||||||
|
|
||||||
|
assert isinstance(looped_clip, VideoFileClip)
|
||||||
|
|
||||||
|
# 4) Save the looped output
|
||||||
|
output_filename = MediaFileType(
|
||||||
|
f"{node_exec_id}_looped_{os.path.basename(local_video_path)}"
|
||||||
|
)
|
||||||
|
output_abspath = get_exec_file_path(graph_exec_id, output_filename)
|
||||||
|
|
||||||
|
looped_clip = looped_clip.with_audio(clip.audio)
|
||||||
|
looped_clip.write_videofile(output_abspath, codec="libx264", audio_codec="aac")
|
||||||
|
|
||||||
|
# Return output - for_block_output returns workspace:// if available, else data URI
|
||||||
|
video_out = await store_media_file(
|
||||||
|
file=output_filename,
|
||||||
|
execution_context=execution_context,
|
||||||
|
return_format="for_block_output",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield "video_out", video_out
|
||||||
|
|
||||||
|
|
||||||
|
class AddAudioToVideoBlock(Block):
|
||||||
|
"""
|
||||||
|
Block that adds (attaches) an audio track to an existing video.
|
||||||
|
Optionally scale the volume of the new track.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Input(BlockSchemaInput):
|
||||||
|
video_in: MediaFileType = SchemaField(
|
||||||
|
description="Video input (URL, data URI, or local path)."
|
||||||
|
)
|
||||||
|
audio_in: MediaFileType = SchemaField(
|
||||||
|
description="Audio input (URL, data URI, or local path)."
|
||||||
|
)
|
||||||
|
volume: float = SchemaField(
|
||||||
|
description="Volume scale for the newly attached audio track (1.0 = original).",
|
||||||
|
default=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Output(BlockSchemaOutput):
|
||||||
|
video_out: MediaFileType = SchemaField(
|
||||||
|
description="Final video (with attached audio), as a path or data URI."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="3503748d-62b6-4425-91d6-725b064af509",
|
||||||
|
description="Block to attach an audio file to a video file using moviepy.",
|
||||||
|
categories={BlockCategory.MULTIMEDIA},
|
||||||
|
input_schema=AddAudioToVideoBlock.Input,
|
||||||
|
output_schema=AddAudioToVideoBlock.Output,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
input_data: Input,
|
||||||
|
*,
|
||||||
|
execution_context: ExecutionContext,
|
||||||
|
**kwargs,
|
||||||
|
) -> BlockOutput:
|
||||||
|
assert execution_context.graph_exec_id is not None
|
||||||
|
assert execution_context.node_exec_id is not None
|
||||||
|
graph_exec_id = execution_context.graph_exec_id
|
||||||
|
node_exec_id = execution_context.node_exec_id
|
||||||
|
|
||||||
|
# 1) Store the inputs locally
|
||||||
|
local_video_path = await store_media_file(
|
||||||
|
file=input_data.video_in,
|
||||||
|
execution_context=execution_context,
|
||||||
|
return_format="for_local_processing",
|
||||||
|
)
|
||||||
|
local_audio_path = await store_media_file(
|
||||||
|
file=input_data.audio_in,
|
||||||
|
execution_context=execution_context,
|
||||||
|
return_format="for_local_processing",
|
||||||
|
)
|
||||||
|
|
||||||
|
abs_temp_dir = os.path.join(tempfile.gettempdir(), "exec_file", graph_exec_id)
|
||||||
|
video_abspath = os.path.join(abs_temp_dir, local_video_path)
|
||||||
|
audio_abspath = os.path.join(abs_temp_dir, local_audio_path)
|
||||||
|
|
||||||
|
# 2) Load video + audio with moviepy
|
||||||
|
video_clip = VideoFileClip(video_abspath)
|
||||||
|
audio_clip = AudioFileClip(audio_abspath)
|
||||||
|
# Optionally scale volume
|
||||||
|
if input_data.volume != 1.0:
|
||||||
|
audio_clip = audio_clip.with_volume_scaled(input_data.volume)
|
||||||
|
|
||||||
|
# 3) Attach the new audio track
|
||||||
|
final_clip = video_clip.with_audio(audio_clip)
|
||||||
|
|
||||||
|
# 4) Write to output file
|
||||||
|
output_filename = MediaFileType(
|
||||||
|
f"{node_exec_id}_audio_attached_{os.path.basename(local_video_path)}"
|
||||||
|
)
|
||||||
|
output_abspath = os.path.join(abs_temp_dir, output_filename)
|
||||||
|
final_clip.write_videofile(output_abspath, codec="libx264", audio_codec="aac")
|
||||||
|
|
||||||
|
# 5) Return output - for_block_output returns workspace:// if available, else data URI
|
||||||
|
video_out = await store_media_file(
|
||||||
|
file=output_filename,
|
||||||
|
execution_context=execution_context,
|
||||||
|
return_format="for_block_output",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield "video_out", video_out
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from backend.blocks.encoder_block import TextEncoderBlock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_text_encoder_basic():
|
|
||||||
"""Test basic encoding of newlines and special characters."""
|
|
||||||
block = TextEncoderBlock()
|
|
||||||
result = []
|
|
||||||
async for output in block.run(TextEncoderBlock.Input(text="Hello\nWorld")):
|
|
||||||
result.append(output)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "encoded_text"
|
|
||||||
assert result[0][1] == "Hello\\nWorld"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_text_encoder_multiple_escapes():
|
|
||||||
"""Test encoding of multiple escape sequences."""
|
|
||||||
block = TextEncoderBlock()
|
|
||||||
result = []
|
|
||||||
async for output in block.run(
|
|
||||||
TextEncoderBlock.Input(text="Line1\nLine2\tTabbed\rCarriage")
|
|
||||||
):
|
|
||||||
result.append(output)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "encoded_text"
|
|
||||||
assert "\\n" in result[0][1]
|
|
||||||
assert "\\t" in result[0][1]
|
|
||||||
assert "\\r" in result[0][1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_text_encoder_unicode():
|
|
||||||
"""Test that unicode characters are handled correctly."""
|
|
||||||
block = TextEncoderBlock()
|
|
||||||
result = []
|
|
||||||
async for output in block.run(TextEncoderBlock.Input(text="Hello 世界\n")):
|
|
||||||
result.append(output)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "encoded_text"
|
|
||||||
# Unicode characters should be escaped as \uXXXX sequences
|
|
||||||
assert "\\n" in result[0][1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_text_encoder_empty_string():
|
|
||||||
"""Test encoding of an empty string."""
|
|
||||||
block = TextEncoderBlock()
|
|
||||||
result = []
|
|
||||||
async for output in block.run(TextEncoderBlock.Input(text="")):
|
|
||||||
result.append(output)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "encoded_text"
|
|
||||||
assert result[0][1] == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_text_encoder_error_handling():
|
|
||||||
"""Test that encoding errors are handled gracefully."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
block = TextEncoderBlock()
|
|
||||||
result = []
|
|
||||||
|
|
||||||
with patch("codecs.encode", side_effect=Exception("Mocked encoding error")):
|
|
||||||
async for output in block.run(TextEncoderBlock.Input(text="test")):
|
|
||||||
result.append(output)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "error"
|
|
||||||
assert "Mocked encoding error" in result[0][1]
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Video editing blocks for AutoGPT Platform.
|
|
||||||
|
|
||||||
This module provides blocks for:
|
|
||||||
- Downloading videos from URLs (YouTube, Vimeo, news sites, direct links)
|
|
||||||
- Clipping/trimming video segments
|
|
||||||
- Concatenating multiple videos
|
|
||||||
- Adding text overlays
|
|
||||||
- Adding AI-generated narration
|
|
||||||
- Getting media duration
|
|
||||||
- Looping videos
|
|
||||||
- Adding audio to videos
|
|
||||||
|
|
||||||
Dependencies:
|
|
||||||
- yt-dlp: For video downloading
|
|
||||||
- moviepy: For video editing operations
|
|
||||||
- elevenlabs: For AI narration (optional)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from backend.blocks.video.add_audio import AddAudioToVideoBlock
|
|
||||||
from backend.blocks.video.clip import VideoClipBlock
|
|
||||||
from backend.blocks.video.concat import VideoConcatBlock
|
|
||||||
from backend.blocks.video.download import VideoDownloadBlock
|
|
||||||
from backend.blocks.video.duration import MediaDurationBlock
|
|
||||||
from backend.blocks.video.loop import LoopVideoBlock
|
|
||||||
from backend.blocks.video.narration import VideoNarrationBlock
|
|
||||||
from backend.blocks.video.text_overlay import VideoTextOverlayBlock
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AddAudioToVideoBlock",
|
|
||||||
"LoopVideoBlock",
|
|
||||||
"MediaDurationBlock",
|
|
||||||
"VideoClipBlock",
|
|
||||||
"VideoConcatBlock",
|
|
||||||
"VideoDownloadBlock",
|
|
||||||
"VideoNarrationBlock",
|
|
||||||
"VideoTextOverlayBlock",
|
|
||||||
]
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Shared utilities for video blocks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Known operation tags added by video blocks
|
|
||||||
_VIDEO_OPS = (
|
|
||||||
r"(?:clip|overlay|narrated|looped|concat|audio_attached|with_audio|narration)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Matches: {node_exec_id}_{operation}_ where node_exec_id contains a UUID
|
|
||||||
_BLOCK_PREFIX_RE = re.compile(
|
|
||||||
r"^[a-zA-Z0-9_-]*"
|
|
||||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
||||||
r"[a-zA-Z0-9_-]*"
|
|
||||||
r"_" + _VIDEO_OPS + r"_"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Matches: a lone {node_exec_id}_ prefix (no operation keyword, e.g. download output)
|
|
||||||
_UUID_PREFIX_RE = re.compile(
|
|
||||||
r"^[a-zA-Z0-9_-]*"
|
|
||||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
||||||
r"[a-zA-Z0-9_-]*_"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_source_name(input_path: str, max_length: int = 50) -> str:
|
|
||||||
"""Extract the original source filename by stripping block-generated prefixes.
|
|
||||||
|
|
||||||
Iteratively removes {node_exec_id}_{operation}_ prefixes that accumulate
|
|
||||||
when chaining video blocks, recovering the original human-readable name.
|
|
||||||
|
|
||||||
Safe for plain filenames (no UUID -> no stripping).
|
|
||||||
Falls back to "video" if everything is stripped.
|
|
||||||
"""
|
|
||||||
stem = Path(input_path).stem
|
|
||||||
|
|
||||||
# Pass 1: strip {node_exec_id}_{operation}_ prefixes iteratively
|
|
||||||
while _BLOCK_PREFIX_RE.match(stem):
|
|
||||||
stem = _BLOCK_PREFIX_RE.sub("", stem, count=1)
|
|
||||||
|
|
||||||
# Pass 2: strip a lone {node_exec_id}_ prefix (e.g. from download block)
|
|
||||||
if _UUID_PREFIX_RE.match(stem):
|
|
||||||
stem = _UUID_PREFIX_RE.sub("", stem, count=1)
|
|
||||||
|
|
||||||
if not stem:
|
|
||||||
return "video"
|
|
||||||
|
|
||||||
return stem[:max_length]
|
|
||||||
|
|
||||||
|
|
||||||
def get_video_codecs(output_path: str) -> tuple[str, str]:
|
|
||||||
"""Get appropriate video and audio codecs based on output file extension.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output_path: Path to the output file (used to determine extension)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (video_codec, audio_codec)
|
|
||||||
|
|
||||||
Codec mappings:
|
|
||||||
- .mp4: H.264 + AAC (universal compatibility)
|
|
||||||
- .webm: VP8 + Vorbis (web streaming)
|
|
||||||
- .mkv: H.264 + AAC (container supports many codecs)
|
|
||||||
- .mov: H.264 + AAC (Apple QuickTime, widely compatible)
|
|
||||||
- .m4v: H.264 + AAC (Apple iTunes/devices)
|
|
||||||
- .avi: MPEG-4 + MP3 (legacy Windows)
|
|
||||||
"""
|
|
||||||
ext = os.path.splitext(output_path)[1].lower()
|
|
||||||
|
|
||||||
codec_map: dict[str, tuple[str, str]] = {
|
|
||||||
".mp4": ("libx264", "aac"),
|
|
||||||
".webm": ("libvpx", "libvorbis"),
|
|
||||||
".mkv": ("libx264", "aac"),
|
|
||||||
".mov": ("libx264", "aac"),
|
|
||||||
".m4v": ("libx264", "aac"),
|
|
||||||
".avi": ("mpeg4", "libmp3lame"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return codec_map.get(ext, ("libx264", "aac"))
|
|
||||||
|
|
||||||
|
|
||||||
def strip_chapters_inplace(video_path: str) -> None:
|
|
||||||
"""Strip chapter metadata from a media file in-place using ffmpeg.
|
|
||||||
|
|
||||||
MoviePy 2.x crashes with IndexError when parsing files with embedded
|
|
||||||
chapter metadata (https://github.com/Zulko/moviepy/issues/2419).
|
|
||||||
This strips chapters without re-encoding.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_path: Absolute path to the media file to strip chapters from.
|
|
||||||
"""
|
|
||||||
base, ext = os.path.splitext(video_path)
|
|
||||||
tmp_path = base + ".tmp" + ext
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
video_path,
|
|
||||||
"-map_chapters",
|
|
||||||
"-1",
|
|
||||||
"-codec",
|
|
||||||
"copy",
|
|
||||||
tmp_path,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.warning(
|
|
||||||
"ffmpeg chapter strip failed (rc=%d): %s",
|
|
||||||
result.returncode,
|
|
||||||
result.stderr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
os.replace(tmp_path, video_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.warning("ffmpeg not found; skipping chapter strip")
|
|
||||||
finally:
|
|
||||||
if os.path.exists(tmp_path):
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""AddAudioToVideoBlock - Attach an audio track to a video file."""
|
|
||||||
|
|
||||||
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.video._utils import extract_source_name, strip_chapters_inplace
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class AddAudioToVideoBlock(Block):
|
|
||||||
"""Add (attach) an audio track to an existing video."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
video_in: MediaFileType = SchemaField(
|
|
||||||
description="Video input (URL, data URI, or local path)."
|
|
||||||
)
|
|
||||||
audio_in: MediaFileType = SchemaField(
|
|
||||||
description="Audio input (URL, data URI, or local path)."
|
|
||||||
)
|
|
||||||
volume: float = SchemaField(
|
|
||||||
description="Volume scale for the newly attached audio track (1.0 = original).",
|
|
||||||
default=1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_out: MediaFileType = SchemaField(
|
|
||||||
description="Final video (with attached audio), as a path or data URI."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="3503748d-62b6-4425-91d6-725b064af509",
|
|
||||||
description="Block to attach an audio file to a video file using moviepy.",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=AddAudioToVideoBlock.Input,
|
|
||||||
output_schema=AddAudioToVideoBlock.Output,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
assert execution_context.node_exec_id is not None
|
|
||||||
graph_exec_id = execution_context.graph_exec_id
|
|
||||||
node_exec_id = execution_context.node_exec_id
|
|
||||||
|
|
||||||
# 1) Store the inputs locally
|
|
||||||
local_video_path = await store_media_file(
|
|
||||||
file=input_data.video_in,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
local_audio_path = await store_media_file(
|
|
||||||
file=input_data.audio_in,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
|
|
||||||
video_abspath = get_exec_file_path(graph_exec_id, local_video_path)
|
|
||||||
audio_abspath = get_exec_file_path(graph_exec_id, local_audio_path)
|
|
||||||
|
|
||||||
# 2) Load video + audio with moviepy
|
|
||||||
strip_chapters_inplace(video_abspath)
|
|
||||||
strip_chapters_inplace(audio_abspath)
|
|
||||||
video_clip = None
|
|
||||||
audio_clip = None
|
|
||||||
final_clip = None
|
|
||||||
try:
|
|
||||||
video_clip = VideoFileClip(video_abspath)
|
|
||||||
audio_clip = AudioFileClip(audio_abspath)
|
|
||||||
# Optionally scale volume
|
|
||||||
if input_data.volume != 1.0:
|
|
||||||
audio_clip = audio_clip.with_volume_scaled(input_data.volume)
|
|
||||||
|
|
||||||
# 3) Attach the new audio track
|
|
||||||
final_clip = video_clip.with_audio(audio_clip)
|
|
||||||
|
|
||||||
# 4) Write to output file
|
|
||||||
source = extract_source_name(local_video_path)
|
|
||||||
output_filename = MediaFileType(f"{node_exec_id}_with_audio_{source}.mp4")
|
|
||||||
output_abspath = get_exec_file_path(graph_exec_id, output_filename)
|
|
||||||
final_clip.write_videofile(
|
|
||||||
output_abspath, codec="libx264", audio_codec="aac"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if final_clip:
|
|
||||||
final_clip.close()
|
|
||||||
if audio_clip:
|
|
||||||
audio_clip.close()
|
|
||||||
if video_clip:
|
|
||||||
video_clip.close()
|
|
||||||
|
|
||||||
# 5) Return output - for_block_output returns workspace:// if available, else data URI
|
|
||||||
video_out = await store_media_file(
|
|
||||||
file=output_filename,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_out", video_out
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"""VideoClipBlock - Extract a segment from a video file."""
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.video._utils import (
|
|
||||||
extract_source_name,
|
|
||||||
get_video_codecs,
|
|
||||||
strip_chapters_inplace,
|
|
||||||
)
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.exceptions import BlockExecutionError
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class VideoClipBlock(Block):
|
|
||||||
"""Extract a time segment from a video."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
video_in: MediaFileType = SchemaField(
|
|
||||||
description="Input video (URL, data URI, or local path)"
|
|
||||||
)
|
|
||||||
start_time: float = SchemaField(description="Start time in seconds", ge=0.0)
|
|
||||||
end_time: float = SchemaField(description="End time in seconds", ge=0.0)
|
|
||||||
output_format: Literal["mp4", "webm", "mkv", "mov"] = SchemaField(
|
|
||||||
description="Output format", default="mp4", advanced=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_out: MediaFileType = SchemaField(
|
|
||||||
description="Clipped video file (path or data URI)"
|
|
||||||
)
|
|
||||||
duration: float = SchemaField(description="Clip duration in seconds")
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="8f539119-e580-4d86-ad41-86fbcb22abb1",
|
|
||||||
description="Extract a time segment from a video",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=self.Input,
|
|
||||||
output_schema=self.Output,
|
|
||||||
test_input={
|
|
||||||
"video_in": "/tmp/test.mp4",
|
|
||||||
"start_time": 0.0,
|
|
||||||
"end_time": 10.0,
|
|
||||||
},
|
|
||||||
test_output=[("video_out", str), ("duration", float)],
|
|
||||||
test_mock={
|
|
||||||
"_clip_video": lambda *args: 10.0,
|
|
||||||
"_store_input_video": lambda *args, **kwargs: "test.mp4",
|
|
||||||
"_store_output_video": lambda *args, **kwargs: "clip_test.mp4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_input_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store input video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_output_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store output video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _clip_video(
|
|
||||||
self,
|
|
||||||
video_abspath: str,
|
|
||||||
output_abspath: str,
|
|
||||||
start_time: float,
|
|
||||||
end_time: float,
|
|
||||||
) -> float:
|
|
||||||
"""Extract a clip from a video. Extracted for testability."""
|
|
||||||
clip = None
|
|
||||||
subclip = None
|
|
||||||
try:
|
|
||||||
strip_chapters_inplace(video_abspath)
|
|
||||||
clip = VideoFileClip(video_abspath)
|
|
||||||
subclip = clip.subclipped(start_time, end_time)
|
|
||||||
video_codec, audio_codec = get_video_codecs(output_abspath)
|
|
||||||
subclip.write_videofile(
|
|
||||||
output_abspath, codec=video_codec, audio_codec=audio_codec
|
|
||||||
)
|
|
||||||
return subclip.duration
|
|
||||||
finally:
|
|
||||||
if subclip:
|
|
||||||
subclip.close()
|
|
||||||
if clip:
|
|
||||||
clip.close()
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
node_exec_id: str,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
# Validate time range
|
|
||||||
if input_data.end_time <= input_data.start_time:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"end_time ({input_data.end_time}) must be greater than start_time ({input_data.start_time})",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
|
|
||||||
# Store the input video locally
|
|
||||||
local_video_path = await self._store_input_video(
|
|
||||||
execution_context, input_data.video_in
|
|
||||||
)
|
|
||||||
video_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, local_video_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build output path
|
|
||||||
source = extract_source_name(local_video_path)
|
|
||||||
output_filename = MediaFileType(
|
|
||||||
f"{node_exec_id}_clip_{source}.{input_data.output_format}"
|
|
||||||
)
|
|
||||||
output_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
duration = self._clip_video(
|
|
||||||
video_abspath,
|
|
||||||
output_abspath,
|
|
||||||
input_data.start_time,
|
|
||||||
input_data.end_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return as workspace path or data URI based on context
|
|
||||||
video_out = await self._store_output_video(
|
|
||||||
execution_context, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_out", video_out
|
|
||||||
yield "duration", duration
|
|
||||||
|
|
||||||
except BlockExecutionError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"Failed to clip video: {e}",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
) from e
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"""VideoConcatBlock - Concatenate multiple video clips into one."""
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from moviepy import concatenate_videoclips
|
|
||||||
from moviepy.video.fx import CrossFadeIn, CrossFadeOut, FadeIn, FadeOut
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.video._utils import (
|
|
||||||
extract_source_name,
|
|
||||||
get_video_codecs,
|
|
||||||
strip_chapters_inplace,
|
|
||||||
)
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.exceptions import BlockExecutionError
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class VideoConcatBlock(Block):
|
|
||||||
"""Merge multiple video clips into one continuous video."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
videos: list[MediaFileType] = SchemaField(
|
|
||||||
description="List of video files to concatenate (in order)"
|
|
||||||
)
|
|
||||||
transition: Literal["none", "crossfade", "fade_black"] = SchemaField(
|
|
||||||
description="Transition between clips", default="none"
|
|
||||||
)
|
|
||||||
transition_duration: int = SchemaField(
|
|
||||||
description="Transition duration in seconds",
|
|
||||||
default=1,
|
|
||||||
ge=0,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
output_format: Literal["mp4", "webm", "mkv", "mov"] = SchemaField(
|
|
||||||
description="Output format", default="mp4", advanced=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_out: MediaFileType = SchemaField(
|
|
||||||
description="Concatenated video file (path or data URI)"
|
|
||||||
)
|
|
||||||
total_duration: float = SchemaField(description="Total duration in seconds")
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="9b0f531a-1118-487f-aeec-3fa63ea8900a",
|
|
||||||
description="Merge multiple video clips into one continuous video",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=self.Input,
|
|
||||||
output_schema=self.Output,
|
|
||||||
test_input={
|
|
||||||
"videos": ["/tmp/a.mp4", "/tmp/b.mp4"],
|
|
||||||
},
|
|
||||||
test_output=[
|
|
||||||
("video_out", str),
|
|
||||||
("total_duration", float),
|
|
||||||
],
|
|
||||||
test_mock={
|
|
||||||
"_concat_videos": lambda *args: 20.0,
|
|
||||||
"_store_input_video": lambda *args, **kwargs: "test.mp4",
|
|
||||||
"_store_output_video": lambda *args, **kwargs: "concat_test.mp4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_input_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store input video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_output_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store output video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _concat_videos(
|
|
||||||
self,
|
|
||||||
video_abspaths: list[str],
|
|
||||||
output_abspath: str,
|
|
||||||
transition: str,
|
|
||||||
transition_duration: int,
|
|
||||||
) -> float:
|
|
||||||
"""Concatenate videos. Extracted for testability.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total duration of the concatenated video.
|
|
||||||
"""
|
|
||||||
clips = []
|
|
||||||
faded_clips = []
|
|
||||||
final = None
|
|
||||||
try:
|
|
||||||
# Load clips
|
|
||||||
for v in video_abspaths:
|
|
||||||
strip_chapters_inplace(v)
|
|
||||||
clips.append(VideoFileClip(v))
|
|
||||||
|
|
||||||
# Validate transition_duration against shortest clip
|
|
||||||
if transition in {"crossfade", "fade_black"} and transition_duration > 0:
|
|
||||||
min_duration = min(c.duration for c in clips)
|
|
||||||
if transition_duration >= min_duration:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=(
|
|
||||||
f"transition_duration ({transition_duration}s) must be "
|
|
||||||
f"shorter than the shortest clip ({min_duration:.2f}s)"
|
|
||||||
),
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
if transition == "crossfade":
|
|
||||||
for i, clip in enumerate(clips):
|
|
||||||
effects = []
|
|
||||||
if i > 0:
|
|
||||||
effects.append(CrossFadeIn(transition_duration))
|
|
||||||
if i < len(clips) - 1:
|
|
||||||
effects.append(CrossFadeOut(transition_duration))
|
|
||||||
if effects:
|
|
||||||
clip = clip.with_effects(effects)
|
|
||||||
faded_clips.append(clip)
|
|
||||||
final = concatenate_videoclips(
|
|
||||||
faded_clips,
|
|
||||||
method="compose",
|
|
||||||
padding=-transition_duration,
|
|
||||||
)
|
|
||||||
elif transition == "fade_black":
|
|
||||||
for clip in clips:
|
|
||||||
faded = clip.with_effects(
|
|
||||||
[FadeIn(transition_duration), FadeOut(transition_duration)]
|
|
||||||
)
|
|
||||||
faded_clips.append(faded)
|
|
||||||
final = concatenate_videoclips(faded_clips)
|
|
||||||
else:
|
|
||||||
final = concatenate_videoclips(clips)
|
|
||||||
|
|
||||||
video_codec, audio_codec = get_video_codecs(output_abspath)
|
|
||||||
final.write_videofile(
|
|
||||||
output_abspath, codec=video_codec, audio_codec=audio_codec
|
|
||||||
)
|
|
||||||
|
|
||||||
return final.duration
|
|
||||||
finally:
|
|
||||||
if final:
|
|
||||||
final.close()
|
|
||||||
for clip in faded_clips:
|
|
||||||
clip.close()
|
|
||||||
for clip in clips:
|
|
||||||
clip.close()
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
node_exec_id: str,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
# Validate minimum clips
|
|
||||||
if len(input_data.videos) < 2:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message="At least 2 videos are required for concatenation",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
|
|
||||||
# Store all input videos locally
|
|
||||||
video_abspaths = []
|
|
||||||
for video in input_data.videos:
|
|
||||||
local_path = await self._store_input_video(execution_context, video)
|
|
||||||
video_abspaths.append(
|
|
||||||
get_exec_file_path(execution_context.graph_exec_id, local_path)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build output path
|
|
||||||
source = (
|
|
||||||
extract_source_name(video_abspaths[0]) if video_abspaths else "video"
|
|
||||||
)
|
|
||||||
output_filename = MediaFileType(
|
|
||||||
f"{node_exec_id}_concat_{source}.{input_data.output_format}"
|
|
||||||
)
|
|
||||||
output_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
total_duration = self._concat_videos(
|
|
||||||
video_abspaths,
|
|
||||||
output_abspath,
|
|
||||||
input_data.transition,
|
|
||||||
input_data.transition_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return as workspace path or data URI based on context
|
|
||||||
video_out = await self._store_output_video(
|
|
||||||
execution_context, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_out", video_out
|
|
||||||
yield "total_duration", total_duration
|
|
||||||
|
|
||||||
except BlockExecutionError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"Failed to concatenate videos: {e}",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
) from e
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"""VideoDownloadBlock - Download video from URL (YouTube, Vimeo, news sites, direct links)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import typing
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import yt_dlp
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from yt_dlp import _Params
|
|
||||||
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.exceptions import BlockExecutionError
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class VideoDownloadBlock(Block):
|
|
||||||
"""Download video from URL using yt-dlp."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
url: str = SchemaField(
|
|
||||||
description="URL of the video to download (YouTube, Vimeo, direct link, etc.)",
|
|
||||||
placeholder="https://www.youtube.com/watch?v=...",
|
|
||||||
)
|
|
||||||
quality: Literal["best", "1080p", "720p", "480p", "audio_only"] = SchemaField(
|
|
||||||
description="Video quality preference", default="720p"
|
|
||||||
)
|
|
||||||
output_format: Literal["mp4", "webm", "mkv"] = SchemaField(
|
|
||||||
description="Output video format", default="mp4", advanced=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_file: MediaFileType = SchemaField(
|
|
||||||
description="Downloaded video (path or data URI)"
|
|
||||||
)
|
|
||||||
duration: float = SchemaField(description="Video duration in seconds")
|
|
||||||
title: str = SchemaField(description="Video title from source")
|
|
||||||
source_url: str = SchemaField(description="Original source URL")
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="c35daabb-cd60-493b-b9ad-51f1fe4b50c4",
|
|
||||||
description="Download video from URL (YouTube, Vimeo, news sites, direct links)",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=self.Input,
|
|
||||||
output_schema=self.Output,
|
|
||||||
disabled=True, # Disable until we can sandbox yt-dlp and handle security implications
|
|
||||||
test_input={
|
|
||||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
||||||
"quality": "480p",
|
|
||||||
},
|
|
||||||
test_output=[
|
|
||||||
("video_file", str),
|
|
||||||
("duration", float),
|
|
||||||
("title", str),
|
|
||||||
("source_url", str),
|
|
||||||
],
|
|
||||||
test_mock={
|
|
||||||
"_download_video": lambda *args: (
|
|
||||||
"video.mp4",
|
|
||||||
212.0,
|
|
||||||
"Test Video",
|
|
||||||
),
|
|
||||||
"_store_output_video": lambda *args, **kwargs: "video.mp4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_output_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store output video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_format_string(self, quality: str) -> str:
|
|
||||||
formats = {
|
|
||||||
"best": "bestvideo+bestaudio/best",
|
|
||||||
"1080p": "bestvideo[height<=1080]+bestaudio/best[height<=1080]",
|
|
||||||
"720p": "bestvideo[height<=720]+bestaudio/best[height<=720]",
|
|
||||||
"480p": "bestvideo[height<=480]+bestaudio/best[height<=480]",
|
|
||||||
"audio_only": "bestaudio/best",
|
|
||||||
}
|
|
||||||
return formats.get(quality, formats["720p"])
|
|
||||||
|
|
||||||
def _download_video(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
quality: str,
|
|
||||||
output_format: str,
|
|
||||||
output_dir: str,
|
|
||||||
node_exec_id: str,
|
|
||||||
) -> tuple[str, float, str]:
|
|
||||||
"""Download video. Extracted for testability."""
|
|
||||||
output_template = os.path.join(
|
|
||||||
output_dir, f"{node_exec_id}_%(title).50s.%(ext)s"
|
|
||||||
)
|
|
||||||
|
|
||||||
ydl_opts: "_Params" = {
|
|
||||||
"format": f"{self._get_format_string(quality)}/best",
|
|
||||||
"outtmpl": output_template,
|
|
||||||
"merge_output_format": output_format,
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
||||||
info = ydl.extract_info(url, download=True)
|
|
||||||
video_path = ydl.prepare_filename(info)
|
|
||||||
|
|
||||||
# Handle format conversion in filename
|
|
||||||
if not video_path.endswith(f".{output_format}"):
|
|
||||||
video_path = video_path.rsplit(".", 1)[0] + f".{output_format}"
|
|
||||||
|
|
||||||
# Return just the filename, not the full path
|
|
||||||
filename = os.path.basename(video_path)
|
|
||||||
|
|
||||||
return (
|
|
||||||
filename,
|
|
||||||
info.get("duration") or 0.0,
|
|
||||||
info.get("title") or "Unknown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
node_exec_id: str,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
try:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
|
|
||||||
# Get the exec file directory
|
|
||||||
output_dir = get_exec_file_path(execution_context.graph_exec_id, "")
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
|
|
||||||
filename, duration, title = self._download_video(
|
|
||||||
input_data.url,
|
|
||||||
input_data.quality,
|
|
||||||
input_data.output_format,
|
|
||||||
output_dir,
|
|
||||||
node_exec_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return as workspace path or data URI based on context
|
|
||||||
video_out = await self._store_output_video(
|
|
||||||
execution_context, MediaFileType(filename)
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_file", video_out
|
|
||||||
yield "duration", duration
|
|
||||||
yield "title", title
|
|
||||||
yield "source_url", input_data.url
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"Failed to download video: {e}",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
) from e
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""MediaDurationBlock - Get the duration of a media file."""
|
|
||||||
|
|
||||||
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.video._utils import strip_chapters_inplace
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class MediaDurationBlock(Block):
|
|
||||||
"""Get the duration of a media file (video or audio)."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
media_in: MediaFileType = SchemaField(
|
|
||||||
description="Media input (URL, data URI, or local path)."
|
|
||||||
)
|
|
||||||
is_video: bool = SchemaField(
|
|
||||||
description="Whether the media is a video (True) or audio (False).",
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
duration: float = SchemaField(
|
|
||||||
description="Duration of the media file (in seconds)."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="d8b91fd4-da26-42d4-8ecb-8b196c6d84b6",
|
|
||||||
description="Block to get the duration of a media file.",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=MediaDurationBlock.Input,
|
|
||||||
output_schema=MediaDurationBlock.Output,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
# 1) Store the input media locally
|
|
||||||
local_media_path = await store_media_file(
|
|
||||||
file=input_data.media_in,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
media_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, local_media_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2) Strip chapters to avoid MoviePy crash, then load the clip
|
|
||||||
strip_chapters_inplace(media_abspath)
|
|
||||||
clip = None
|
|
||||||
try:
|
|
||||||
if input_data.is_video:
|
|
||||||
clip = VideoFileClip(media_abspath)
|
|
||||||
else:
|
|
||||||
clip = AudioFileClip(media_abspath)
|
|
||||||
|
|
||||||
duration = clip.duration
|
|
||||||
finally:
|
|
||||||
if clip:
|
|
||||||
clip.close()
|
|
||||||
|
|
||||||
yield "duration", duration
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""LoopVideoBlock - Loop a video to a given duration or number of repeats."""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from moviepy.video.fx.Loop import Loop
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.video._utils import extract_source_name, strip_chapters_inplace
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class LoopVideoBlock(Block):
|
|
||||||
"""Loop (repeat) a video clip until a given duration or number of loops."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
video_in: MediaFileType = SchemaField(
|
|
||||||
description="The input video (can be a URL, data URI, or local path)."
|
|
||||||
)
|
|
||||||
duration: Optional[float] = SchemaField(
|
|
||||||
description="Target duration (in seconds) to loop the video to. Either duration or n_loops must be provided.",
|
|
||||||
default=None,
|
|
||||||
ge=0.0,
|
|
||||||
le=3600.0, # Max 1 hour to prevent disk exhaustion
|
|
||||||
)
|
|
||||||
n_loops: Optional[int] = SchemaField(
|
|
||||||
description="Number of times to repeat the video. Either n_loops or duration must be provided.",
|
|
||||||
default=None,
|
|
||||||
ge=1,
|
|
||||||
le=10, # Max 10 loops to prevent disk exhaustion
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_out: MediaFileType = SchemaField(
|
|
||||||
description="Looped video returned either as a relative path or a data URI."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="8bf9eef6-5451-4213-b265-25306446e94b",
|
|
||||||
description="Block to loop a video to a given duration or number of repeats.",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=LoopVideoBlock.Input,
|
|
||||||
output_schema=LoopVideoBlock.Output,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
assert execution_context.node_exec_id is not None
|
|
||||||
graph_exec_id = execution_context.graph_exec_id
|
|
||||||
node_exec_id = execution_context.node_exec_id
|
|
||||||
|
|
||||||
# 1) Store the input video locally
|
|
||||||
local_video_path = await store_media_file(
|
|
||||||
file=input_data.video_in,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
input_abspath = get_exec_file_path(graph_exec_id, local_video_path)
|
|
||||||
|
|
||||||
# 2) Load the clip
|
|
||||||
strip_chapters_inplace(input_abspath)
|
|
||||||
clip = None
|
|
||||||
looped_clip = None
|
|
||||||
try:
|
|
||||||
clip = VideoFileClip(input_abspath)
|
|
||||||
|
|
||||||
# 3) Apply the loop effect
|
|
||||||
if input_data.duration:
|
|
||||||
# Loop until we reach the specified duration
|
|
||||||
looped_clip = clip.with_effects([Loop(duration=input_data.duration)])
|
|
||||||
elif input_data.n_loops:
|
|
||||||
looped_clip = clip.with_effects([Loop(n=input_data.n_loops)])
|
|
||||||
else:
|
|
||||||
raise ValueError("Either 'duration' or 'n_loops' must be provided.")
|
|
||||||
|
|
||||||
assert isinstance(looped_clip, VideoFileClip)
|
|
||||||
|
|
||||||
# 4) Save the looped output
|
|
||||||
source = extract_source_name(local_video_path)
|
|
||||||
output_filename = MediaFileType(f"{node_exec_id}_looped_{source}.mp4")
|
|
||||||
output_abspath = get_exec_file_path(graph_exec_id, output_filename)
|
|
||||||
|
|
||||||
looped_clip = looped_clip.with_audio(clip.audio)
|
|
||||||
looped_clip.write_videofile(
|
|
||||||
output_abspath, codec="libx264", audio_codec="aac"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if looped_clip:
|
|
||||||
looped_clip.close()
|
|
||||||
if clip:
|
|
||||||
clip.close()
|
|
||||||
|
|
||||||
# Return output - for_block_output returns workspace:// if available, else data URI
|
|
||||||
video_out = await store_media_file(
|
|
||||||
file=output_filename,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_out", video_out
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
"""VideoNarrationBlock - Generate AI voice narration and add to video."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from elevenlabs import ElevenLabs
|
|
||||||
from moviepy import CompositeAudioClip
|
|
||||||
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.elevenlabs._auth import (
|
|
||||||
TEST_CREDENTIALS,
|
|
||||||
TEST_CREDENTIALS_INPUT,
|
|
||||||
ElevenLabsCredentials,
|
|
||||||
ElevenLabsCredentialsInput,
|
|
||||||
)
|
|
||||||
from backend.blocks.video._utils import (
|
|
||||||
extract_source_name,
|
|
||||||
get_video_codecs,
|
|
||||||
strip_chapters_inplace,
|
|
||||||
)
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import CredentialsField, SchemaField
|
|
||||||
from backend.util.exceptions import BlockExecutionError
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class VideoNarrationBlock(Block):
|
|
||||||
"""Generate AI narration and add to video."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
credentials: ElevenLabsCredentialsInput = CredentialsField(
|
|
||||||
description="ElevenLabs API key for voice synthesis"
|
|
||||||
)
|
|
||||||
video_in: MediaFileType = SchemaField(
|
|
||||||
description="Input video (URL, data URI, or local path)"
|
|
||||||
)
|
|
||||||
script: str = SchemaField(description="Narration script text")
|
|
||||||
voice_id: str = SchemaField(
|
|
||||||
description="ElevenLabs voice ID", default="21m00Tcm4TlvDq8ikWAM" # Rachel
|
|
||||||
)
|
|
||||||
model_id: Literal[
|
|
||||||
"eleven_multilingual_v2",
|
|
||||||
"eleven_flash_v2_5",
|
|
||||||
"eleven_turbo_v2_5",
|
|
||||||
"eleven_turbo_v2",
|
|
||||||
] = SchemaField(
|
|
||||||
description="ElevenLabs TTS model",
|
|
||||||
default="eleven_multilingual_v2",
|
|
||||||
)
|
|
||||||
mix_mode: Literal["replace", "mix", "ducking"] = SchemaField(
|
|
||||||
description="How to combine with original audio. 'ducking' applies stronger attenuation than 'mix'.",
|
|
||||||
default="ducking",
|
|
||||||
)
|
|
||||||
narration_volume: float = SchemaField(
|
|
||||||
description="Narration volume (0.0 to 2.0)",
|
|
||||||
default=1.0,
|
|
||||||
ge=0.0,
|
|
||||||
le=2.0,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
original_volume: float = SchemaField(
|
|
||||||
description="Original audio volume when mixing (0.0 to 1.0)",
|
|
||||||
default=0.3,
|
|
||||||
ge=0.0,
|
|
||||||
le=1.0,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_out: MediaFileType = SchemaField(
|
|
||||||
description="Video with narration (path or data URI)"
|
|
||||||
)
|
|
||||||
audio_file: MediaFileType = SchemaField(
|
|
||||||
description="Generated audio file (path or data URI)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="3d036b53-859c-4b17-9826-ca340f736e0e",
|
|
||||||
description="Generate AI narration and add to video",
|
|
||||||
categories={BlockCategory.MULTIMEDIA, BlockCategory.AI},
|
|
||||||
input_schema=self.Input,
|
|
||||||
output_schema=self.Output,
|
|
||||||
test_input={
|
|
||||||
"video_in": "/tmp/test.mp4",
|
|
||||||
"script": "Hello world",
|
|
||||||
"credentials": TEST_CREDENTIALS_INPUT,
|
|
||||||
},
|
|
||||||
test_credentials=TEST_CREDENTIALS,
|
|
||||||
test_output=[("video_out", str), ("audio_file", str)],
|
|
||||||
test_mock={
|
|
||||||
"_generate_narration_audio": lambda *args: b"mock audio content",
|
|
||||||
"_add_narration_to_video": lambda *args: None,
|
|
||||||
"_store_input_video": lambda *args, **kwargs: "test.mp4",
|
|
||||||
"_store_output_video": lambda *args, **kwargs: "narrated_test.mp4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_input_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store input video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_output_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store output video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _generate_narration_audio(
|
|
||||||
self, api_key: str, script: str, voice_id: str, model_id: str
|
|
||||||
) -> bytes:
|
|
||||||
"""Generate narration audio via ElevenLabs API."""
|
|
||||||
client = ElevenLabs(api_key=api_key)
|
|
||||||
audio_generator = client.text_to_speech.convert(
|
|
||||||
voice_id=voice_id,
|
|
||||||
text=script,
|
|
||||||
model_id=model_id,
|
|
||||||
)
|
|
||||||
# The SDK returns a generator, collect all chunks
|
|
||||||
return b"".join(audio_generator)
|
|
||||||
|
|
||||||
def _add_narration_to_video(
|
|
||||||
self,
|
|
||||||
video_abspath: str,
|
|
||||||
audio_abspath: str,
|
|
||||||
output_abspath: str,
|
|
||||||
mix_mode: str,
|
|
||||||
narration_volume: float,
|
|
||||||
original_volume: float,
|
|
||||||
) -> None:
|
|
||||||
"""Add narration audio to video. Extracted for testability."""
|
|
||||||
video = None
|
|
||||||
final = None
|
|
||||||
narration_original = None
|
|
||||||
narration_scaled = None
|
|
||||||
original = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
strip_chapters_inplace(video_abspath)
|
|
||||||
video = VideoFileClip(video_abspath)
|
|
||||||
narration_original = AudioFileClip(audio_abspath)
|
|
||||||
narration_scaled = narration_original.with_volume_scaled(narration_volume)
|
|
||||||
narration = narration_scaled
|
|
||||||
|
|
||||||
if mix_mode == "replace":
|
|
||||||
final_audio = narration
|
|
||||||
elif mix_mode == "mix":
|
|
||||||
if video.audio:
|
|
||||||
original = video.audio.with_volume_scaled(original_volume)
|
|
||||||
final_audio = CompositeAudioClip([original, narration])
|
|
||||||
else:
|
|
||||||
final_audio = narration
|
|
||||||
else: # ducking - apply stronger attenuation
|
|
||||||
if video.audio:
|
|
||||||
# Ducking uses a much lower volume for original audio
|
|
||||||
ducking_volume = original_volume * 0.3
|
|
||||||
original = video.audio.with_volume_scaled(ducking_volume)
|
|
||||||
final_audio = CompositeAudioClip([original, narration])
|
|
||||||
else:
|
|
||||||
final_audio = narration
|
|
||||||
|
|
||||||
final = video.with_audio(final_audio)
|
|
||||||
video_codec, audio_codec = get_video_codecs(output_abspath)
|
|
||||||
final.write_videofile(
|
|
||||||
output_abspath, codec=video_codec, audio_codec=audio_codec
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if original:
|
|
||||||
original.close()
|
|
||||||
if narration_scaled:
|
|
||||||
narration_scaled.close()
|
|
||||||
if narration_original:
|
|
||||||
narration_original.close()
|
|
||||||
if final:
|
|
||||||
final.close()
|
|
||||||
if video:
|
|
||||||
video.close()
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
credentials: ElevenLabsCredentials,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
node_exec_id: str,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
try:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
|
|
||||||
# Store the input video locally
|
|
||||||
local_video_path = await self._store_input_video(
|
|
||||||
execution_context, input_data.video_in
|
|
||||||
)
|
|
||||||
video_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, local_video_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate narration audio via ElevenLabs
|
|
||||||
audio_content = self._generate_narration_audio(
|
|
||||||
credentials.api_key.get_secret_value(),
|
|
||||||
input_data.script,
|
|
||||||
input_data.voice_id,
|
|
||||||
input_data.model_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save audio to exec file path
|
|
||||||
audio_filename = MediaFileType(f"{node_exec_id}_narration.mp3")
|
|
||||||
audio_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, audio_filename
|
|
||||||
)
|
|
||||||
os.makedirs(os.path.dirname(audio_abspath), exist_ok=True)
|
|
||||||
with open(audio_abspath, "wb") as f:
|
|
||||||
f.write(audio_content)
|
|
||||||
|
|
||||||
# Add narration to video
|
|
||||||
source = extract_source_name(local_video_path)
|
|
||||||
output_filename = MediaFileType(f"{node_exec_id}_narrated_{source}.mp4")
|
|
||||||
output_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_narration_to_video(
|
|
||||||
video_abspath,
|
|
||||||
audio_abspath,
|
|
||||||
output_abspath,
|
|
||||||
input_data.mix_mode,
|
|
||||||
input_data.narration_volume,
|
|
||||||
input_data.original_volume,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return as workspace path or data URI based on context
|
|
||||||
video_out = await self._store_output_video(
|
|
||||||
execution_context, output_filename
|
|
||||||
)
|
|
||||||
audio_out = await self._store_output_video(
|
|
||||||
execution_context, audio_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_out", video_out
|
|
||||||
yield "audio_file", audio_out
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"Failed to add narration: {e}",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
) from e
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"""VideoTextOverlayBlock - Add text overlay to video."""
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from moviepy import CompositeVideoClip, TextClip
|
|
||||||
from moviepy.video.io.VideoFileClip import VideoFileClip
|
|
||||||
|
|
||||||
from backend.blocks.video._utils import (
|
|
||||||
extract_source_name,
|
|
||||||
get_video_codecs,
|
|
||||||
strip_chapters_inplace,
|
|
||||||
)
|
|
||||||
from backend.data.block import (
|
|
||||||
Block,
|
|
||||||
BlockCategory,
|
|
||||||
BlockOutput,
|
|
||||||
BlockSchemaInput,
|
|
||||||
BlockSchemaOutput,
|
|
||||||
)
|
|
||||||
from backend.data.execution import ExecutionContext
|
|
||||||
from backend.data.model import SchemaField
|
|
||||||
from backend.util.exceptions import BlockExecutionError
|
|
||||||
from backend.util.file import MediaFileType, get_exec_file_path, store_media_file
|
|
||||||
|
|
||||||
|
|
||||||
class VideoTextOverlayBlock(Block):
|
|
||||||
"""Add text overlay/caption to video."""
|
|
||||||
|
|
||||||
class Input(BlockSchemaInput):
|
|
||||||
video_in: MediaFileType = SchemaField(
|
|
||||||
description="Input video (URL, data URI, or local path)"
|
|
||||||
)
|
|
||||||
text: str = SchemaField(description="Text to overlay on video")
|
|
||||||
position: Literal[
|
|
||||||
"top",
|
|
||||||
"center",
|
|
||||||
"bottom",
|
|
||||||
"top-left",
|
|
||||||
"top-right",
|
|
||||||
"bottom-left",
|
|
||||||
"bottom-right",
|
|
||||||
] = SchemaField(description="Position of text on screen", default="bottom")
|
|
||||||
start_time: float | None = SchemaField(
|
|
||||||
description="When to show text (seconds). None = entire video",
|
|
||||||
default=None,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
end_time: float | None = SchemaField(
|
|
||||||
description="When to hide text (seconds). None = until end",
|
|
||||||
default=None,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
font_size: int = SchemaField(
|
|
||||||
description="Font size", default=48, ge=12, le=200, advanced=True
|
|
||||||
)
|
|
||||||
font_color: str = SchemaField(
|
|
||||||
description="Font color (hex or name)", default="white", advanced=True
|
|
||||||
)
|
|
||||||
bg_color: str | None = SchemaField(
|
|
||||||
description="Background color behind text (None for transparent)",
|
|
||||||
default=None,
|
|
||||||
advanced=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
|
||||||
video_out: MediaFileType = SchemaField(
|
|
||||||
description="Video with text overlay (path or data URI)"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(
|
|
||||||
id="8ef14de6-cc90-430a-8cfa-3a003be92454",
|
|
||||||
description="Add text overlay/caption to video",
|
|
||||||
categories={BlockCategory.MULTIMEDIA},
|
|
||||||
input_schema=self.Input,
|
|
||||||
output_schema=self.Output,
|
|
||||||
disabled=True, # Disable until we can lockdown imagemagick security policy
|
|
||||||
test_input={"video_in": "/tmp/test.mp4", "text": "Hello World"},
|
|
||||||
test_output=[("video_out", str)],
|
|
||||||
test_mock={
|
|
||||||
"_add_text_overlay": lambda *args: None,
|
|
||||||
"_store_input_video": lambda *args, **kwargs: "test.mp4",
|
|
||||||
"_store_output_video": lambda *args, **kwargs: "overlay_test.mp4",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_input_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store input video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_local_processing",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _store_output_video(
|
|
||||||
self, execution_context: ExecutionContext, file: MediaFileType
|
|
||||||
) -> MediaFileType:
|
|
||||||
"""Store output video. Extracted for testability."""
|
|
||||||
return await store_media_file(
|
|
||||||
file=file,
|
|
||||||
execution_context=execution_context,
|
|
||||||
return_format="for_block_output",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _add_text_overlay(
|
|
||||||
self,
|
|
||||||
video_abspath: str,
|
|
||||||
output_abspath: str,
|
|
||||||
text: str,
|
|
||||||
position: str,
|
|
||||||
start_time: float | None,
|
|
||||||
end_time: float | None,
|
|
||||||
font_size: int,
|
|
||||||
font_color: str,
|
|
||||||
bg_color: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Add text overlay to video. Extracted for testability."""
|
|
||||||
video = None
|
|
||||||
final = None
|
|
||||||
txt_clip = None
|
|
||||||
try:
|
|
||||||
strip_chapters_inplace(video_abspath)
|
|
||||||
video = VideoFileClip(video_abspath)
|
|
||||||
|
|
||||||
txt_clip = TextClip(
|
|
||||||
text=text,
|
|
||||||
font_size=font_size,
|
|
||||||
color=font_color,
|
|
||||||
bg_color=bg_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Position mapping
|
|
||||||
pos_map = {
|
|
||||||
"top": ("center", "top"),
|
|
||||||
"center": ("center", "center"),
|
|
||||||
"bottom": ("center", "bottom"),
|
|
||||||
"top-left": ("left", "top"),
|
|
||||||
"top-right": ("right", "top"),
|
|
||||||
"bottom-left": ("left", "bottom"),
|
|
||||||
"bottom-right": ("right", "bottom"),
|
|
||||||
}
|
|
||||||
|
|
||||||
txt_clip = txt_clip.with_position(pos_map[position])
|
|
||||||
|
|
||||||
# Set timing
|
|
||||||
start = start_time or 0
|
|
||||||
end = end_time or video.duration
|
|
||||||
duration = max(0, end - start)
|
|
||||||
txt_clip = txt_clip.with_start(start).with_end(end).with_duration(duration)
|
|
||||||
|
|
||||||
final = CompositeVideoClip([video, txt_clip])
|
|
||||||
video_codec, audio_codec = get_video_codecs(output_abspath)
|
|
||||||
final.write_videofile(
|
|
||||||
output_abspath, codec=video_codec, audio_codec=audio_codec
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if txt_clip:
|
|
||||||
txt_clip.close()
|
|
||||||
if final:
|
|
||||||
final.close()
|
|
||||||
if video:
|
|
||||||
video.close()
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
input_data: Input,
|
|
||||||
*,
|
|
||||||
execution_context: ExecutionContext,
|
|
||||||
node_exec_id: str,
|
|
||||||
**kwargs,
|
|
||||||
) -> BlockOutput:
|
|
||||||
# Validate time range if both are provided
|
|
||||||
if (
|
|
||||||
input_data.start_time is not None
|
|
||||||
and input_data.end_time is not None
|
|
||||||
and input_data.end_time <= input_data.start_time
|
|
||||||
):
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"end_time ({input_data.end_time}) must be greater than start_time ({input_data.start_time})",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
assert execution_context.graph_exec_id is not None
|
|
||||||
|
|
||||||
# Store the input video locally
|
|
||||||
local_video_path = await self._store_input_video(
|
|
||||||
execution_context, input_data.video_in
|
|
||||||
)
|
|
||||||
video_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, local_video_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build output path
|
|
||||||
source = extract_source_name(local_video_path)
|
|
||||||
output_filename = MediaFileType(f"{node_exec_id}_overlay_{source}.mp4")
|
|
||||||
output_abspath = get_exec_file_path(
|
|
||||||
execution_context.graph_exec_id, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_text_overlay(
|
|
||||||
video_abspath,
|
|
||||||
output_abspath,
|
|
||||||
input_data.text,
|
|
||||||
input_data.position,
|
|
||||||
input_data.start_time,
|
|
||||||
input_data.end_time,
|
|
||||||
input_data.font_size,
|
|
||||||
input_data.font_color,
|
|
||||||
input_data.bg_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return as workspace path or data URI based on context
|
|
||||||
video_out = await self._store_output_video(
|
|
||||||
execution_context, output_filename
|
|
||||||
)
|
|
||||||
|
|
||||||
yield "video_out", video_out
|
|
||||||
|
|
||||||
except BlockExecutionError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BlockExecutionError(
|
|
||||||
message=f"Failed to add text overlay: {e}",
|
|
||||||
block_name=self.name,
|
|
||||||
block_id=str(self.id),
|
|
||||||
) from e
|
|
||||||
@@ -165,13 +165,10 @@ class TranscribeYoutubeVideoBlock(Block):
|
|||||||
credentials: WebshareProxyCredentials,
|
credentials: WebshareProxyCredentials,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
try:
|
video_id = self.extract_video_id(input_data.youtube_url)
|
||||||
video_id = self.extract_video_id(input_data.youtube_url)
|
yield "video_id", video_id
|
||||||
transcript = self.get_transcript(video_id, credentials)
|
|
||||||
transcript_text = self.format_transcript(transcript=transcript)
|
|
||||||
|
|
||||||
# Only yield after all operations succeed
|
transcript = self.get_transcript(video_id, credentials)
|
||||||
yield "video_id", video_id
|
transcript_text = self.format_transcript(transcript=transcript)
|
||||||
yield "transcript", transcript_text
|
|
||||||
except Exception as e:
|
yield "transcript", transcript_text
|
||||||
yield "error", str(e)
|
|
||||||
|
|||||||
@@ -36,14 +36,12 @@ from backend.blocks.replicate.replicate_block import ReplicateModelBlock
|
|||||||
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
|
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
|
||||||
from backend.blocks.talking_head import CreateTalkingAvatarVideoBlock
|
from backend.blocks.talking_head import CreateTalkingAvatarVideoBlock
|
||||||
from backend.blocks.text_to_speech_block import UnrealTextToSpeechBlock
|
from backend.blocks.text_to_speech_block import UnrealTextToSpeechBlock
|
||||||
from backend.blocks.video.narration import VideoNarrationBlock
|
|
||||||
from backend.data.block import Block, BlockCost, BlockCostType
|
from backend.data.block import Block, BlockCost, BlockCostType
|
||||||
from backend.integrations.credentials_store import (
|
from backend.integrations.credentials_store import (
|
||||||
aiml_api_credentials,
|
aiml_api_credentials,
|
||||||
anthropic_credentials,
|
anthropic_credentials,
|
||||||
apollo_credentials,
|
apollo_credentials,
|
||||||
did_credentials,
|
did_credentials,
|
||||||
elevenlabs_credentials,
|
|
||||||
enrichlayer_credentials,
|
enrichlayer_credentials,
|
||||||
groq_credentials,
|
groq_credentials,
|
||||||
ideogram_credentials,
|
ideogram_credentials,
|
||||||
@@ -80,7 +78,6 @@ MODEL_COST: dict[LlmModel, int] = {
|
|||||||
LlmModel.CLAUDE_4_1_OPUS: 21,
|
LlmModel.CLAUDE_4_1_OPUS: 21,
|
||||||
LlmModel.CLAUDE_4_OPUS: 21,
|
LlmModel.CLAUDE_4_OPUS: 21,
|
||||||
LlmModel.CLAUDE_4_SONNET: 5,
|
LlmModel.CLAUDE_4_SONNET: 5,
|
||||||
LlmModel.CLAUDE_4_6_OPUS: 14,
|
|
||||||
LlmModel.CLAUDE_4_5_HAIKU: 4,
|
LlmModel.CLAUDE_4_5_HAIKU: 4,
|
||||||
LlmModel.CLAUDE_4_5_OPUS: 14,
|
LlmModel.CLAUDE_4_5_OPUS: 14,
|
||||||
LlmModel.CLAUDE_4_5_SONNET: 9,
|
LlmModel.CLAUDE_4_5_SONNET: 9,
|
||||||
@@ -642,16 +639,4 @@ BLOCK_COSTS: dict[Type[Block], list[BlockCost]] = {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
VideoNarrationBlock: [
|
|
||||||
BlockCost(
|
|
||||||
cost_amount=5, # ElevenLabs TTS cost
|
|
||||||
cost_filter={
|
|
||||||
"credentials": {
|
|
||||||
"id": elevenlabs_credentials.id,
|
|
||||||
"provider": elevenlabs_credentials.provider,
|
|
||||||
"type": elevenlabs_credentials.type,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,16 +134,6 @@ async def test_block_credit_reset(server: SpinTestServer):
|
|||||||
month1 = datetime.now(timezone.utc).replace(month=1, day=1)
|
month1 = datetime.now(timezone.utc).replace(month=1, day=1)
|
||||||
user_credit.time_now = lambda: month1
|
user_credit.time_now = lambda: month1
|
||||||
|
|
||||||
# IMPORTANT: Set updatedAt to December of previous year to ensure it's
|
|
||||||
# in a different month than month1 (January). This fixes a timing bug
|
|
||||||
# where if the test runs in early February, 35 days ago would be January,
|
|
||||||
# matching the mocked month1 and preventing the refill from triggering.
|
|
||||||
dec_previous_year = month1.replace(year=month1.year - 1, month=12, day=15)
|
|
||||||
await UserBalance.prisma().update(
|
|
||||||
where={"userId": DEFAULT_USER_ID},
|
|
||||||
data={"updatedAt": dec_previous_year},
|
|
||||||
)
|
|
||||||
|
|
||||||
# First call in month 1 should trigger refill
|
# First call in month 1 should trigger refill
|
||||||
balance = await user_credit.get_credits(DEFAULT_USER_ID)
|
balance = await user_credit.get_credits(DEFAULT_USER_ID)
|
||||||
assert balance == REFILL_VALUE # Should get 1000 credits
|
assert balance == REFILL_VALUE # Should get 1000 credits
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import (
|
|||||||
cast,
|
cast,
|
||||||
get_args,
|
get_args,
|
||||||
)
|
)
|
||||||
|
from urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from prisma.enums import CreditTransactionType, OnboardingStep
|
from prisma.enums import CreditTransactionType, OnboardingStep
|
||||||
@@ -41,7 +42,6 @@ from typing_extensions import TypedDict
|
|||||||
|
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
from backend.util.json import loads as json_loads
|
from backend.util.json import loads as json_loads
|
||||||
from backend.util.request import parse_url
|
|
||||||
from backend.util.settings import Secrets
|
from backend.util.settings import Secrets
|
||||||
|
|
||||||
# Type alias for any provider name (including custom ones)
|
# Type alias for any provider name (including custom ones)
|
||||||
@@ -397,25 +397,19 @@ class HostScopedCredentials(_BaseCredentials):
|
|||||||
def matches_url(self, url: str) -> bool:
|
def matches_url(self, url: str) -> bool:
|
||||||
"""Check if this credential should be applied to the given URL."""
|
"""Check if this credential should be applied to the given URL."""
|
||||||
|
|
||||||
request_host, request_port = _extract_host_from_url(url)
|
parsed_url = urlparse(url)
|
||||||
cred_scope_host, cred_scope_port = _extract_host_from_url(self.host)
|
# Extract hostname without port
|
||||||
|
request_host = parsed_url.hostname
|
||||||
if not request_host:
|
if not request_host:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If a port is specified in credential host, the request host port must match
|
# Simple host matching - exact match or wildcard subdomain match
|
||||||
if cred_scope_port is not None and request_port != cred_scope_port:
|
if self.host == request_host:
|
||||||
return False
|
|
||||||
# Non-standard ports are only allowed if explicitly specified in credential host
|
|
||||||
elif cred_scope_port is None and request_port not in (80, 443, None):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Simple host matching
|
|
||||||
if cred_scope_host == request_host:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Support wildcard matching (e.g., "*.example.com" matches "api.example.com")
|
# Support wildcard matching (e.g., "*.example.com" matches "api.example.com")
|
||||||
if cred_scope_host.startswith("*."):
|
if self.host.startswith("*."):
|
||||||
domain = cred_scope_host[2:] # Remove "*."
|
domain = self.host[2:] # Remove "*."
|
||||||
return request_host.endswith(f".{domain}") or request_host == domain
|
return request_host.endswith(f".{domain}") or request_host == domain
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -557,13 +551,13 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_host_from_url(url: str) -> tuple[str, int | None]:
|
def _extract_host_from_url(url: str) -> str:
|
||||||
"""Extract host and port from URL for grouping host-scoped credentials."""
|
"""Extract host from URL for grouping host-scoped credentials."""
|
||||||
try:
|
try:
|
||||||
parsed = parse_url(url)
|
parsed = urlparse(url)
|
||||||
return parsed.hostname or url, parsed.port
|
return parsed.hostname or url
|
||||||
except Exception:
|
except Exception:
|
||||||
return "", None
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
||||||
@@ -612,7 +606,7 @@ class CredentialsFieldInfo(BaseModel, Generic[CP, CT]):
|
|||||||
providers = frozenset(
|
providers = frozenset(
|
||||||
[cast(CP, "http")]
|
[cast(CP, "http")]
|
||||||
+ [
|
+ [
|
||||||
cast(CP, parse_url(str(value)).netloc)
|
cast(CP, _extract_host_from_url(str(value)))
|
||||||
for value in field.discriminator_values
|
for value in field.discriminator_values
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,23 +79,10 @@ class TestHostScopedCredentials:
|
|||||||
headers={"Authorization": SecretStr("Bearer token")},
|
headers={"Authorization": SecretStr("Bearer token")},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Non-standard ports require explicit port in credential host
|
assert creds.matches_url("http://localhost:8080/api/v1")
|
||||||
assert not creds.matches_url("http://localhost:8080/api/v1")
|
|
||||||
assert creds.matches_url("https://localhost:443/secure/endpoint")
|
assert creds.matches_url("https://localhost:443/secure/endpoint")
|
||||||
assert creds.matches_url("http://localhost/simple")
|
assert creds.matches_url("http://localhost/simple")
|
||||||
|
|
||||||
def test_matches_url_with_explicit_port(self):
|
|
||||||
"""Test URL matching with explicit port in credential host."""
|
|
||||||
creds = HostScopedCredentials(
|
|
||||||
provider="custom",
|
|
||||||
host="localhost:8080",
|
|
||||||
headers={"Authorization": SecretStr("Bearer token")},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert creds.matches_url("http://localhost:8080/api/v1")
|
|
||||||
assert not creds.matches_url("http://localhost:3000/api/v1")
|
|
||||||
assert not creds.matches_url("http://localhost/simple")
|
|
||||||
|
|
||||||
def test_empty_headers_dict(self):
|
def test_empty_headers_dict(self):
|
||||||
"""Test HostScopedCredentials with empty headers."""
|
"""Test HostScopedCredentials with empty headers."""
|
||||||
creds = HostScopedCredentials(
|
creds = HostScopedCredentials(
|
||||||
@@ -141,20 +128,8 @@ class TestHostScopedCredentials:
|
|||||||
("*.example.com", "https://sub.api.example.com/test", True),
|
("*.example.com", "https://sub.api.example.com/test", True),
|
||||||
("*.example.com", "https://example.com/test", True),
|
("*.example.com", "https://example.com/test", True),
|
||||||
("*.example.com", "https://example.org/test", False),
|
("*.example.com", "https://example.org/test", False),
|
||||||
# Non-standard ports require explicit port in credential host
|
("localhost", "http://localhost:3000/test", True),
|
||||||
("localhost", "http://localhost:3000/test", False),
|
|
||||||
("localhost:3000", "http://localhost:3000/test", True),
|
|
||||||
("localhost", "http://127.0.0.1:3000/test", False),
|
("localhost", "http://127.0.0.1:3000/test", False),
|
||||||
# IPv6 addresses (frontend stores with brackets via URL.hostname)
|
|
||||||
("[::1]", "http://[::1]/test", True),
|
|
||||||
("[::1]", "http://[::1]:80/test", True),
|
|
||||||
("[::1]", "https://[::1]:443/test", True),
|
|
||||||
("[::1]", "http://[::1]:8080/test", False), # Non-standard port
|
|
||||||
("[::1]:8080", "http://[::1]:8080/test", True),
|
|
||||||
("[::1]:8080", "http://[::1]:9090/test", False),
|
|
||||||
("[2001:db8::1]", "http://[2001:db8::1]/path", True),
|
|
||||||
("[2001:db8::1]", "https://[2001:db8::1]:443/path", True),
|
|
||||||
("[2001:db8::1]", "http://[2001:db8::ff]/path", False),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool):
|
def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool):
|
||||||
|
|||||||
@@ -224,14 +224,6 @@ openweathermap_credentials = APIKeyCredentials(
|
|||||||
expires_at=None,
|
expires_at=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
elevenlabs_credentials = APIKeyCredentials(
|
|
||||||
id="f4a8b6c2-3d1e-4f5a-9b8c-7d6e5f4a3b2c",
|
|
||||||
provider="elevenlabs",
|
|
||||||
api_key=SecretStr(settings.secrets.elevenlabs_api_key),
|
|
||||||
title="Use Credits for ElevenLabs",
|
|
||||||
expires_at=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_CREDENTIALS = [
|
DEFAULT_CREDENTIALS = [
|
||||||
ollama_credentials,
|
ollama_credentials,
|
||||||
revid_credentials,
|
revid_credentials,
|
||||||
@@ -260,7 +252,6 @@ DEFAULT_CREDENTIALS = [
|
|||||||
v0_credentials,
|
v0_credentials,
|
||||||
webshare_proxy_credentials,
|
webshare_proxy_credentials,
|
||||||
openweathermap_credentials,
|
openweathermap_credentials,
|
||||||
elevenlabs_credentials,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
|
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
|
||||||
@@ -375,8 +366,6 @@ class IntegrationCredentialsStore:
|
|||||||
all_credentials.append(webshare_proxy_credentials)
|
all_credentials.append(webshare_proxy_credentials)
|
||||||
if settings.secrets.openweathermap_api_key:
|
if settings.secrets.openweathermap_api_key:
|
||||||
all_credentials.append(openweathermap_credentials)
|
all_credentials.append(openweathermap_credentials)
|
||||||
if settings.secrets.elevenlabs_api_key:
|
|
||||||
all_credentials.append(elevenlabs_credentials)
|
|
||||||
return all_credentials
|
return all_credentials
|
||||||
|
|
||||||
async def get_creds_by_id(
|
async def get_creds_by_id(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class ProviderName(str, Enum):
|
|||||||
DISCORD = "discord"
|
DISCORD = "discord"
|
||||||
D_ID = "d_id"
|
D_ID = "d_id"
|
||||||
E2B = "e2b"
|
E2B = "e2b"
|
||||||
ELEVENLABS = "elevenlabs"
|
|
||||||
FAL = "fal"
|
FAL = "fal"
|
||||||
GITHUB = "github"
|
GITHUB = "github"
|
||||||
GOOGLE = "google"
|
GOOGLE = "google"
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from backend.util.cloud_storage import get_cloud_storage_handler
|
from backend.util.cloud_storage import get_cloud_storage_handler
|
||||||
from backend.util.request import Requests
|
from backend.util.request import Requests
|
||||||
from backend.util.settings import Config
|
from backend.util.settings import Config
|
||||||
@@ -19,35 +17,6 @@ from backend.util.virus_scanner import scan_content_safe
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from backend.data.execution import ExecutionContext
|
from backend.data.execution import ExecutionContext
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUri(BaseModel):
|
|
||||||
"""Parsed workspace:// URI."""
|
|
||||||
|
|
||||||
file_ref: str # File ID or path (e.g. "abc123" or "/path/to/file.txt")
|
|
||||||
mime_type: str | None = None # MIME type from fragment (e.g. "video/mp4")
|
|
||||||
is_path: bool = False # True if file_ref is a path (starts with "/")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_workspace_uri(uri: str) -> WorkspaceUri:
|
|
||||||
"""Parse a workspace:// URI into its components.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
"workspace://abc123" → WorkspaceUri(file_ref="abc123", mime_type=None, is_path=False)
|
|
||||||
"workspace://abc123#video/mp4" → WorkspaceUri(file_ref="abc123", mime_type="video/mp4", is_path=False)
|
|
||||||
"workspace:///path/to/file.txt" → WorkspaceUri(file_ref="/path/to/file.txt", mime_type=None, is_path=True)
|
|
||||||
"""
|
|
||||||
raw = uri.removeprefix("workspace://")
|
|
||||||
mime_type: str | None = None
|
|
||||||
if "#" in raw:
|
|
||||||
raw, fragment = raw.split("#", 1)
|
|
||||||
mime_type = fragment or None
|
|
||||||
return WorkspaceUri(
|
|
||||||
file_ref=raw,
|
|
||||||
mime_type=mime_type,
|
|
||||||
is_path=raw.startswith("/"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Return format options for store_media_file
|
# Return format options for store_media_file
|
||||||
# - "for_local_processing": Returns local file path - use with ffmpeg, MoviePy, PIL, etc.
|
# - "for_local_processing": Returns local file path - use with ffmpeg, MoviePy, PIL, etc.
|
||||||
# - "for_external_api": Returns data URI (base64) - use when sending content to external APIs
|
# - "for_external_api": Returns data URI (base64) - use when sending content to external APIs
|
||||||
@@ -214,20 +183,22 @@ async def store_media_file(
|
|||||||
"This file type is only available in CoPilot sessions."
|
"This file type is only available in CoPilot sessions."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse workspace reference (strips #mimeType fragment from file ID)
|
# Parse workspace reference
|
||||||
ws = parse_workspace_uri(file)
|
# workspace://abc123 - by file ID
|
||||||
|
# workspace:///path/to/file.txt - by virtual path
|
||||||
|
file_ref = file[12:] # Remove "workspace://"
|
||||||
|
|
||||||
if ws.is_path:
|
if file_ref.startswith("/"):
|
||||||
# Path reference: workspace:///path/to/file.txt
|
# Path reference
|
||||||
workspace_content = await workspace_manager.read_file(ws.file_ref)
|
workspace_content = await workspace_manager.read_file(file_ref)
|
||||||
file_info = await workspace_manager.get_file_info_by_path(ws.file_ref)
|
file_info = await workspace_manager.get_file_info_by_path(file_ref)
|
||||||
filename = sanitize_filename(
|
filename = sanitize_filename(
|
||||||
file_info.name if file_info else f"{uuid.uuid4()}.bin"
|
file_info.name if file_info else f"{uuid.uuid4()}.bin"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# ID reference: workspace://abc123 or workspace://abc123#video/mp4
|
# ID reference
|
||||||
workspace_content = await workspace_manager.read_file_by_id(ws.file_ref)
|
workspace_content = await workspace_manager.read_file_by_id(file_ref)
|
||||||
file_info = await workspace_manager.get_file_info(ws.file_ref)
|
file_info = await workspace_manager.get_file_info(file_ref)
|
||||||
filename = sanitize_filename(
|
filename = sanitize_filename(
|
||||||
file_info.name if file_info else f"{uuid.uuid4()}.bin"
|
file_info.name if file_info else f"{uuid.uuid4()}.bin"
|
||||||
)
|
)
|
||||||
@@ -363,21 +334,7 @@ async def store_media_file(
|
|||||||
|
|
||||||
# Don't re-save if input was already from workspace
|
# Don't re-save if input was already from workspace
|
||||||
if is_from_workspace:
|
if is_from_workspace:
|
||||||
# Return original workspace reference, ensuring MIME type fragment
|
# Return original workspace reference
|
||||||
ws = parse_workspace_uri(file)
|
|
||||||
if not ws.mime_type:
|
|
||||||
# Add MIME type fragment if missing (older refs without it)
|
|
||||||
try:
|
|
||||||
if ws.is_path:
|
|
||||||
info = await workspace_manager.get_file_info_by_path(
|
|
||||||
ws.file_ref
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
info = await workspace_manager.get_file_info(ws.file_ref)
|
|
||||||
if info:
|
|
||||||
return MediaFileType(f"{file}#{info.mimeType}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return MediaFileType(file)
|
return MediaFileType(file)
|
||||||
|
|
||||||
# Save new content to workspace
|
# Save new content to workspace
|
||||||
@@ -389,7 +346,7 @@ async def store_media_file(
|
|||||||
filename=filename,
|
filename=filename,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
)
|
)
|
||||||
return MediaFileType(f"workspace://{file_record.id}#{file_record.mimeType}")
|
return MediaFileType(f"workspace://{file_record.id}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid return_format: {return_format}")
|
raise ValueError(f"Invalid return_format: {return_format}")
|
||||||
|
|||||||
@@ -157,7 +157,12 @@ async def validate_url(
|
|||||||
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
is_trusted: Boolean indicating if the hostname is in trusted_origins
|
||||||
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
ip_addresses: List of IP addresses for the host; empty if the host is trusted
|
||||||
"""
|
"""
|
||||||
parsed = parse_url(url)
|
# Canonicalize URL
|
||||||
|
url = url.strip("/ ").replace("\\", "/")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.scheme:
|
||||||
|
url = f"http://{url}"
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
# Check scheme
|
# Check scheme
|
||||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||||
@@ -215,17 +220,6 @@ async def validate_url(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_url(url: str) -> URL:
|
|
||||||
"""Canonicalizes and parses a URL string."""
|
|
||||||
url = url.strip("/ ").replace("\\", "/")
|
|
||||||
|
|
||||||
# Ensure scheme is present for proper parsing
|
|
||||||
if not re.match(r"[a-z0-9+.\-]+://", url):
|
|
||||||
url = f"http://{url}"
|
|
||||||
|
|
||||||
return urlparse(url)
|
|
||||||
|
|
||||||
|
|
||||||
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL:
|
||||||
"""
|
"""
|
||||||
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
Pins a URL to a specific IP address to prevent DNS rebinding attacks.
|
||||||
|
|||||||
@@ -656,7 +656,6 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
|
|||||||
e2b_api_key: str = Field(default="", description="E2B API key")
|
e2b_api_key: str = Field(default="", description="E2B API key")
|
||||||
nvidia_api_key: str = Field(default="", description="Nvidia API key")
|
nvidia_api_key: str = Field(default="", description="Nvidia API key")
|
||||||
mem0_api_key: str = Field(default="", description="Mem0 API key")
|
mem0_api_key: str = Field(default="", description="Mem0 API key")
|
||||||
elevenlabs_api_key: str = Field(default="", description="ElevenLabs API key")
|
|
||||||
|
|
||||||
linear_client_id: str = Field(default="", description="Linear client ID")
|
linear_client_id: str = Field(default="", description="Linear client ID")
|
||||||
linear_client_secret: str = Field(default="", description="Linear client secret")
|
linear_client_secret: str = Field(default="", description="Linear client secret")
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from backend.data.workspace import (
|
|||||||
soft_delete_workspace_file,
|
soft_delete_workspace_file,
|
||||||
)
|
)
|
||||||
from backend.util.settings import Config
|
from backend.util.settings import Config
|
||||||
from backend.util.virus_scanner import scan_content_safe
|
|
||||||
from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage
|
from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -188,9 +187,6 @@ class WorkspaceManager:
|
|||||||
f"{Config().max_file_size_mb}MB limit"
|
f"{Config().max_file_size_mb}MB limit"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Virus scan content before persisting (defense in depth)
|
|
||||||
await scan_content_safe(content, filename=filename)
|
|
||||||
|
|
||||||
# Determine path with session scoping
|
# Determine path with session scoping
|
||||||
if path is None:
|
if path is None:
|
||||||
path = f"/{filename}"
|
path = f"/{filename}"
|
||||||
|
|||||||
47
autogpt_platform/backend/poetry.lock
generated
47
autogpt_platform/backend/poetry.lock
generated
@@ -1169,29 +1169,6 @@ attrs = ">=21.3.0"
|
|||||||
e2b = ">=1.5.4,<2.0.0"
|
e2b = ">=1.5.4,<2.0.0"
|
||||||
httpx = ">=0.20.0,<1.0.0"
|
httpx = ">=0.20.0,<1.0.0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "elevenlabs"
|
|
||||||
version = "1.59.0"
|
|
||||||
description = ""
|
|
||||||
optional = false
|
|
||||||
python-versions = "<4.0,>=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "elevenlabs-1.59.0-py3-none-any.whl", hash = "sha256:468145db81a0bc867708b4a8619699f75583e9481b395ec1339d0b443da771ed"},
|
|
||||||
{file = "elevenlabs-1.59.0.tar.gz", hash = "sha256:16e735bd594e86d415dd445d249c8cc28b09996cfd627fbc10102c0a84698859"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
httpx = ">=0.21.2"
|
|
||||||
pydantic = ">=1.9.2"
|
|
||||||
pydantic-core = ">=2.18.2,<3.0.0"
|
|
||||||
requests = ">=2.20"
|
|
||||||
typing_extensions = ">=4.0.0"
|
|
||||||
websockets = ">=11.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
pyaudio = ["pyaudio (>=0.2.14)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email-validator"
|
name = "email-validator"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -7384,28 +7361,6 @@ files = [
|
|||||||
defusedxml = ">=0.7.1,<0.8.0"
|
defusedxml = ">=0.7.1,<0.8.0"
|
||||||
requests = "*"
|
requests = "*"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yt-dlp"
|
|
||||||
version = "2025.12.8"
|
|
||||||
description = "A feature-rich command-line audio/video downloader"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.10"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065"},
|
|
||||||
{file = "yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
build = ["build", "hatchling (>=1.27.0)", "pip", "setuptools (>=71.0.2)", "wheel"]
|
|
||||||
curl-cffi = ["curl-cffi (>=0.5.10,<0.6.dev0 || >=0.10.dev0,<0.14) ; implementation_name == \"cpython\""]
|
|
||||||
default = ["brotli ; implementation_name == \"cpython\"", "brotlicffi ; implementation_name != \"cpython\"", "certifi", "mutagen", "pycryptodomex", "requests (>=2.32.2,<3)", "urllib3 (>=2.0.2,<3)", "websockets (>=13.0)", "yt-dlp-ejs (==0.3.2)"]
|
|
||||||
dev = ["autopep8 (>=2.0,<3.0)", "pre-commit", "pytest (>=8.1,<9.0)", "pytest-rerunfailures (>=14.0,<15.0)", "ruff (>=0.14.0,<0.15.0)"]
|
|
||||||
pyinstaller = ["pyinstaller (>=6.17.0)"]
|
|
||||||
secretstorage = ["cffi", "secretstorage"]
|
|
||||||
static-analysis = ["autopep8 (>=2.0,<3.0)", "ruff (>=0.14.0,<0.15.0)"]
|
|
||||||
test = ["pytest (>=8.1,<9.0)", "pytest-rerunfailures (>=14.0,<15.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerobouncesdk"
|
name = "zerobouncesdk"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -7557,4 +7512,4 @@ cffi = ["cffi (>=1.11)"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.10,<3.14"
|
python-versions = ">=3.10,<3.14"
|
||||||
content-hash = "8239323f9ae6713224dffd1fe8ba8b449fe88b6c3c7a90940294a74f43a0387a"
|
content-hash = "ee5742dc1a9df50dfc06d4b26a1682cbb2b25cab6b79ce5625ec272f93e4f4bf"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ click = "^8.2.0"
|
|||||||
cryptography = "^45.0"
|
cryptography = "^45.0"
|
||||||
discord-py = "^2.5.2"
|
discord-py = "^2.5.2"
|
||||||
e2b-code-interpreter = "^1.5.2"
|
e2b-code-interpreter = "^1.5.2"
|
||||||
elevenlabs = "^1.50.0"
|
|
||||||
fastapi = "^0.116.1"
|
fastapi = "^0.116.1"
|
||||||
feedparser = "^6.0.11"
|
feedparser = "^6.0.11"
|
||||||
flake8 = "^7.3.0"
|
flake8 = "^7.3.0"
|
||||||
@@ -72,7 +71,6 @@ tweepy = "^4.16.0"
|
|||||||
uvicorn = { extras = ["standard"], version = "^0.35.0" }
|
uvicorn = { extras = ["standard"], version = "^0.35.0" }
|
||||||
websockets = "^15.0"
|
websockets = "^15.0"
|
||||||
youtube-transcript-api = "^1.2.1"
|
youtube-transcript-api = "^1.2.1"
|
||||||
yt-dlp = "2025.12.08"
|
|
||||||
zerobouncesdk = "^1.1.2"
|
zerobouncesdk = "^1.1.2"
|
||||||
# NOTE: please insert new dependencies in their alphabetical location
|
# NOTE: please insert new dependencies in their alphabetical location
|
||||||
pytest-snapshot = "^0.9.0"
|
pytest-snapshot = "^0.9.0"
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { OAuthPopupResultMessage } from "./types";
|
import { OAuthPopupResultMessage } from "./types";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely encode a value as JSON for embedding in a script tag.
|
|
||||||
* Escapes characters that could break out of the script context to prevent XSS.
|
|
||||||
*/
|
|
||||||
function safeJsonStringify(value: unknown): string {
|
|
||||||
return JSON.stringify(value)
|
|
||||||
.replace(/</g, "\\u003c")
|
|
||||||
.replace(/>/g, "\\u003e")
|
|
||||||
.replace(/&/g, "\\u0026");
|
|
||||||
}
|
|
||||||
|
|
||||||
// This route is intended to be used as the callback for integration OAuth flows,
|
// This route is intended to be used as the callback for integration OAuth flows,
|
||||||
// controlled by the CredentialsInput component. The CredentialsInput opens the login
|
// controlled by the CredentialsInput component. The CredentialsInput opens the login
|
||||||
// page in a pop-up window, which then redirects to this route to close the loop.
|
// page in a pop-up window, which then redirects to this route to close the loop.
|
||||||
@@ -34,13 +23,12 @@ export async function GET(request: Request) {
|
|||||||
console.debug("Sending message to opener:", message);
|
console.debug("Sending message to opener:", message);
|
||||||
|
|
||||||
// Return a response with the message as JSON and a script to close the window
|
// Return a response with the message as JSON and a script to close the window
|
||||||
// Use safeJsonStringify to prevent XSS by escaping <, >, and & characters
|
|
||||||
return new NextResponse(
|
return new NextResponse(
|
||||||
`
|
`
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
window.opener.postMessage(${safeJsonStringify(message)});
|
window.opener.postMessage(${JSON.stringify(message)});
|
||||||
window.close();
|
window.close();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beautifyString } from "@/lib/utils";
|
import { beautifyString } from "@/lib/utils";
|
||||||
import { Clipboard, Maximize2 } from "lucide-react";
|
import { Clipboard, Maximize2 } from "lucide-react";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||||
import {
|
import {
|
||||||
@@ -11,12 +11,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../../../components/__legacy__/ui/table";
|
} 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 { useToast } from "../../../../../components/molecules/Toast/use-toast";
|
||||||
import ExpandableOutputDialog from "./ExpandableOutputDialog";
|
import ExpandableOutputDialog from "./ExpandableOutputDialog";
|
||||||
|
|
||||||
@@ -32,9 +26,6 @@ export default function DataTable({
|
|||||||
data,
|
data,
|
||||||
}: DataTableProps) {
|
}: DataTableProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const enableEnhancedOutputHandling = useGetFlag(
|
|
||||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
|
||||||
);
|
|
||||||
const [expandedDialog, setExpandedDialog] = useState<{
|
const [expandedDialog, setExpandedDialog] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
execId: string;
|
execId: string;
|
||||||
@@ -42,15 +33,6 @@ export default function DataTable({
|
|||||||
data: any[];
|
data: any[];
|
||||||
} | null>(null);
|
} | 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) => {
|
const copyData = (pin: string, data: string) => {
|
||||||
navigator.clipboard.writeText(data).then(() => {
|
navigator.clipboard.writeText(data).then(() => {
|
||||||
toast({
|
toast({
|
||||||
@@ -120,31 +102,15 @@ export default function DataTable({
|
|||||||
<Clipboard size={18} />
|
<Clipboard size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{value.map((item, index) => {
|
{value.map((item, index) => (
|
||||||
const renderer = getItemRenderer?.(item);
|
<React.Fragment key={index}>
|
||||||
if (enableEnhancedOutputHandling && renderer) {
|
<ContentRenderer
|
||||||
const metadata: OutputMetadata = {};
|
value={item}
|
||||||
return (
|
truncateLongData={truncateLongData}
|
||||||
<React.Fragment key={index}>
|
/>
|
||||||
<OutputItem
|
{index < value.length - 1 && ", "}
|
||||||
value={item}
|
</React.Fragment>
|
||||||
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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import React, { useContext, useMemo, useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { Button } from "@/components/__legacy__/ui/button";
|
import { Button } from "@/components/__legacy__/ui/button";
|
||||||
import { Maximize2 } from "lucide-react";
|
import { Maximize2 } from "lucide-react";
|
||||||
import * as Separator from "@radix-ui/react-separator";
|
import * as Separator from "@radix-ui/react-separator";
|
||||||
import { ContentRenderer } from "@/components/__legacy__/ui/render";
|
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 { beautifyString } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -27,9 +21,6 @@ export default function NodeOutputs({
|
|||||||
data,
|
data,
|
||||||
}: NodeOutputsProps) {
|
}: NodeOutputsProps) {
|
||||||
const builderContext = useContext(BuilderContext);
|
const builderContext = useContext(BuilderContext);
|
||||||
const enableEnhancedOutputHandling = useGetFlag(
|
|
||||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [expandedDialog, setExpandedDialog] = useState<{
|
const [expandedDialog, setExpandedDialog] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -46,15 +37,6 @@ export default function NodeOutputs({
|
|||||||
|
|
||||||
const { getNodeTitle } = builderContext;
|
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) => {
|
const getBeautifiedPinName = (pin: string) => {
|
||||||
if (!pin.startsWith("tools_^_")) {
|
if (!pin.startsWith("tools_^_")) {
|
||||||
return beautifyString(pin);
|
return beautifyString(pin);
|
||||||
@@ -105,31 +87,15 @@ export default function NodeOutputs({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<strong className="mr-2">Data:</strong>
|
<strong className="mr-2">Data:</strong>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{dataArray.slice(0, 10).map((item, index) => {
|
{dataArray.slice(0, 10).map((item, index) => (
|
||||||
const renderer = getItemRenderer?.(item);
|
<React.Fragment key={index}>
|
||||||
if (enableEnhancedOutputHandling && renderer) {
|
<ContentRenderer
|
||||||
const metadata: OutputMetadata = {};
|
value={item}
|
||||||
return (
|
truncateLongData={truncateLongData}
|
||||||
<React.Fragment key={index}>
|
/>
|
||||||
<OutputItem
|
{index < Math.min(dataArray.length, 10) - 1 && ", "}
|
||||||
value={item}
|
</React.Fragment>
|
||||||
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 && (
|
{dataArray.length > 10 && (
|
||||||
<span style={{ color: "#888" }}>
|
<span style={{ color: "#888" }}>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -26,20 +26,8 @@ export function buildCopilotChatUrl(prompt: string): string {
|
|||||||
|
|
||||||
export function getQuickActions(): string[] {
|
export function getQuickActions(): string[] {
|
||||||
return [
|
return [
|
||||||
"I don't know where to start, just ask me stuff",
|
"Show me what I can automate",
|
||||||
"I do the same thing every week and it's killing me",
|
"Design a custom workflow",
|
||||||
"Help me find where I'm wasting my time",
|
"Help me with content creation",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInputPlaceholder(width?: number) {
|
|
||||||
if (!width) return "What's your role and what eats up most of your day?";
|
|
||||||
|
|
||||||
if (width < 500) {
|
|
||||||
return "I'm a chef and I hate...";
|
|
||||||
}
|
|
||||||
if (width <= 1080) {
|
|
||||||
return "What's your role and what eats up most of your day?";
|
|
||||||
}
|
|
||||||
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { Chat } from "@/components/contextual/Chat/Chat";
|
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useCopilotStore } from "./copilot-page-store";
|
import { useCopilotStore } from "./copilot-page-store";
|
||||||
import { getInputPlaceholder } from "./helpers";
|
|
||||||
import { useCopilotPage } from "./useCopilotPage";
|
import { useCopilotPage } from "./useCopilotPage";
|
||||||
|
|
||||||
export default function CopilotPage() {
|
export default function CopilotPage() {
|
||||||
@@ -16,25 +14,8 @@ export default function CopilotPage() {
|
|||||||
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
||||||
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
||||||
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
||||||
|
|
||||||
const [inputPlaceholder, setInputPlaceholder] = useState(
|
|
||||||
getInputPlaceholder(),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleResize();
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
|
const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
|
||||||
state;
|
state;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleQuickAction,
|
handleQuickAction,
|
||||||
startChatWithPrompt,
|
startChatWithPrompt,
|
||||||
@@ -92,7 +73,7 @@ export default function CopilotPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-3 py-5 md:px-6 md:py-10">
|
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-6 py-10">
|
||||||
<div className="w-full text-center">
|
<div className="w-full text-center">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
@@ -109,25 +90,25 @@ export default function CopilotPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-2xl">
|
||||||
<Text
|
<Text
|
||||||
variant="h3"
|
variant="h3"
|
||||||
className="mb-1 !text-[1.375rem] text-zinc-700"
|
className="mb-3 !text-[1.375rem] text-zinc-700"
|
||||||
>
|
>
|
||||||
Hey, <span className="text-violet-600">{greetingName}</span>
|
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h3" className="mb-8 !font-normal">
|
<Text variant="h3" className="mb-8 !font-normal">
|
||||||
Tell me about your work — I'll find what to automate.
|
What do you want to automate?
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={startChatWithPrompt}
|
onSend={startChatWithPrompt}
|
||||||
placeholder={inputPlaceholder}
|
placeholder='You can search or just ask - e.g. "create a blog post outline"'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex flex-nowrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{quickActions.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action}
|
key={action}
|
||||||
@@ -135,7 +116,7 @@ export default function CopilotPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handleQuickAction(action)}
|
onClick={() => handleQuickAction(action)}
|
||||||
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
|
className="h-auto shrink-0 border-zinc-600 !px-4 !py-2 text-[1rem] text-zinc-600"
|
||||||
>
|
>
|
||||||
{action}
|
{action}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const isValidVideoUrl = (url: string): boolean => {
|
|||||||
if (url.startsWith("data:video")) {
|
if (url.startsWith("data:video")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const videoExtensions = /\.(mp4|webm|ogg|mov|avi|mkv|m4v)$/i;
|
const videoExtensions = /\.(mp4|webm|ogg)$/i;
|
||||||
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
|
||||||
const cleanedUrl = url.split("?")[0];
|
const cleanedUrl = url.split("?")[0];
|
||||||
return (
|
return (
|
||||||
@@ -44,29 +44,11 @@ const isValidAudioUrl = (url: string): boolean => {
|
|||||||
if (url.startsWith("data:audio")) {
|
if (url.startsWith("data:audio")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const audioExtensions = /\.(mp3|wav|ogg|m4a|aac|flac)$/i;
|
const audioExtensions = /\.(mp3|wav)$/i;
|
||||||
const cleanedUrl = url.split("?")[0];
|
const cleanedUrl = url.split("?")[0];
|
||||||
return isValidMediaUri(url) && audioExtensions.test(cleanedUrl);
|
return isValidMediaUri(url) && audioExtensions.test(cleanedUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoMimeType = (url: string): string => {
|
|
||||||
if (url.startsWith("data:video/")) {
|
|
||||||
const match = url.match(/^data:(video\/[^;]+)/);
|
|
||||||
return match?.[1] || "video/mp4";
|
|
||||||
}
|
|
||||||
const extension = url.split("?")[0].split(".").pop()?.toLowerCase();
|
|
||||||
const mimeMap: Record<string, string> = {
|
|
||||||
mp4: "video/mp4",
|
|
||||||
webm: "video/webm",
|
|
||||||
ogg: "video/ogg",
|
|
||||||
mov: "video/quicktime",
|
|
||||||
avi: "video/x-msvideo",
|
|
||||||
mkv: "video/x-matroska",
|
|
||||||
m4v: "video/mp4",
|
|
||||||
};
|
|
||||||
return mimeMap[extension || ""] || "video/mp4";
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
|
const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
|
||||||
const videoId = getYouTubeVideoId(videoUrl);
|
const videoId = getYouTubeVideoId(videoUrl);
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +63,7 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
|
|||||||
></iframe>
|
></iframe>
|
||||||
) : (
|
) : (
|
||||||
<video controls width="100%" height="315">
|
<video controls width="100%" height="315">
|
||||||
<source src={videoUrl} type={getVideoMimeType(videoUrl)} />
|
<source src={videoUrl} type="video/mp4" />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessi
|
|||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
import { GlobeHemisphereEastIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -55,6 +56,10 @@ export function ChatContainer({
|
|||||||
onStreamingChange?.(isStreaming);
|
onStreamingChange?.(isStreaming);
|
||||||
}, [isStreaming, onStreamingChange]);
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
const isMobile =
|
||||||
|
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -122,7 +127,11 @@ export function ChatContainer({
|
|||||||
disabled={isStreaming || !sessionId}
|
disabled={isStreaming || !sessionId}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStop={stopStreaming}
|
onStop={stopStreaming}
|
||||||
placeholder="What else can I help with?"
|
placeholder={
|
||||||
|
isMobile
|
||||||
|
? "You can search or just ask"
|
||||||
|
: 'You can search or just ask — e.g. "create a blog post outline"'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,20 +74,19 @@ export function ChatInput({
|
|||||||
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
hasMultipleLines ? "rounded-xlarge" : "rounded-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!value && !isRecording && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 top-0.5 flex items-center justify-start pl-14 text-[1rem] text-zinc-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{isTranscribing ? "Transcribing..." : placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<textarea
|
<textarea
|
||||||
id={inputId}
|
id={inputId}
|
||||||
aria-label="Chat message input"
|
aria-label="Chat message input"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
isTranscribing
|
||||||
|
? "Transcribing..."
|
||||||
|
: isRecording
|
||||||
|
? ""
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
disabled={isInputDisabled}
|
disabled={isInputDisabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -123,14 +122,13 @@ export function ChatInput({
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
aria-label={isRecording ? "Stop recording" : "Start recording"}
|
||||||
onClick={toggleRecording}
|
onClick={toggleRecording}
|
||||||
disabled={disabled || isTranscribing || isStreaming}
|
disabled={disabled || isTranscribing}
|
||||||
className={cn(
|
className={cn(
|
||||||
isRecording
|
isRecording
|
||||||
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
? "animate-pulse border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600"
|
||||||
: isTranscribing
|
: isTranscribing
|
||||||
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
? "border-zinc-300 bg-zinc-100 text-zinc-400"
|
||||||
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
: "border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
|
||||||
isStreaming && "opacity-40",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isTranscribing ? (
|
{isTranscribing ? (
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function AudioWaveform({
|
|||||||
// Create audio context and analyser
|
// Create audio context and analyser
|
||||||
const audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 512;
|
||||||
analyser.smoothingTimeConstant = 0.3;
|
analyser.smoothingTimeConstant = 0.8;
|
||||||
|
|
||||||
// Connect the stream to the analyser
|
// Connect the stream to the analyser
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
@@ -73,11 +73,10 @@ export function AudioWaveform({
|
|||||||
maxAmplitude = Math.max(maxAmplitude, amplitude);
|
maxAmplitude = Math.max(maxAmplitude, amplitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize amplitude (0-128 range) to 0-1
|
// Map amplitude (0-128) to bar height
|
||||||
const normalized = maxAmplitude / 128;
|
const normalized = (maxAmplitude / 128) * 255;
|
||||||
// Apply sensitivity boost (multiply by 4) and use sqrt curve to amplify quiet sounds
|
const height =
|
||||||
const boosted = Math.min(1, Math.sqrt(normalized) * 4);
|
minBarHeight + (normalized / 255) * (maxBarHeight - minBarHeight);
|
||||||
const height = minBarHeight + boosted * (maxBarHeight - minBarHeight);
|
|
||||||
newBars.push(height);
|
newBars.push(height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function useVoiceRecording({
|
|||||||
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showMicButton = isSupported;
|
const showMicButton = isSupported && !isStreaming;
|
||||||
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ export function ChatMessage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClarificationAnswers(answers: Record<string, string>) {
|
||||||
|
if (onSendMessage) {
|
||||||
|
const contextMessage = Object.entries(answers)
|
||||||
|
.map(([keyword, answer]) => `${keyword}: ${answer}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
onSendMessage(
|
||||||
|
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCopy = useCallback(
|
const handleCopy = useCallback(
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
if (message.type !== "message") return;
|
if (message.type !== "message") return;
|
||||||
@@ -150,22 +162,6 @@ export function ChatMessage({
|
|||||||
.slice(index + 1)
|
.slice(index + 1)
|
||||||
.some((m) => m.type === "message" && m.role === "user");
|
.some((m) => m.type === "message" && m.role === "user");
|
||||||
|
|
||||||
const handleClarificationAnswers = (answers: Record<string, string>) => {
|
|
||||||
if (onSendMessage) {
|
|
||||||
// Iterate over questions (preserves original order) instead of answers
|
|
||||||
const contextMessage = message.questions
|
|
||||||
.map((q) => {
|
|
||||||
const answer = answers[q.keyword] || "";
|
|
||||||
return `> ${q.question}\n\n${answer}`;
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
onSendMessage(
|
|
||||||
`**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClarificationQuestionsWidget
|
<ClarificationQuestionsWidget
|
||||||
questions={message.questions}
|
questions={message.questions}
|
||||||
@@ -350,7 +346,6 @@ export function ChatMessage({
|
|||||||
toolId={message.toolId}
|
toolId={message.toolId}
|
||||||
toolName={message.toolName}
|
toolName={message.toolName}
|
||||||
result={message.result}
|
result={message.result}
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
|
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EyeSlash } from "@phosphor-icons/react";
|
import { EyeSlash } from "@phosphor-icons/react";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
@@ -48,9 +48,7 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||||||
*/
|
*/
|
||||||
function resolveWorkspaceUrl(src: string): string {
|
function resolveWorkspaceUrl(src: string): string {
|
||||||
if (src.startsWith("workspace://")) {
|
if (src.startsWith("workspace://")) {
|
||||||
// Strip MIME type fragment if present (e.g., workspace://abc123#video/mp4 → abc123)
|
const fileId = src.replace("workspace://", "");
|
||||||
const withoutPrefix = src.replace("workspace://", "");
|
|
||||||
const fileId = withoutPrefix.split("#")[0];
|
|
||||||
// Use the generated API URL helper to get the correct path
|
// Use the generated API URL helper to get the correct path
|
||||||
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
|
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
|
||||||
// Route through the Next.js proxy (same pattern as customMutator for client-side)
|
// Route through the Next.js proxy (same pattern as customMutator for client-side)
|
||||||
@@ -67,49 +65,13 @@ function isWorkspaceImage(src: string | undefined): boolean {
|
|||||||
return src?.includes("/workspace/files/") ?? false;
|
return src?.includes("/workspace/files/") ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a workspace video with controls and an optional "AI cannot see" badge.
|
|
||||||
*/
|
|
||||||
function WorkspaceVideo({
|
|
||||||
src,
|
|
||||||
aiCannotSee,
|
|
||||||
}: {
|
|
||||||
src: string;
|
|
||||||
aiCannotSee: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span className="relative my-2 inline-block">
|
|
||||||
<video
|
|
||||||
controls
|
|
||||||
className="h-auto max-w-full rounded-md border border-zinc-200"
|
|
||||||
preload="metadata"
|
|
||||||
>
|
|
||||||
<source src={src} />
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
{aiCannotSee && (
|
|
||||||
<span
|
|
||||||
className="absolute bottom-2 right-2 flex items-center gap-1 rounded bg-black/70 px-2 py-1 text-xs text-white"
|
|
||||||
title="The AI cannot see this video"
|
|
||||||
>
|
|
||||||
<EyeSlash size={14} />
|
|
||||||
<span>AI cannot see this video</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom image component that shows an indicator when the AI cannot see the image.
|
* Custom image component that shows an indicator when the AI cannot see the image.
|
||||||
* Also handles the "video:" alt-text prefix convention to render <video> elements.
|
|
||||||
* For workspace files with unknown types, falls back to <video> if <img> fails.
|
|
||||||
* Note: src is already transformed by urlTransform, so workspace:// is now /api/workspace/...
|
* Note: src is already transformed by urlTransform, so workspace:// is now /api/workspace/...
|
||||||
*/
|
*/
|
||||||
function MarkdownImage(props: Record<string, unknown>) {
|
function MarkdownImage(props: Record<string, unknown>) {
|
||||||
const src = props.src as string | undefined;
|
const src = props.src as string | undefined;
|
||||||
const alt = props.alt as string | undefined;
|
const alt = props.alt as string | undefined;
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
|
||||||
|
|
||||||
const aiCannotSee = isWorkspaceImage(src);
|
const aiCannotSee = isWorkspaceImage(src);
|
||||||
|
|
||||||
@@ -122,18 +84,6 @@ function MarkdownImage(props: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect video: prefix in alt text (set by formatOutputValue in helpers.ts)
|
|
||||||
if (alt?.startsWith("video:")) {
|
|
||||||
return <WorkspaceVideo src={src} aiCannotSee={aiCannotSee} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the <img> failed to load and this is a workspace file, try as video.
|
|
||||||
// This handles generic output keys like "file_out" where the MIME type
|
|
||||||
// isn't known from the key name alone.
|
|
||||||
if (imgFailed && aiCannotSee) {
|
|
||||||
return <WorkspaceVideo src={src} aiCannotSee={aiCannotSee} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="relative my-2 inline-block">
|
<span className="relative my-2 inline-block">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
@@ -142,9 +92,6 @@ function MarkdownImage(props: Record<string, unknown>) {
|
|||||||
alt={alt || "Image"}
|
alt={alt || "Image"}
|
||||||
className="h-auto max-w-full rounded-md border border-zinc-200"
|
className="h-auto max-w-full rounded-md border border-zinc-200"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => {
|
|
||||||
if (aiCannotSee) setImgFailed(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{aiCannotSee && (
|
{aiCannotSee && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export function MessageList({
|
|||||||
key={index}
|
key={index}
|
||||||
message={message}
|
message={message}
|
||||||
prevMessage={messages[index - 1]}
|
prevMessage={messages[index - 1]}
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { shouldSkipAgentOutput } from "../../helpers";
|
|||||||
export interface LastToolResponseProps {
|
export interface LastToolResponseProps {
|
||||||
message: ChatMessageData;
|
message: ChatMessageData;
|
||||||
prevMessage: ChatMessageData | undefined;
|
prevMessage: ChatMessageData | undefined;
|
||||||
onSendMessage?: (content: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LastToolResponse({
|
export function LastToolResponse({
|
||||||
message,
|
message,
|
||||||
prevMessage,
|
prevMessage,
|
||||||
onSendMessage,
|
|
||||||
}: LastToolResponseProps) {
|
}: LastToolResponseProps) {
|
||||||
if (message.type !== "tool_response") return null;
|
if (message.type !== "tool_response") return null;
|
||||||
|
|
||||||
@@ -23,7 +21,6 @@ export function LastToolResponse({
|
|||||||
toolId={message.toolId}
|
toolId={message.toolId}
|
||||||
toolName={message.toolName}
|
toolName={message.toolName}
|
||||||
result={message.result}
|
result={message.result}
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Progress } from "@/components/atoms/Progress/Progress";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
import { useAsymptoticProgress } from "../ToolCallMessage/useAsymptoticProgress";
|
|
||||||
|
|
||||||
export interface ThinkingMessageProps {
|
export interface ThinkingMessageProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -13,19 +11,18 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
|||||||
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
|
const [showCoffeeMessage, setShowCoffeeMessage] = useState(false);
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const coffeeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const progress = useAsymptoticProgress(showCoffeeMessage);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current === null) {
|
if (timerRef.current === null) {
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
setShowSlowLoader(true);
|
setShowSlowLoader(true);
|
||||||
}, 3000);
|
}, 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coffeeTimerRef.current === null) {
|
if (coffeeTimerRef.current === null) {
|
||||||
coffeeTimerRef.current = setTimeout(() => {
|
coffeeTimerRef.current = setTimeout(() => {
|
||||||
setShowCoffeeMessage(true);
|
setShowCoffeeMessage(true);
|
||||||
}, 8000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -52,18 +49,9 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
|||||||
<AIChatBubble>
|
<AIChatBubble>
|
||||||
<div className="transition-all duration-500 ease-in-out">
|
<div className="transition-all duration-500 ease-in-out">
|
||||||
{showCoffeeMessage ? (
|
{showCoffeeMessage ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||||
<div className="flex w-full max-w-[280px] flex-col gap-1.5">
|
This could take a few minutes, grab a coffee ☕️
|
||||||
<div className="flex items-center justify-between text-xs text-neutral-500">
|
</span>
|
||||||
<span>Working on it...</span>
|
|
||||||
<span>{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={progress} className="h-2 w-full" />
|
|
||||||
</div>
|
|
||||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
|
||||||
This could take a few minutes, grab a coffee ☕️
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : showSlowLoader ? (
|
) : showSlowLoader ? (
|
||||||
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||||
Taking a bit more time...
|
Taking a bit more time...
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that returns a progress value that starts fast and slows down,
|
|
||||||
* asymptotically approaching but never reaching the max value.
|
|
||||||
*
|
|
||||||
* Uses a half-life formula: progress = max * (1 - 0.5^(time/halfLife))
|
|
||||||
* This creates the "game loading bar" effect where:
|
|
||||||
* - 50% is reached at halfLifeSeconds
|
|
||||||
* - 75% is reached at 2 * halfLifeSeconds
|
|
||||||
* - 87.5% is reached at 3 * halfLifeSeconds
|
|
||||||
* - and so on...
|
|
||||||
*
|
|
||||||
* @param isActive - Whether the progress should be animating
|
|
||||||
* @param halfLifeSeconds - Time in seconds to reach 50% progress (default: 30)
|
|
||||||
* @param maxProgress - Maximum progress value to approach (default: 100)
|
|
||||||
* @param intervalMs - Update interval in milliseconds (default: 100)
|
|
||||||
* @returns Current progress value (0-maxProgress)
|
|
||||||
*/
|
|
||||||
export function useAsymptoticProgress(
|
|
||||||
isActive: boolean,
|
|
||||||
halfLifeSeconds = 30,
|
|
||||||
maxProgress = 100,
|
|
||||||
intervalMs = 100,
|
|
||||||
) {
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const elapsedTimeRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive) {
|
|
||||||
setProgress(0);
|
|
||||||
elapsedTimeRef.current = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
elapsedTimeRef.current += intervalMs / 1000;
|
|
||||||
// Half-life approach: progress = max * (1 - 0.5^(time/halfLife))
|
|
||||||
// At t=halfLife: 50%, at t=2*halfLife: 75%, at t=3*halfLife: 87.5%, etc.
|
|
||||||
const newProgress =
|
|
||||||
maxProgress *
|
|
||||||
(1 - Math.pow(0.5, elapsedTimeRef.current / halfLifeSeconds));
|
|
||||||
setProgress(newProgress);
|
|
||||||
}, intervalMs);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isActive, halfLifeSeconds, maxProgress, intervalMs]);
|
|
||||||
|
|
||||||
return progress;
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
|
|
||||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
|
||||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
|
||||||
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import {
|
|
||||||
CheckCircleIcon,
|
|
||||||
PencilLineIcon,
|
|
||||||
PlayIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
agentName: string;
|
|
||||||
libraryAgentId: string;
|
|
||||||
onSendMessage?: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentCreatedPrompt({
|
|
||||||
agentName,
|
|
||||||
libraryAgentId,
|
|
||||||
onSendMessage,
|
|
||||||
}: Props) {
|
|
||||||
// Fetch library agent eagerly so modal is ready when user clicks
|
|
||||||
const { data: libraryAgentResponse, isLoading } = useGetV2GetLibraryAgent(
|
|
||||||
libraryAgentId,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
enabled: !!libraryAgentId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const libraryAgent =
|
|
||||||
libraryAgentResponse?.status === 200 ? libraryAgentResponse.data : null;
|
|
||||||
|
|
||||||
function handleRunWithPlaceholders() {
|
|
||||||
onSendMessage?.(
|
|
||||||
`Run the agent "${agentName}" with placeholder/example values so I can test it.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRunCreated(execution: GraphExecutionMeta) {
|
|
||||||
onSendMessage?.(
|
|
||||||
`I've started the agent "${agentName}". The execution ID is ${execution.id}. Please monitor its progress and let me know when it completes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScheduleCreated(schedule: GraphExecutionJobInfo) {
|
|
||||||
const scheduleInfo = schedule.cron
|
|
||||||
? `with cron schedule "${schedule.cron}"`
|
|
||||||
: "to run on the specified schedule";
|
|
||||||
onSendMessage?.(
|
|
||||||
`I've scheduled the agent "${agentName}" ${scheduleInfo}. The schedule ID is ${schedule.id}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AIChatBubble>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
|
||||||
<CheckCircleIcon
|
|
||||||
size={18}
|
|
||||||
weight="fill"
|
|
||||||
className="text-green-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text variant="body-medium" className="text-neutral-900">
|
|
||||||
Agent Created Successfully
|
|
||||||
</Text>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
"{agentName}" is ready to test
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Text variant="small-medium" className="text-neutral-700">
|
|
||||||
Ready to test?
|
|
||||||
</Text>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
onClick={handleRunWithPlaceholders}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<PlayIcon size={16} />
|
|
||||||
Run with example values
|
|
||||||
</Button>
|
|
||||||
{libraryAgent ? (
|
|
||||||
<RunAgentModal
|
|
||||||
triggerSlot={
|
|
||||||
<Button variant="outline" size="small" className="gap-2">
|
|
||||||
<PencilLineIcon size={16} />
|
|
||||||
Run with my inputs
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
agent={libraryAgent}
|
|
||||||
onRunCreated={handleRunCreated}
|
|
||||||
onScheduleCreated={handleScheduleCreated}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="small"
|
|
||||||
loading={isLoading}
|
|
||||||
disabled
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<PencilLineIcon size={16} />
|
|
||||||
Run with my inputs
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Text variant="small" className="text-neutral-500">
|
|
||||||
or just ask me
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AIChatBubble>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,11 @@ import { Text } from "@/components/atoms/Text/Text";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ToolResult } from "@/types/chat";
|
import type { ToolResult } from "@/types/chat";
|
||||||
import { WarningCircleIcon } from "@phosphor-icons/react";
|
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||||
import { AgentCreatedPrompt } from "./AgentCreatedPrompt";
|
|
||||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||||
import {
|
import {
|
||||||
formatToolResponse,
|
formatToolResponse,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
isAgentSavedResponse,
|
|
||||||
isErrorResponse,
|
isErrorResponse,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
@@ -18,7 +16,6 @@ export interface ToolResponseMessageProps {
|
|||||||
result?: ToolResult;
|
result?: ToolResult;
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onSendMessage?: (content: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolResponseMessage({
|
export function ToolResponseMessage({
|
||||||
@@ -27,7 +24,6 @@ export function ToolResponseMessage({
|
|||||||
result,
|
result,
|
||||||
success: _success,
|
success: _success,
|
||||||
className,
|
className,
|
||||||
onSendMessage,
|
|
||||||
}: ToolResponseMessageProps) {
|
}: ToolResponseMessageProps) {
|
||||||
if (isErrorResponse(result)) {
|
if (isErrorResponse(result)) {
|
||||||
const errorMessage = getErrorMessage(result);
|
const errorMessage = getErrorMessage(result);
|
||||||
@@ -47,18 +43,6 @@ export function ToolResponseMessage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for agent_saved response - show special prompt
|
|
||||||
const agentSavedData = isAgentSavedResponse(result);
|
|
||||||
if (agentSavedData.isSaved) {
|
|
||||||
return (
|
|
||||||
<AgentCreatedPrompt
|
|
||||||
agentName={agentSavedData.agentName}
|
|
||||||
libraryAgentId={agentSavedData.libraryAgentId}
|
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedText = formatToolResponse(result, toolName);
|
const formattedText = formatToolResponse(result, toolName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,43 +6,6 @@ function stripInternalReasoning(content: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSavedData {
|
|
||||||
isSaved: boolean;
|
|
||||||
agentName: string;
|
|
||||||
agentId: string;
|
|
||||||
libraryAgentId: string;
|
|
||||||
libraryAgentLink: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAgentSavedResponse(result: unknown): AgentSavedData {
|
|
||||||
if (typeof result !== "object" || result === null) {
|
|
||||||
return {
|
|
||||||
isSaved: false,
|
|
||||||
agentName: "",
|
|
||||||
agentId: "",
|
|
||||||
libraryAgentId: "",
|
|
||||||
libraryAgentLink: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const response = result as Record<string, unknown>;
|
|
||||||
if (response.type === "agent_saved") {
|
|
||||||
return {
|
|
||||||
isSaved: true,
|
|
||||||
agentName: (response.agent_name as string) || "Agent",
|
|
||||||
agentId: (response.agent_id as string) || "",
|
|
||||||
libraryAgentId: (response.library_agent_id as string) || "",
|
|
||||||
libraryAgentLink: (response.library_agent_link as string) || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
isSaved: false,
|
|
||||||
agentName: "",
|
|
||||||
agentId: "",
|
|
||||||
libraryAgentId: "",
|
|
||||||
libraryAgentLink: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isErrorResponse(result: unknown): boolean {
|
export function isErrorResponse(result: unknown): boolean {
|
||||||
if (typeof result === "string") {
|
if (typeof result === "string") {
|
||||||
const lower = result.toLowerCase();
|
const lower = result.toLowerCase();
|
||||||
@@ -76,101 +39,69 @@ export function getErrorMessage(result: unknown): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a value is a workspace file reference.
|
* Check if a value is a workspace file reference.
|
||||||
* Format: workspace://{fileId} or workspace://{fileId}#{mimeType}
|
|
||||||
*/
|
*/
|
||||||
function isWorkspaceRef(value: unknown): value is string {
|
function isWorkspaceRef(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.startsWith("workspace://");
|
return typeof value === "string" && value.startsWith("workspace://");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract MIME type from a workspace reference fragment.
|
* Check if a workspace reference appears to be an image based on common patterns.
|
||||||
* e.g., "workspace://abc123#video/mp4" → "video/mp4"
|
* Since workspace refs don't have extensions, we check the context or assume image
|
||||||
* Returns undefined if no fragment is present.
|
* for certain block types.
|
||||||
|
*
|
||||||
|
* TODO: Replace keyword matching with MIME type encoded in workspace ref.
|
||||||
|
* e.g., workspace://abc123#image/png or workspace://abc123#video/mp4
|
||||||
|
* This would let frontend render correctly without fragile keyword matching.
|
||||||
*/
|
*/
|
||||||
function getWorkspaceMimeType(value: string): string | undefined {
|
function isLikelyImageRef(value: string, outputKey?: string): boolean {
|
||||||
const hashIndex = value.indexOf("#");
|
if (!isWorkspaceRef(value)) return false;
|
||||||
if (hashIndex === -1) return undefined;
|
|
||||||
return value.slice(hashIndex + 1) || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Check output key name for video-related hints (these are NOT images)
|
||||||
* Determine the media category of a workspace ref or data URI.
|
const videoKeywords = ["video", "mp4", "mov", "avi", "webm", "movie", "clip"];
|
||||||
* Uses the MIME type fragment on workspace refs when available,
|
if (outputKey) {
|
||||||
* falls back to output key keyword matching for older refs without it.
|
const lowerKey = outputKey.toLowerCase();
|
||||||
*/
|
if (videoKeywords.some((kw) => lowerKey.includes(kw))) {
|
||||||
function getMediaCategory(
|
return false;
|
||||||
value: string,
|
|
||||||
outputKey?: string,
|
|
||||||
): "video" | "image" | "audio" | "unknown" {
|
|
||||||
// Data URIs carry their own MIME type
|
|
||||||
if (value.startsWith("data:video/")) return "video";
|
|
||||||
if (value.startsWith("data:image/")) return "image";
|
|
||||||
if (value.startsWith("data:audio/")) return "audio";
|
|
||||||
|
|
||||||
// Workspace refs: prefer MIME type fragment
|
|
||||||
if (isWorkspaceRef(value)) {
|
|
||||||
const mime = getWorkspaceMimeType(value);
|
|
||||||
if (mime) {
|
|
||||||
if (mime.startsWith("video/")) return "video";
|
|
||||||
if (mime.startsWith("image/")) return "image";
|
|
||||||
if (mime.startsWith("audio/")) return "audio";
|
|
||||||
return "unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: keyword matching on output key for older refs without fragment
|
|
||||||
if (outputKey) {
|
|
||||||
const lowerKey = outputKey.toLowerCase();
|
|
||||||
|
|
||||||
const videoKeywords = [
|
|
||||||
"video",
|
|
||||||
"mp4",
|
|
||||||
"mov",
|
|
||||||
"avi",
|
|
||||||
"webm",
|
|
||||||
"movie",
|
|
||||||
"clip",
|
|
||||||
];
|
|
||||||
if (videoKeywords.some((kw) => lowerKey.includes(kw))) return "video";
|
|
||||||
|
|
||||||
const imageKeywords = [
|
|
||||||
"image",
|
|
||||||
"img",
|
|
||||||
"photo",
|
|
||||||
"picture",
|
|
||||||
"thumbnail",
|
|
||||||
"avatar",
|
|
||||||
"icon",
|
|
||||||
"screenshot",
|
|
||||||
];
|
|
||||||
if (imageKeywords.some((kw) => lowerKey.includes(kw))) return "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to image for backward compatibility
|
|
||||||
return "image";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "unknown";
|
// Check output key name for image-related hints
|
||||||
|
const imageKeywords = [
|
||||||
|
"image",
|
||||||
|
"img",
|
||||||
|
"photo",
|
||||||
|
"picture",
|
||||||
|
"thumbnail",
|
||||||
|
"avatar",
|
||||||
|
"icon",
|
||||||
|
"screenshot",
|
||||||
|
];
|
||||||
|
if (outputKey) {
|
||||||
|
const lowerKey = outputKey.toLowerCase();
|
||||||
|
if (imageKeywords.some((kw) => lowerKey.includes(kw))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to treating workspace refs as potential images
|
||||||
|
// since that's the most common case for generated content
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a single output value, converting workspace refs to markdown images/videos.
|
* Format a single output value, converting workspace refs to markdown images.
|
||||||
* Videos use a "video:" alt-text prefix so the MarkdownContent renderer can
|
|
||||||
* distinguish them from images and render a <video> element.
|
|
||||||
*/
|
*/
|
||||||
function formatOutputValue(value: unknown, outputKey?: string): string {
|
function formatOutputValue(value: unknown, outputKey?: string): string {
|
||||||
|
if (isWorkspaceRef(value) && isLikelyImageRef(value, outputKey)) {
|
||||||
|
// Format as markdown image
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const category = getMediaCategory(value, outputKey);
|
// Check for data URIs (images)
|
||||||
|
if (value.startsWith("data:image/")) {
|
||||||
if (category === "video") {
|
|
||||||
// Format with "video:" prefix so MarkdownContent renders <video>
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category === "image") {
|
|
||||||
return ``;
|
return ``;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For audio, unknown workspace refs, data URIs, etc. - return as-is
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,17 +41,7 @@ export function HostScopedCredentialsModal({
|
|||||||
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
const currentHost = currentUrl ? getHostFromUrl(currentUrl) : "";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
host: z
|
host: z.string().min(1, "Host is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Host is required")
|
|
||||||
.refine((val) => !/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(val), {
|
|
||||||
message: "Enter only the host (e.g. api.example.com), not a full URL",
|
|
||||||
})
|
|
||||||
.refine((val) => !val.includes("/"), {
|
|
||||||
message:
|
|
||||||
"Enter only the host (e.g. api.example.com), without a trailing path. " +
|
|
||||||
"You may specify a port (e.g. api.example.com:8080) if needed.",
|
|
||||||
}),
|
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const providerIcons: Partial<
|
|||||||
nvidia: fallbackIcon,
|
nvidia: fallbackIcon,
|
||||||
discord: FaDiscord,
|
discord: FaDiscord,
|
||||||
d_id: fallbackIcon,
|
d_id: fallbackIcon,
|
||||||
elevenlabs: fallbackIcon,
|
|
||||||
google_maps: FaGoogle,
|
google_maps: FaGoogle,
|
||||||
jina: fallbackIcon,
|
jina: fallbackIcon,
|
||||||
ideogram: fallbackIcon,
|
ideogram: fallbackIcon,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function Navbar() {
|
|||||||
|
|
||||||
const actualLoggedInLinks = [
|
const actualLoggedInLinks = [
|
||||||
{ name: "Home", href: homeHref },
|
{ name: "Home", href: homeHref },
|
||||||
...(isChatEnabled === true ? [{ name: "Agents", href: "/library" }] : []),
|
...(isChatEnabled === true ? [{ name: "Tasks", href: "/library" }] : []),
|
||||||
...loggedInLinks,
|
...loggedInLinks,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store |
|
| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store |
|
||||||
| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API |
|
| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API |
|
||||||
| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data |
|
| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data |
|
||||||
|
| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear |
|
||||||
| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty |
|
| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty |
|
||||||
| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library |
|
| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library |
|
||||||
| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes |
|
| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes |
|
||||||
@@ -192,7 +193,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Get Current Time](block-integrations/text.md#get-current-time) | This block outputs the current time |
|
| [Get Current Time](block-integrations/text.md#get-current-time) | This block outputs the current time |
|
||||||
| [Match Text Pattern](block-integrations/text.md#match-text-pattern) | Matches text against a regex pattern and forwards data to positive or negative output based on the match |
|
| [Match Text Pattern](block-integrations/text.md#match-text-pattern) | Matches text against a regex pattern and forwards data to positive or negative output based on the match |
|
||||||
| [Text Decoder](block-integrations/text.md#text-decoder) | Decodes a string containing escape sequences into actual text |
|
| [Text Decoder](block-integrations/text.md#text-decoder) | Decodes a string containing escape sequences into actual text |
|
||||||
| [Text Encoder](block-integrations/text.md#text-encoder) | Encodes a string by converting special characters into escape sequences |
|
|
||||||
| [Text Replace](block-integrations/text.md#text-replace) | This block is used to replace a text with a new text |
|
| [Text Replace](block-integrations/text.md#text-replace) | This block is used to replace a text with a new text |
|
||||||
| [Text Split](block-integrations/text.md#text-split) | This block is used to split a text into a list of strings |
|
| [Text Split](block-integrations/text.md#text-split) | This block is used to split a text into a list of strings |
|
||||||
| [Word Character Count](block-integrations/text.md#word-character-count) | Counts the number of words and characters in a given text |
|
| [Word Character Count](block-integrations/text.md#word-character-count) | Counts the number of words and characters in a given text |
|
||||||
@@ -233,7 +233,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Stagehand Extract](block-integrations/stagehand/blocks.md#stagehand-extract) | Extract structured data from a webpage |
|
| [Stagehand Extract](block-integrations/stagehand/blocks.md#stagehand-extract) | Extract structured data from a webpage |
|
||||||
| [Stagehand Observe](block-integrations/stagehand/blocks.md#stagehand-observe) | Find suggested actions for your workflows |
|
| [Stagehand Observe](block-integrations/stagehand/blocks.md#stagehand-observe) | Find suggested actions for your workflows |
|
||||||
| [Unreal Text To Speech](block-integrations/llm.md#unreal-text-to-speech) | Converts text to speech using the Unreal Speech API |
|
| [Unreal Text To Speech](block-integrations/llm.md#unreal-text-to-speech) | Converts text to speech using the Unreal Speech API |
|
||||||
| [Video Narration](block-integrations/video/narration.md#video-narration) | Generate AI narration and add to video |
|
|
||||||
|
|
||||||
## Search and Information Retrieval
|
## Search and Information Retrieval
|
||||||
|
|
||||||
@@ -473,13 +472,9 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
|
|
||||||
| Block Name | Description |
|
| Block Name | Description |
|
||||||
|------------|-------------|
|
|------------|-------------|
|
||||||
| [Add Audio To Video](block-integrations/video/add_audio.md#add-audio-to-video) | Block to attach an audio file to a video file using moviepy |
|
| [Add Audio To Video](block-integrations/multimedia.md#add-audio-to-video) | Block to attach an audio file to a video file using moviepy |
|
||||||
| [Loop Video](block-integrations/video/loop.md#loop-video) | Block to loop a video to a given duration or number of repeats |
|
| [Loop Video](block-integrations/multimedia.md#loop-video) | Block to loop a video to a given duration or number of repeats |
|
||||||
| [Media Duration](block-integrations/video/duration.md#media-duration) | Block to get the duration of a media file |
|
| [Media Duration](block-integrations/multimedia.md#media-duration) | Block to get the duration of a media file |
|
||||||
| [Video Clip](block-integrations/video/clip.md#video-clip) | Extract a time segment from a video |
|
|
||||||
| [Video Concat](block-integrations/video/concat.md#video-concat) | Merge multiple video clips into one continuous video |
|
|
||||||
| [Video Download](block-integrations/video/download.md#video-download) | Download video from URL (YouTube, Vimeo, news sites, direct links) |
|
|
||||||
| [Video Text Overlay](block-integrations/video/text_overlay.md#video-text-overlay) | Add text overlay/caption to video |
|
|
||||||
|
|
||||||
## Productivity
|
## Productivity
|
||||||
|
|
||||||
@@ -576,7 +571,6 @@ Below is a comprehensive list of all available blocks, categorized by their prim
|
|||||||
| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue |
|
| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue |
|
||||||
| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear |
|
| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear |
|
||||||
| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee |
|
| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee |
|
||||||
| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear |
|
|
||||||
| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear |
|
| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear |
|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
* [LLM](block-integrations/llm.md)
|
* [LLM](block-integrations/llm.md)
|
||||||
* [Logic](block-integrations/logic.md)
|
* [Logic](block-integrations/logic.md)
|
||||||
* [Misc](block-integrations/misc.md)
|
* [Misc](block-integrations/misc.md)
|
||||||
|
* [Multimedia](block-integrations/multimedia.md)
|
||||||
* [Notion Create Page](block-integrations/notion/create_page.md)
|
* [Notion Create Page](block-integrations/notion/create_page.md)
|
||||||
* [Notion Read Database](block-integrations/notion/read_database.md)
|
* [Notion Read Database](block-integrations/notion/read_database.md)
|
||||||
* [Notion Read Page](block-integrations/notion/read_page.md)
|
* [Notion Read Page](block-integrations/notion/read_page.md)
|
||||||
@@ -128,13 +129,5 @@
|
|||||||
* [Twitter Timeline](block-integrations/twitter/timeline.md)
|
* [Twitter Timeline](block-integrations/twitter/timeline.md)
|
||||||
* [Twitter Tweet Lookup](block-integrations/twitter/tweet_lookup.md)
|
* [Twitter Tweet Lookup](block-integrations/twitter/tweet_lookup.md)
|
||||||
* [Twitter User Lookup](block-integrations/twitter/user_lookup.md)
|
* [Twitter User Lookup](block-integrations/twitter/user_lookup.md)
|
||||||
* [Video Add Audio](block-integrations/video/add_audio.md)
|
|
||||||
* [Video Clip](block-integrations/video/clip.md)
|
|
||||||
* [Video Concat](block-integrations/video/concat.md)
|
|
||||||
* [Video Download](block-integrations/video/download.md)
|
|
||||||
* [Video Duration](block-integrations/video/duration.md)
|
|
||||||
* [Video Loop](block-integrations/video/loop.md)
|
|
||||||
* [Video Narration](block-integrations/video/narration.md)
|
|
||||||
* [Video Text Overlay](block-integrations/video/text_overlay.md)
|
|
||||||
* [Wolfram LLM API](block-integrations/wolfram/llm_api.md)
|
* [Wolfram LLM API](block-integrations/wolfram/llm_api.md)
|
||||||
* [Zerobounce Validate Emails](block-integrations/zerobounce/validate_emails.md)
|
* [Zerobounce Validate Emails](block-integrations/zerobounce/validate_emails.md)
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ Searches for issues on Linear
|
|||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
<!-- MANUAL: how_it_works -->
|
<!-- MANUAL: how_it_works -->
|
||||||
This block searches for issues in Linear using a text query. It searches across issue titles, descriptions, and other fields to find matching issues. You can limit the number of results returned using the `max_results` parameter (default: 10, max: 100) to control token consumption and response size.
|
This block searches for issues in Linear using a text query. It searches across issue titles, descriptions, and other fields to find matching issues.
|
||||||
|
|
||||||
Optionally filter results by team name to narrow searches to specific workspaces. If a team name is provided, the block resolves it to a team ID before searching. Returns matching issues with their state, creation date, project, and assignee information. If the search or team resolution fails, an error message is returned.
|
Returns a list of issues matching the search term.
|
||||||
<!-- END MANUAL -->
|
<!-- END MANUAL -->
|
||||||
|
|
||||||
### Inputs
|
### Inputs
|
||||||
@@ -100,14 +100,12 @@ Optionally filter results by team name to narrow searches to specific workspaces
|
|||||||
| Input | Description | Type | Required |
|
| Input | Description | Type | Required |
|
||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| term | Term to search for issues | str | Yes |
|
| term | Term to search for issues | str | Yes |
|
||||||
| max_results | Maximum number of results to return | int | No |
|
|
||||||
| team_name | Optional team name to filter results (e.g., 'Internal', 'Open Source') | str | No |
|
|
||||||
|
|
||||||
### Outputs
|
### Outputs
|
||||||
|
|
||||||
| Output | Description | Type |
|
| Output | Description | Type |
|
||||||
|--------|-------------|------|
|
|--------|-------------|------|
|
||||||
| error | Error message if the search failed | str |
|
| error | Error message if the operation failed | str |
|
||||||
| issues | List of issues | List[Issue] |
|
| issues | List of issues | List[Issue] |
|
||||||
|
|
||||||
### Possible use case
|
### Possible use case
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ The result routes data to yes_output or no_output, enabling intelligent branchin
|
|||||||
| condition | A plaintext English description of the condition to evaluate | str | Yes |
|
| condition | A plaintext English description of the condition to evaluate | str | Yes |
|
||||||
| yes_value | (Optional) Value to output if the condition is true. If not provided, input_value will be used. | Yes Value | No |
|
| yes_value | (Optional) Value to output if the condition is true. If not provided, input_value will be used. | Yes Value | No |
|
||||||
| no_value | (Optional) Value to output if the condition is false. If not provided, input_value will be used. | No Value | No |
|
| no_value | (Optional) Value to output if the condition is false. If not provided, input_value will be used. | No Value | No |
|
||||||
| model | The language model to use for evaluating the condition. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for evaluating the condition. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
|
|
||||||
### Outputs
|
### Outputs
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ The block sends the entire conversation history to the chosen LLM, including sys
|
|||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| prompt | The prompt to send to the language model. | str | No |
|
| prompt | The prompt to send to the language model. | str | No |
|
||||||
| messages | List of messages in the conversation. | List[Any] | Yes |
|
| messages | List of messages in the conversation. | List[Any] | Yes |
|
||||||
| model | The language model to use for the conversation. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for the conversation. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
||||||
| ollama_host | Ollama host for local models | str | No |
|
| ollama_host | Ollama host for local models | str | No |
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ The block formulates a prompt based on the given focus or source data, sends it
|
|||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| focus | The focus of the list to generate. | str | No |
|
| focus | The focus of the list to generate. | str | No |
|
||||||
| source_data | The data to generate the list from. | str | No |
|
| source_data | The data to generate the list from. | str | No |
|
||||||
| model | The language model to use for generating the list. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for generating the list. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
| max_retries | Maximum number of retries for generating a valid list. | int | No |
|
| max_retries | Maximum number of retries for generating a valid list. | int | No |
|
||||||
| force_json_output | Whether to force the LLM to produce a JSON-only response. This can increase the block's reliability, but may also reduce the quality of the response because it prohibits the LLM from reasoning before providing its JSON response. | bool | No |
|
| force_json_output | Whether to force the LLM to produce a JSON-only response. This can increase the block's reliability, but may also reduce the quality of the response because it prohibits the LLM from reasoning before providing its JSON response. | bool | No |
|
||||||
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
||||||
@@ -424,7 +424,7 @@ The block sends the input prompt to a chosen LLM, along with any system prompts
|
|||||||
| prompt | The prompt to send to the language model. | str | Yes |
|
| prompt | The prompt to send to the language model. | str | Yes |
|
||||||
| expected_format | Expected format of the response. If provided, the response will be validated against this format. The keys should be the expected fields in the response, and the values should be the description of the field. | Dict[str, str] | Yes |
|
| expected_format | Expected format of the response. If provided, the response will be validated against this format. The keys should be the expected fields in the response, and the values should be the description of the field. | Dict[str, str] | Yes |
|
||||||
| list_result | Whether the response should be a list of objects in the expected format. | bool | No |
|
| list_result | Whether the response should be a list of objects in the expected format. | bool | No |
|
||||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
| force_json_output | Whether to force the LLM to produce a JSON-only response. This can increase the block's reliability, but may also reduce the quality of the response because it prohibits the LLM from reasoning before providing its JSON response. | bool | No |
|
| force_json_output | Whether to force the LLM to produce a JSON-only response. This can increase the block's reliability, but may also reduce the quality of the response because it prohibits the LLM from reasoning before providing its JSON response. | bool | No |
|
||||||
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
||||||
| conversation_history | The conversation history to provide context for the prompt. | List[Dict[str, Any]] | No |
|
| conversation_history | The conversation history to provide context for the prompt. | List[Dict[str, Any]] | No |
|
||||||
@@ -464,7 +464,7 @@ The block sends the input prompt to a chosen LLM, processes the response, and re
|
|||||||
| Input | Description | Type | Required |
|
| Input | Description | Type | Required |
|
||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| prompt | The prompt to send to the language model. You can use any of the {keys} from Prompt Values to fill in the prompt with values from the prompt values dictionary by putting them in curly braces. | str | Yes |
|
| prompt | The prompt to send to the language model. You can use any of the {keys} from Prompt Values to fill in the prompt with values from the prompt values dictionary by putting them in curly braces. | str | Yes |
|
||||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
||||||
| retry | Number of times to retry the LLM call if the response does not match the expected format. | int | No |
|
| retry | Number of times to retry the LLM call if the response does not match the expected format. | int | No |
|
||||||
| prompt_values | Values used to fill in the prompt. The values can be used in the prompt by putting them in a double curly braces, e.g. {{variable_name}}. | Dict[str, str] | No |
|
| prompt_values | Values used to fill in the prompt. The values can be used in the prompt by putting them in a double curly braces, e.g. {{variable_name}}. | Dict[str, str] | No |
|
||||||
@@ -501,7 +501,7 @@ The block splits the input text into smaller chunks, sends each chunk to an LLM
|
|||||||
| Input | Description | Type | Required |
|
| Input | Description | Type | Required |
|
||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| text | The text to summarize. | str | Yes |
|
| text | The text to summarize. | str | Yes |
|
||||||
| model | The language model to use for summarizing the text. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for summarizing the text. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
| focus | The topic to focus on in the summary | str | No |
|
| focus | The topic to focus on in the summary | str | No |
|
||||||
| style | The style of the summary to generate. | "concise" \| "detailed" \| "bullet points" \| "numbered list" | No |
|
| style | The style of the summary to generate. | "concise" \| "detailed" \| "bullet points" \| "numbered list" | No |
|
||||||
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
| max_tokens | The maximum number of tokens to generate in the chat completion. | int | No |
|
||||||
@@ -763,7 +763,7 @@ Configure agent_mode_max_iterations to control loop behavior: 0 for single decis
|
|||||||
| Input | Description | Type | Required |
|
| Input | Description | Type | Required |
|
||||||
|-------|-------------|------|----------|
|
|-------|-------------|------|----------|
|
||||||
| prompt | The prompt to send to the language model. | str | Yes |
|
| prompt | The prompt to send to the language model. | str | Yes |
|
||||||
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-opus-4-6" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
| model | The language model to use for answering the prompt. | "o3-mini" \| "o3-2025-04-16" \| "o1" \| "o1-mini" \| "gpt-5.2-2025-12-11" \| "gpt-5.1-2025-11-13" \| "gpt-5-2025-08-07" \| "gpt-5-mini-2025-08-07" \| "gpt-5-nano-2025-08-07" \| "gpt-5-chat-latest" \| "gpt-4.1-2025-04-14" \| "gpt-4.1-mini-2025-04-14" \| "gpt-4o-mini" \| "gpt-4o" \| "gpt-4-turbo" \| "gpt-3.5-turbo" \| "claude-opus-4-1-20250805" \| "claude-opus-4-20250514" \| "claude-sonnet-4-20250514" \| "claude-opus-4-5-20251101" \| "claude-sonnet-4-5-20250929" \| "claude-haiku-4-5-20251001" \| "claude-3-haiku-20240307" \| "Qwen/Qwen2.5-72B-Instruct-Turbo" \| "nvidia/llama-3.1-nemotron-70b-instruct" \| "meta-llama/Llama-3.3-70B-Instruct-Turbo" \| "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" \| "meta-llama/Llama-3.2-3B-Instruct-Turbo" \| "llama-3.3-70b-versatile" \| "llama-3.1-8b-instant" \| "llama3.3" \| "llama3.2" \| "llama3" \| "llama3.1:405b" \| "dolphin-mistral:latest" \| "openai/gpt-oss-120b" \| "openai/gpt-oss-20b" \| "google/gemini-2.5-pro-preview-03-25" \| "google/gemini-3-pro-preview" \| "google/gemini-2.5-flash" \| "google/gemini-2.0-flash-001" \| "google/gemini-2.5-flash-lite-preview-06-17" \| "google/gemini-2.0-flash-lite-001" \| "mistralai/mistral-nemo" \| "cohere/command-r-08-2024" \| "cohere/command-r-plus-08-2024" \| "deepseek/deepseek-chat" \| "deepseek/deepseek-r1-0528" \| "perplexity/sonar" \| "perplexity/sonar-pro" \| "perplexity/sonar-deep-research" \| "nousresearch/hermes-3-llama-3.1-405b" \| "nousresearch/hermes-3-llama-3.1-70b" \| "amazon/nova-lite-v1" \| "amazon/nova-micro-v1" \| "amazon/nova-pro-v1" \| "microsoft/wizardlm-2-8x22b" \| "gryphe/mythomax-l2-13b" \| "meta-llama/llama-4-scout" \| "meta-llama/llama-4-maverick" \| "x-ai/grok-4" \| "x-ai/grok-4-fast" \| "x-ai/grok-4.1-fast" \| "x-ai/grok-code-fast-1" \| "moonshotai/kimi-k2" \| "qwen/qwen3-235b-a22b-thinking-2507" \| "qwen/qwen3-coder" \| "Llama-4-Scout-17B-16E-Instruct-FP8" \| "Llama-4-Maverick-17B-128E-Instruct-FP8" \| "Llama-3.3-8B-Instruct" \| "Llama-3.3-70B-Instruct" \| "v0-1.5-md" \| "v0-1.5-lg" \| "v0-1.0-md" | No |
|
||||||
| multiple_tool_calls | Whether to allow multiple tool calls in a single response. | bool | No |
|
| multiple_tool_calls | Whether to allow multiple tool calls in a single response. | bool | No |
|
||||||
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
| sys_prompt | The system prompt to provide additional context to the model. | str | No |
|
||||||
| conversation_history | The conversation history to provide context for the prompt. | List[Dict[str, Any]] | No |
|
| conversation_history | The conversation history to provide context for the prompt. | List[Dict[str, Any]] | No |
|
||||||
|
|||||||
@@ -380,42 +380,6 @@ This is useful when working with data from APIs or files where escape sequences
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Text Encoder
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Encodes a string by converting special characters into escape sequences
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The Text Encoder takes the input string and applies Python's `unicode_escape` encoding (equivalent to `codecs.encode(text, "unicode_escape").decode("utf-8")`) to transform special characters like newlines, tabs, and backslashes into their escaped forms.
|
|
||||||
|
|
||||||
The block relies on the input schema to ensure the value is a string; non-string inputs are rejected by validation, and any encoding failures surface as block errors. Non-ASCII characters are emitted as `\uXXXX` sequences, which is useful for ASCII-only payloads.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| text | A string containing special characters to be encoded | str | Yes |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if encoding fails | str |
|
|
||||||
| encoded_text | The encoded text with special characters converted to escape sequences | str |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
**JSON Payload Preparation**: Encode multiline or quoted text before embedding it in JSON string fields to ensure proper escaping.
|
|
||||||
|
|
||||||
**Config/ENV Generation**: Convert template text into escaped strings for `.env` or YAML values that require special character handling.
|
|
||||||
|
|
||||||
**Snapshot Fixtures**: Produce stable escaped strings for golden files or API tests where consistent text representation is needed.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Text Replace
|
## Text Replace
|
||||||
|
|
||||||
### What it is
|
### What it is
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Video Add Audio
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block allows you to attach a separate audio track to a video file, replacing or combining with the original audio.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Add Audio To Video
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Block to attach an audio file to a video file using moviepy.
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses MoviePy to combine video and audio files. It loads the video and audio inputs (which can be URLs, data URIs, or local paths), optionally scales the audio volume, then writes the combined result to a new video file using H.264 video codec and AAC audio codec.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| video_in | Video input (URL, data URI, or local path). | str (file) | Yes |
|
|
||||||
| audio_in | Audio input (URL, data URI, or local path). | str (file) | Yes |
|
|
||||||
| volume | Volume scale for the newly attached audio track (1.0 = original). | float | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_out | Final video (with attached audio), as a path or data URI. | str (file) |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Adding background music to a silent screen recording
|
|
||||||
- Replacing original audio with a voiceover or translated audio track
|
|
||||||
- Combining AI-generated speech with stock footage
|
|
||||||
- Adding sound effects to video content
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Video Clip
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block extracts a specific time segment from a video file, allowing you to trim videos to precise start and end times.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Video Clip
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Extract a time segment from a video
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses MoviePy's `subclipped` function to extract a portion of the video between specified start and end times. It validates that end time is greater than start time, then creates a new video file containing only the selected segment. The output is encoded with H.264 video codec and AAC audio codec, preserving both video and audio from the original clip.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| video_in | Input video (URL, data URI, or local path) | str (file) | Yes |
|
|
||||||
| start_time | Start time in seconds | float | Yes |
|
|
||||||
| end_time | End time in seconds | float | Yes |
|
|
||||||
| output_format | Output format | "mp4" \| "webm" \| "mkv" \| "mov" | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_out | Clipped video file (path or data URI) | str (file) |
|
|
||||||
| duration | Clip duration in seconds | float |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Extracting highlights from a longer video
|
|
||||||
- Trimming intro/outro from recorded content
|
|
||||||
- Creating short clips for social media from longer videos
|
|
||||||
- Isolating specific segments for further processing in a workflow
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Video Concat
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block merges multiple video clips into a single continuous video, with optional transitions between clips.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Video Concat
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Merge multiple video clips into one continuous video
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses MoviePy's `concatenate_videoclips` function to join multiple videos in sequence. It supports three transition modes: **none** (direct concatenation), **crossfade** (smooth blending where clips overlap), and **fade_black** (each clip fades out to black and the next fades in). At least 2 videos are required. The output is encoded with H.264 video codec and AAC audio codec.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| videos | List of video files to concatenate (in order) | List[str (file)] | Yes |
|
|
||||||
| transition | Transition between clips | "none" \| "crossfade" \| "fade_black" | No |
|
|
||||||
| transition_duration | Transition duration in seconds | int | No |
|
|
||||||
| output_format | Output format | "mp4" \| "webm" \| "mkv" \| "mov" | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_out | Concatenated video file (path or data URI) | str (file) |
|
|
||||||
| total_duration | Total duration in seconds | float |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Combining multiple clips into a compilation video
|
|
||||||
- Assembling intro, main content, and outro segments
|
|
||||||
- Creating montages from multiple source videos
|
|
||||||
- Building video playlists or slideshows with transitions
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Video Download
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block downloads videos from URLs, supporting a wide range of video platforms and direct links.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Video Download
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Download video from URL (YouTube, Vimeo, news sites, direct links)
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses yt-dlp, a powerful video downloading library that supports over 1000 websites. It accepts a URL, quality preference, and output format, then downloads the video while merging the best available video and audio streams for the selected quality. Quality options: **best** (highest available), **1080p/720p/480p** (maximum resolution at that height), **audio_only** (extracts just the audio track).
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| url | URL of the video to download (YouTube, Vimeo, direct link, etc.) | str | Yes |
|
|
||||||
| quality | Video quality preference | "best" \| "1080p" \| "720p" \| "480p" \| "audio_only" | No |
|
|
||||||
| output_format | Output video format | "mp4" \| "webm" \| "mkv" | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_file | Downloaded video (path or data URI) | str (file) |
|
|
||||||
| duration | Video duration in seconds | float |
|
|
||||||
| title | Video title from source | str |
|
|
||||||
| source_url | Original source URL | str |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Downloading source videos for editing or remixing
|
|
||||||
- Archiving video content for offline processing
|
|
||||||
- Extracting audio from videos for transcription or podcast creation
|
|
||||||
- Gathering video content for automated content pipelines
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Video Duration
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block retrieves the duration of video or audio files, useful for planning and conditional logic in media workflows.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Media Duration
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Block to get the duration of a media file.
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses MoviePy to load the media file and extract its duration property. It supports both video files (using VideoFileClip) and audio files (using AudioFileClip), determined by the `is_video` flag. The media can be provided as a URL, data URI, or local file path. The duration is returned in seconds as a floating-point number.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| media_in | Media input (URL, data URI, or local path). | str (file) | Yes |
|
|
||||||
| is_video | Whether the media is a video (True) or audio (False). | bool | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| duration | Duration of the media file (in seconds). | float |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Checking video length before processing to avoid timeout issues
|
|
||||||
- Calculating how many times to loop a video to reach a target duration
|
|
||||||
- Validating that uploaded content meets length requirements
|
|
||||||
- Building conditional workflows based on media duration
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Video Loop
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block repeats a video to extend its duration, either to a specific length or a set number of repetitions.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Loop Video
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Block to loop a video to a given duration or number of repeats.
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses MoviePy's Loop effect to repeat a video clip. You can specify either a target duration (the video will repeat until reaching that length) or a number of loops (the video will repeat that many times). The Loop effect handles both video and audio looping automatically, maintaining sync. Either `duration` or `n_loops` must be provided. The output is encoded with H.264 video codec and AAC audio codec.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| video_in | The input video (can be a URL, data URI, or local path). | str (file) | Yes |
|
|
||||||
| duration | Target duration (in seconds) to loop the video to. Either duration or n_loops must be provided. | float | No |
|
|
||||||
| n_loops | Number of times to repeat the video. Either n_loops or duration must be provided. | int | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_out | Looped video returned either as a relative path or a data URI. | str (file) |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Extending a short background video to match the length of narration audio
|
|
||||||
- Creating seamless looping content for digital signage
|
|
||||||
- Repeating a product demo video multiple times for emphasis
|
|
||||||
- Extending short clips to meet minimum duration requirements for platforms
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Video Narration
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block generates AI voiceover narration using ElevenLabs and adds it to a video, with flexible audio mixing options.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Video Narration
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Generate AI narration and add to video
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses ElevenLabs text-to-speech API to generate natural-sounding narration from your script. It then combines the narration with the video using MoviePy. Three audio mixing modes are available: **replace** (completely replaces original audio), **mix** (blends narration with original audio at configurable volumes), and **ducking** (similar to mix but applies stronger attenuation to original audio, making narration more prominent). The block outputs both the final video and the generated audio file separately.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| video_in | Input video (URL, data URI, or local path) | str (file) | Yes |
|
|
||||||
| script | Narration script text | str | Yes |
|
|
||||||
| voice_id | ElevenLabs voice ID | str | No |
|
|
||||||
| model_id | ElevenLabs TTS model | "eleven_multilingual_v2" \| "eleven_flash_v2_5" \| "eleven_turbo_v2_5" \| "eleven_turbo_v2" | No |
|
|
||||||
| mix_mode | How to combine with original audio. 'ducking' applies stronger attenuation than 'mix'. | "replace" \| "mix" \| "ducking" | No |
|
|
||||||
| narration_volume | Narration volume (0.0 to 2.0) | float | No |
|
|
||||||
| original_volume | Original audio volume when mixing (0.0 to 1.0) | float | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_out | Video with narration (path or data URI) | str (file) |
|
|
||||||
| audio_file | Generated audio file (path or data URI) | str (file) |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Adding professional voiceover to product demos or tutorials
|
|
||||||
- Creating narrated explainer videos from screen recordings
|
|
||||||
- Generating multi-language versions of video content
|
|
||||||
- Adding commentary to gameplay or walkthrough videos
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Video Text Overlay
|
|
||||||
<!-- MANUAL: file_description -->
|
|
||||||
This block adds customizable text captions or titles to videos, with control over positioning, timing, and styling.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
## Video Text Overlay
|
|
||||||
|
|
||||||
### What it is
|
|
||||||
Add text overlay/caption to video
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
<!-- MANUAL: how_it_works -->
|
|
||||||
The block uses MoviePy's TextClip and CompositeVideoClip to render text onto video frames. The text is created as a separate clip with configurable font size, color, and optional background color, then composited over the video at the specified position. Timing can be controlled to show text only during specific portions of the video. Position options include center alignments (top, center, bottom) and corner positions (top-left, top-right, bottom-left, bottom-right). The output is encoded with H.264 video codec and AAC audio codec.
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
| Input | Description | Type | Required |
|
|
||||||
|-------|-------------|------|----------|
|
|
||||||
| video_in | Input video (URL, data URI, or local path) | str (file) | Yes |
|
|
||||||
| text | Text to overlay on video | str | Yes |
|
|
||||||
| position | Position of text on screen | "top" \| "center" \| "bottom" \| "top-left" \| "top-right" \| "bottom-left" \| "bottom-right" | No |
|
|
||||||
| start_time | When to show text (seconds). None = entire video | float | No |
|
|
||||||
| end_time | When to hide text (seconds). None = until end | float | No |
|
|
||||||
| font_size | Font size | int | No |
|
|
||||||
| font_color | Font color (hex or name) | str | No |
|
|
||||||
| bg_color | Background color behind text (None for transparent) | str | No |
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
| Output | Description | Type |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| error | Error message if the operation failed | str |
|
|
||||||
| video_out | Video with text overlay (path or data URI) | str (file) |
|
|
||||||
|
|
||||||
### Possible use case
|
|
||||||
<!-- MANUAL: use_case -->
|
|
||||||
- Adding titles or chapter headings to video content
|
|
||||||
- Creating lower-thirds with speaker names or captions
|
|
||||||
- Watermarking videos with branding text
|
|
||||||
- Adding call-to-action text at specific moments in a video
|
|
||||||
<!-- END MANUAL -->
|
|
||||||
|
|
||||||
---
|
|
||||||
Reference in New Issue
Block a user