mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-16 15:30:59 +00:00
cold
This commit is contained in:
@@ -62,16 +62,24 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||||
|
|
||||||
Returns ``(None, None)`` when the bot does not exist, is not a
|
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||||
``web_page_bot``, is disabled, or has no pipeline bound.
|
``web_page_bot``, is disabled, or has no pipeline/workflow bound.
|
||||||
"""
|
"""
|
||||||
for bot in self.ap.platform_mgr.bots:
|
for bot in self.ap.platform_mgr.bots:
|
||||||
if (
|
if (
|
||||||
bot.bot_entity.uuid == bot_uuid
|
bot.bot_entity.uuid == bot_uuid
|
||||||
and bot.bot_entity.adapter == 'web_page_bot'
|
and bot.bot_entity.adapter == 'web_page_bot'
|
||||||
and bot.bot_entity.enable
|
and bot.bot_entity.enable
|
||||||
and bot.bot_entity.use_pipeline_uuid
|
|
||||||
):
|
):
|
||||||
return bot, bot.bot_entity.use_pipeline_uuid
|
# Check for workflow binding first
|
||||||
|
binding_type = getattr(bot.bot_entity, 'binding_type', 'pipeline') or 'pipeline'
|
||||||
|
binding_uuid = getattr(bot.bot_entity, 'binding_uuid', None)
|
||||||
|
|
||||||
|
if binding_type == 'workflow' and binding_uuid:
|
||||||
|
# For workflow binding, return workflow UUID
|
||||||
|
return bot, binding_uuid
|
||||||
|
elif bot.bot_entity.use_pipeline_uuid:
|
||||||
|
# For pipeline binding, return pipeline UUID
|
||||||
|
return bot, bot.bot_entity.use_pipeline_uuid
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def _get_bot_config(self, bot_uuid: str) -> dict:
|
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
elif quart.request.method == 'DELETE':
|
elif quart.request.method == 'DELETE':
|
||||||
await self.ap.workflow_service.delete_workflow(workflow_uuid)
|
await self.ap.workflow_service.delete_workflow(workflow_uuid)
|
||||||
return self.success()
|
return self.success()
|
||||||
|
return self.http_status(405, -1, 'method not allowed')
|
||||||
|
|
||||||
# Publish workflow (enable)
|
# Publish workflow (enable)
|
||||||
@self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<workflow_uuid>/publish', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
@@ -193,6 +194,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
return self.success()
|
return self.success()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return self.http_status(404, -1, str(e))
|
return self.http_status(404, -1, str(e))
|
||||||
|
return self.http_status(405, -1, 'method not allowed')
|
||||||
|
|
||||||
# Debug API - Start debug execution
|
# Debug API - Start debug execution
|
||||||
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<workflow_uuid>/debug/start', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ Debug execution support has been moved to the ``debug`` module.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import operator
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional, TYPE_CHECKING
|
from typing import Any, Optional, TYPE_CHECKING
|
||||||
@@ -32,6 +31,7 @@ from .entities import (
|
|||||||
)
|
)
|
||||||
from ..entity.persistence import workflow as persistence_workflow
|
from ..entity.persistence import workflow as persistence_workflow
|
||||||
from .registry import NodeTypeRegistry
|
from .registry import NodeTypeRegistry
|
||||||
|
from .safe_eval import safe_eval_with_vars
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..core import app
|
from ..core import app
|
||||||
@@ -39,108 +39,6 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ─── Safe expression evaluator (replaces eval()) ─────────────────────
|
|
||||||
# Uses Python's ast module to whitelist only comparison / boolean / arithmetic
|
|
||||||
# operations. No function calls, attribute access, or subscript injection.
|
|
||||||
|
|
||||||
_SAFE_OPS = {
|
|
||||||
ast.Add: operator.add,
|
|
||||||
ast.Sub: operator.sub,
|
|
||||||
ast.Mult: operator.mul,
|
|
||||||
ast.Div: operator.truediv,
|
|
||||||
ast.FloorDiv: operator.floordiv,
|
|
||||||
ast.Mod: operator.mod,
|
|
||||||
ast.Pow: operator.pow,
|
|
||||||
ast.USub: operator.neg,
|
|
||||||
ast.UAdd: operator.pos,
|
|
||||||
ast.Not: operator.not_,
|
|
||||||
ast.Eq: operator.eq,
|
|
||||||
ast.NotEq: operator.ne,
|
|
||||||
ast.Lt: operator.lt,
|
|
||||||
ast.LtE: operator.le,
|
|
||||||
ast.Gt: operator.gt,
|
|
||||||
ast.GtE: operator.ge,
|
|
||||||
ast.Is: operator.is_,
|
|
||||||
ast.IsNot: operator.is_not,
|
|
||||||
ast.In: lambda a, b: a in b,
|
|
||||||
ast.NotIn: lambda a, b: a not in b,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_eval(expr: str) -> Any:
|
|
||||||
"""Evaluate a simple expression safely via AST whitelist.
|
|
||||||
|
|
||||||
Supports: literals, comparisons (==, !=, <, >, <=, >=, in, not in, is, is not),
|
|
||||||
boolean logic (and, or, not), arithmetic (+, -, *, /, //, %, **), and
|
|
||||||
string operations (contains via ``in``).
|
|
||||||
|
|
||||||
Raises ``ValueError`` on any disallowed construct (function calls,
|
|
||||||
attribute access, imports, etc.).
|
|
||||||
"""
|
|
||||||
tree = ast.parse(expr.strip(), mode='eval')
|
|
||||||
return _eval_node(tree.body)
|
|
||||||
|
|
||||||
|
|
||||||
def _eval_node(node: ast.AST) -> Any:
|
|
||||||
# Literals: numbers, strings, True/False/None
|
|
||||||
if isinstance(node, ast.Constant):
|
|
||||||
return node.value
|
|
||||||
|
|
||||||
# Unary operators: -x, +x, not x
|
|
||||||
if isinstance(node, ast.UnaryOp):
|
|
||||||
op_fn = _SAFE_OPS.get(type(node.op))
|
|
||||||
if op_fn is None:
|
|
||||||
raise ValueError(f'Unsupported unary op: {type(node.op).__name__}')
|
|
||||||
return op_fn(_eval_node(node.operand))
|
|
||||||
|
|
||||||
# Binary operators: x + y, x * y, etc.
|
|
||||||
if isinstance(node, ast.BinOp):
|
|
||||||
op_fn = _SAFE_OPS.get(type(node.op))
|
|
||||||
if op_fn is None:
|
|
||||||
raise ValueError(f'Unsupported binary op: {type(node.op).__name__}')
|
|
||||||
return op_fn(_eval_node(node.left), _eval_node(node.right))
|
|
||||||
|
|
||||||
# Comparisons: x == y, x > y, x in y, etc. (chained)
|
|
||||||
if isinstance(node, ast.Compare):
|
|
||||||
left = _eval_node(node.left)
|
|
||||||
for op, comparator in zip(node.ops, node.comparators):
|
|
||||||
op_fn = _SAFE_OPS.get(type(op))
|
|
||||||
if op_fn is None:
|
|
||||||
raise ValueError(f'Unsupported comparison: {type(op).__name__}')
|
|
||||||
right = _eval_node(comparator)
|
|
||||||
if not op_fn(left, right):
|
|
||||||
return False
|
|
||||||
left = right
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Boolean operators: x and y, x or y
|
|
||||||
if isinstance(node, ast.BoolOp):
|
|
||||||
if isinstance(node.op, ast.And):
|
|
||||||
return all(_eval_node(v) for v in node.values)
|
|
||||||
if isinstance(node.op, ast.Or):
|
|
||||||
return any(_eval_node(v) for v in node.values)
|
|
||||||
|
|
||||||
# Ternary: x if cond else y
|
|
||||||
if isinstance(node, ast.IfExp):
|
|
||||||
return _eval_node(node.body) if _eval_node(node.test) else _eval_node(node.orelse)
|
|
||||||
|
|
||||||
# Tuples / Lists (used in "x in [1,2,3]")
|
|
||||||
if isinstance(node, (ast.Tuple, ast.List)):
|
|
||||||
return [_eval_node(e) for e in node.elts]
|
|
||||||
|
|
||||||
# Name lookup – only allow None, True, False
|
|
||||||
if isinstance(node, ast.Name):
|
|
||||||
if node.id == 'None':
|
|
||||||
return None
|
|
||||||
if node.id == 'True':
|
|
||||||
return True
|
|
||||||
if node.id == 'False':
|
|
||||||
return False
|
|
||||||
raise ValueError(f'Unsupported variable reference: {node.id}')
|
|
||||||
|
|
||||||
raise ValueError(f'Unsupported expression node: {type(node).__name__}')
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowExecutor:
|
class WorkflowExecutor:
|
||||||
"""
|
"""
|
||||||
Workflow execution engine.
|
Workflow execution engine.
|
||||||
@@ -169,10 +67,6 @@ class WorkflowExecutor:
|
|||||||
context.status = ExecutionStatus.RUNNING
|
context.status = ExecutionStatus.RUNNING
|
||||||
context.start_time = datetime.now()
|
context.start_time = datetime.now()
|
||||||
|
|
||||||
# Note: Frontend panel logging has been removed.
|
|
||||||
# A new solution will be implemented separately.
|
|
||||||
monitoring_message_id = ''
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build execution graph
|
# Build execution graph
|
||||||
node_map = {node.id: node for node in workflow.nodes}
|
node_map = {node.id: node for node in workflow.nodes}
|
||||||
@@ -575,42 +469,40 @@ class WorkflowExecutor:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool:
|
async def _evaluate_condition(self, condition: str, context: ExecutionContext) -> bool:
|
||||||
"""Evaluate a condition expression safely using AST whitelist"""
|
"""Evaluate a condition expression safely.
|
||||||
try:
|
|
||||||
# Resolve variable references in condition
|
|
||||||
if '{{' in condition:
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
Any ``{{ ... }}`` references are resolved against the execution context
|
||||||
|
and bound as **variables** that are passed to :func:`safe_eval_with_vars`.
|
||||||
|
Values are never string-concatenated into the expression, which avoids
|
||||||
|
broken parsing (e.g. values containing quotes) and any injection risk
|
||||||
|
from non-literal value types (lists, dicts, etc.).
|
||||||
|
"""
|
||||||
|
variables: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
# Resolve variable references in condition into bound variables.
|
||||||
|
if '{{' in condition:
|
||||||
pattern = r'\{\{([^}]+)\}\}'
|
pattern = r'\{\{([^}]+)\}\}'
|
||||||
|
|
||||||
# First pass: replace all variable references with placeholders
|
placeholders: dict[str, str] = {}
|
||||||
placeholders = {}
|
|
||||||
placeholder_idx = 0
|
placeholder_idx = 0
|
||||||
|
|
||||||
def replace_with_placeholder(match):
|
def replace_with_placeholder(match: re.Match[str]) -> str:
|
||||||
nonlocal placeholder_idx
|
nonlocal placeholder_idx
|
||||||
var_expr = match.group(1)
|
var_expr = match.group(1)
|
||||||
placeholder = f'__PH{placeholder_idx}__'
|
placeholder = f'__ph{placeholder_idx}__'
|
||||||
placeholders[placeholder] = var_expr
|
placeholders[placeholder] = var_expr
|
||||||
placeholder_idx += 1
|
placeholder_idx += 1
|
||||||
return placeholder
|
return placeholder
|
||||||
|
|
||||||
condition_with_placeholders = re.sub(pattern, replace_with_placeholder, condition)
|
condition = re.sub(pattern, replace_with_placeholder, condition)
|
||||||
|
|
||||||
# Second pass: resolve each placeholder asynchronously
|
# Resolve each placeholder and bind it as a variable, so the
|
||||||
|
# actual value (of any type) is passed through unchanged.
|
||||||
for placeholder, var_expr in placeholders.items():
|
for placeholder, var_expr in placeholders.items():
|
||||||
value = await self._resolve_expression(var_expr, context)
|
variables[placeholder] = await self._resolve_expression(var_expr, context)
|
||||||
if isinstance(value, str):
|
|
||||||
condition_with_placeholders = condition_with_placeholders.replace(placeholder, f'"{value}"')
|
|
||||||
elif value is None:
|
|
||||||
condition_with_placeholders = condition_with_placeholders.replace(placeholder, 'None')
|
|
||||||
else:
|
|
||||||
condition_with_placeholders = condition_with_placeholders.replace(placeholder, str(value))
|
|
||||||
|
|
||||||
condition = condition_with_placeholders
|
# Safe expression evaluation with bound variables (AST whitelist).
|
||||||
|
result = safe_eval_with_vars(condition, variables)
|
||||||
# Safe expression evaluation using AST whitelist
|
|
||||||
result = _safe_eval(condition)
|
|
||||||
return bool(result)
|
return bool(result)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -753,8 +645,13 @@ class ParallelExecutor:
|
|||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
processed_results = []
|
processed_results = []
|
||||||
for result in results:
|
for index, result in enumerate(results):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
|
logger.error(
|
||||||
|
f'Parallel branch {index} failed: {result}',
|
||||||
|
exc_info=result,
|
||||||
|
extra={'branch_index': index, 'execution_id': context.execution_id},
|
||||||
|
)
|
||||||
processed_results.append({'error': str(result)})
|
processed_results.append({'error': str(result)})
|
||||||
else:
|
else:
|
||||||
processed_results.append(result)
|
processed_results.append(result)
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# """Plugin Call Node - invoke a plugin
|
|
||||||
|
|
||||||
# Node metadata is loaded from: ../../templates/metadata/nodes/plugin_call.yaml
|
|
||||||
# """
|
|
||||||
|
|
||||||
# from __future__ import annotations
|
|
||||||
|
|
||||||
# from typing import Any
|
|
||||||
|
|
||||||
# from langbot_plugin.api.entities.builtin.workflow.entities import ExecutionContext
|
|
||||||
# from ..node import WorkflowNode, workflow_node
|
|
||||||
|
|
||||||
# @workflow_node('plugin_call')
|
|
||||||
# class PluginCallNode(WorkflowNode):
|
|
||||||
# """Plugin call node - invoke a plugin"""
|
|
||||||
|
|
||||||
# type_name = "plugin_call"
|
|
||||||
# category = "action"
|
|
||||||
# icon = "🔌"
|
|
||||||
# name = "plugin_call"
|
|
||||||
# description = "plugin_call"
|
|
||||||
|
|
||||||
# inputs: ClassVar[list[NodePort]] = []
|
|
||||||
# outputs: ClassVar[list[NodePort]] = []
|
|
||||||
# config_schema: ClassVar[list[NodeConfig]] = []
|
|
||||||
|
|
||||||
# async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
|
||||||
# plugin_name = self.get_config("plugin_name", "")
|
|
||||||
# method_name = self.get_config("method_name", "")
|
|
||||||
# arguments = inputs.get("arguments", {})
|
|
||||||
|
|
||||||
# return {
|
|
||||||
# "result": None,
|
|
||||||
# "success": False,
|
|
||||||
# "error": f"Plugin call '{plugin_name}/{method_name}' not implemented yet",
|
|
||||||
# "_debug": {
|
|
||||||
# "plugin_name": plugin_name,
|
|
||||||
# "method_name": method_name,
|
|
||||||
# "arguments": arguments,
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
"""Tests for the safe expression evaluator that replaced eval()."""
|
"""Tests for the safe expression evaluator that replaced eval().
|
||||||
|
|
||||||
|
The workflow engine now uses :func:`safe_eval_with_vars` everywhere (the old
|
||||||
|
``executor._safe_eval`` helper was removed to eliminate duplication). These
|
||||||
|
tests exercise that single, shared evaluator.
|
||||||
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -7,7 +12,12 @@ import pytest
|
|||||||
# Add project root to path
|
# Add project root to path
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src'))
|
||||||
|
|
||||||
from langbot.pkg.workflow.executor import _safe_eval
|
from langbot.pkg.workflow.safe_eval import safe_eval_with_vars
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_eval(expr):
|
||||||
|
"""Backwards-compatible shim: evaluate an expression with no bound vars."""
|
||||||
|
return safe_eval_with_vars(expr)
|
||||||
|
|
||||||
|
|
||||||
class TestSafeEvalLiterals:
|
class TestSafeEvalLiterals:
|
||||||
@@ -147,8 +157,39 @@ class TestSafeEvalArithmetic:
|
|||||||
assert _safe_eval("1 + 2 == 3") is True
|
assert _safe_eval("1 + 2 == 3") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeEvalVariables:
|
||||||
|
"""Test variable binding (the feature that justified the unification)."""
|
||||||
|
|
||||||
|
def test_bound_variable(self):
|
||||||
|
assert safe_eval_with_vars("x + 1", {"x": 41}) == 42
|
||||||
|
|
||||||
|
def test_bound_string_compare(self):
|
||||||
|
assert safe_eval_with_vars('input == "hello"', {"input": "hello"}) is True
|
||||||
|
|
||||||
|
def test_bound_dict_attribute(self):
|
||||||
|
assert safe_eval_with_vars("data.name == 'a'", {"data": {"name": "a"}}) is True
|
||||||
|
|
||||||
|
def test_bound_subscript(self):
|
||||||
|
assert safe_eval_with_vars('data["k"] > 3', {"data": {"k": 5}}) is True
|
||||||
|
|
||||||
|
def test_value_with_quotes_does_not_break(self):
|
||||||
|
# The whole point of binding variables instead of string-concatenation:
|
||||||
|
# a value containing a double quote must not corrupt parsing.
|
||||||
|
assert safe_eval_with_vars('v == val', {"v": 'a"b', "val": 'a"b'}) is True
|
||||||
|
|
||||||
|
def test_unbound_variable_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
safe_eval_with_vars("x + 1", {})
|
||||||
|
|
||||||
|
|
||||||
class TestSafeEvalSecurity:
|
class TestSafeEvalSecurity:
|
||||||
"""Ensure dangerous constructs are rejected."""
|
"""Ensure dangerous constructs are rejected.
|
||||||
|
|
||||||
|
The shared evaluator rejects **all function calls**, which blocks every
|
||||||
|
known sandbox-escape vector (``__import__``, ``exec``, ``eval``,
|
||||||
|
``__subclasses__()`` chains, etc.). Bare attribute/subscript *reads* are
|
||||||
|
permitted but are inert without a call, so they cannot escalate.
|
||||||
|
"""
|
||||||
|
|
||||||
def test_import_blocked(self):
|
def test_import_blocked(self):
|
||||||
with pytest.raises((ValueError, SyntaxError)):
|
with pytest.raises((ValueError, SyntaxError)):
|
||||||
@@ -162,14 +203,6 @@ class TestSafeEvalSecurity:
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
_safe_eval('open("/etc/passwd")')
|
_safe_eval('open("/etc/passwd")')
|
||||||
|
|
||||||
def test_attribute_access_blocked(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_safe_eval('"hello".__class__')
|
|
||||||
|
|
||||||
def test_subscript_blocked(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_safe_eval('[1,2,3][0]')
|
|
||||||
|
|
||||||
def test_class_subclasses_blocked(self):
|
def test_class_subclasses_blocked(self):
|
||||||
with pytest.raises((ValueError, SyntaxError)):
|
with pytest.raises((ValueError, SyntaxError)):
|
||||||
_safe_eval('().__class__.__subclasses__()')
|
_safe_eval('().__class__.__subclasses__()')
|
||||||
@@ -186,6 +219,11 @@ class TestSafeEvalSecurity:
|
|||||||
with pytest.raises((ValueError, SyntaxError)):
|
with pytest.raises((ValueError, SyntaxError)):
|
||||||
_safe_eval('lambda: 1')
|
_safe_eval('lambda: 1')
|
||||||
|
|
||||||
def test_variable_reference_blocked(self):
|
def test_any_call_blocked(self):
|
||||||
|
# Even a call on an otherwise-safe attribute read must be rejected.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_safe_eval('"hello".upper()')
|
||||||
|
|
||||||
|
def test_unknown_variable_reference_blocked(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
_safe_eval('x + 1')
|
_safe_eval('x + 1')
|
||||||
|
|||||||
@@ -177,3 +177,7 @@
|
|||||||
transform: scale(0.95) rotate(-4deg);
|
transform: scale(0.95) rotate(-4deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-flow__attribution {
|
||||||
|
opacity: 0.2 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -358,7 +358,10 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
<Upload className="size-4 mr-1" />
|
<Upload className="size-4 mr-1" />
|
||||||
{t('workflows.import')}
|
{t('workflows.import')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isSaving || !basicInfo.name.trim()}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !basicInfo.name.trim()}
|
||||||
|
>
|
||||||
{isSaving ? t('common.saving') : t('common.create')}
|
{isSaving ? t('common.saving') : t('common.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export default function WorkflowDebugger({
|
|||||||
const [activeTab, setActiveTab] = useState<string>('context');
|
const [activeTab, setActiveTab] = useState<string>('context');
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const [newVariable, setNewVariable] = useState({ key: '', value: '' });
|
const [newVariable, setNewVariable] = useState({ key: '', value: '' });
|
||||||
const [, setExpandedNodes] = useState<Set<string>>(new Set());
|
|
||||||
const pollCancelledRef = useRef(false);
|
const pollCancelledRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -107,7 +106,6 @@ export default function WorkflowDebugger({
|
|||||||
clearDebugLogs,
|
clearDebugLogs,
|
||||||
setDebugContext,
|
setDebugContext,
|
||||||
resetDebugContext,
|
resetDebugContext,
|
||||||
addWatchedVariable,
|
|
||||||
removeWatchedVariable,
|
removeWatchedVariable,
|
||||||
resetDebugState,
|
resetDebugState,
|
||||||
} = useWorkflowStore();
|
} = useWorkflowStore();
|
||||||
@@ -311,19 +309,6 @@ export default function WorkflowDebugger({
|
|||||||
}
|
}
|
||||||
}, [workflowId, debugExecutionId, t, resetDebugState]);
|
}, [workflowId, debugExecutionId, t, resetDebugState]);
|
||||||
|
|
||||||
// Toggle node expansion
|
|
||||||
const toggleNodeExpanded = (nodeId: string) => {
|
|
||||||
setExpandedNodes((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (newSet.has(nodeId)) {
|
|
||||||
newSet.delete(nodeId);
|
|
||||||
} else {
|
|
||||||
newSet.add(nodeId);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add custom variable
|
// Add custom variable
|
||||||
const handleAddVariable = () => {
|
const handleAddVariable = () => {
|
||||||
if (newVariable.key.trim()) {
|
if (newVariable.key.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user