diff --git a/api/app/core/workflow/nodes/__init__.py b/api/app/core/workflow/nodes/__init__.py index 3744775d..33fe040c 100644 --- a/api/app/core/workflow/nodes/__init__.py +++ b/api/app/core/workflow/nodes/__init__.py @@ -8,10 +8,11 @@ from app.core.workflow.nodes.agent import AgentNode from app.core.workflow.nodes.assigner import AssignerNode from app.core.workflow.nodes.base_node import BaseNode, WorkflowState from app.core.workflow.nodes.end import EndNode +from app.core.workflow.nodes.http_request import HttpRequestNode from app.core.workflow.nodes.if_else import IfElseNode +from app.core.workflow.nodes.jinja_render import JinjaRenderNode from app.core.workflow.nodes.knowledge import KnowledgeRetrievalNode from app.core.workflow.nodes.llm import LLMNode -from app.core.workflow.nodes.http_request import HttpRequestNode from app.core.workflow.nodes.node_factory import NodeFactory, WorkflowNode from app.core.workflow.nodes.start import StartNode from app.core.workflow.nodes.transform import TransformNode @@ -29,5 +30,6 @@ __all__ = [ "WorkflowNode", "KnowledgeRetrievalNode", "AssignerNode", - "HttpRequestNode" + "HttpRequestNode", + "JinjaRenderNode", ] diff --git a/api/app/core/workflow/nodes/assigner/config.py b/api/app/core/workflow/nodes/assigner/config.py index 03302af4..d9721e99 100644 --- a/api/app/core/workflow/nodes/assigner/config.py +++ b/api/app/core/workflow/nodes/assigner/config.py @@ -30,3 +30,18 @@ class AssignerNodeConfig(BaseNodeConfig): ..., description="List of variable assignment definitions", ) + + class Config: + json_schema_extra = { + "examples": [ + { + "assignments": [ + { + "variable_selector": "{{ conv.test1 }}", + "operation": "add", + "value": "3" + } + ] + } + ] + } diff --git a/api/app/core/workflow/nodes/assigner/node.py b/api/app/core/workflow/nodes/assigner/node.py index b8b7c1f4..c174f52a 100644 --- a/api/app/core/workflow/nodes/assigner/node.py +++ b/api/app/core/workflow/nodes/assigner/node.py @@ -1,4 +1,5 @@ import logging +import re from typing import Any from app.core.workflow.expression_evaluator import ExpressionEvaluator @@ -34,7 +35,9 @@ class AssignerNode(BaseNode): variable_selector = assignment.variable_selector if isinstance(variable_selector, str): # Support dot-separated string paths, e.g., "conv.test" -> ["conv", "test"] - variable_selector = variable_selector.split('.') + pattern = r"\{\{\s*(.*?)\s*\}\}" + expression = re.sub(pattern, r"\1", variable_selector).strip() + variable_selector = expression.split('.') # Only conversation variables ('conv') are allowed if variable_selector[0] != 'conv': # TODO: Loop node variable support (Feature) diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index 44c92755..82f3d9b8 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -7,10 +7,12 @@ import asyncio import logging from abc import ABC, abstractmethod -from typing import Any, TypedDict, Annotated from operator import add -from langchain_core.messages import AnyMessage, HumanMessage, AIMessage +from typing import Any + +from langchain_core.messages import AnyMessage, AIMessage from langgraph.config import get_stream_writer +from typing_extensions import TypedDict, Annotated from app.core.workflow.variable_pool import VariablePool diff --git a/api/app/core/workflow/nodes/code/__init__.py b/api/app/core/workflow/nodes/code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/app/core/workflow/nodes/configs.py b/api/app/core/workflow/nodes/configs.py index f904740c..3a87c589 100644 --- a/api/app/core/workflow/nodes/configs.py +++ b/api/app/core/workflow/nodes/configs.py @@ -3,20 +3,21 @@ 所有节点的配置类都在这里导出,方便使用。 """ +from app.core.workflow.nodes.agent.config import AgentNodeConfig +from app.core.workflow.nodes.assigner.config import AssignerNodeConfig from app.core.workflow.nodes.base_config import ( BaseNodeConfig, VariableDefinition, VariableType, ) -from app.core.workflow.nodes.start.config import StartNodeConfig from app.core.workflow.nodes.end.config import EndNodeConfig -from app.core.workflow.nodes.llm.config import LLMNodeConfig, MessageConfig -from app.core.workflow.nodes.agent.config import AgentNodeConfig -from app.core.workflow.nodes.transform.config import TransformNodeConfig -from app.core.workflow.nodes.if_else.config import IfElseNodeConfig -from app.core.workflow.nodes.knowledge.config import KnowledgeRetrievalNodeConfig from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig -from app.core.workflow.nodes.assigner.config import AssignerNodeConfig +from app.core.workflow.nodes.if_else.config import IfElseNodeConfig +from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig +from app.core.workflow.nodes.knowledge.config import KnowledgeRetrievalNodeConfig +from app.core.workflow.nodes.llm.config import LLMNodeConfig, MessageConfig +from app.core.workflow.nodes.start.config import StartNodeConfig +from app.core.workflow.nodes.transform.config import TransformNodeConfig __all__ = [ # 基础类 @@ -33,5 +34,6 @@ __all__ = [ "IfElseNodeConfig", "KnowledgeRetrievalNodeConfig", "AssignerNodeConfig", - "HttpRequestNodeConfig" + "HttpRequestNodeConfig", + "JinjaRenderNodeConfig", ] diff --git a/api/app/core/workflow/nodes/enums.py b/api/app/core/workflow/nodes/enums.py index b33b64a3..40b9a7ef 100644 --- a/api/app/core/workflow/nodes/enums.py +++ b/api/app/core/workflow/nodes/enums.py @@ -24,6 +24,7 @@ class NodeType(StrEnum): TOOL = "tool" AGENT = "agent" ASSIGNER = "assigner" + JINJARENDER = "jinja-render" class ComparisonOperator(StrEnum): diff --git a/api/app/core/workflow/nodes/http_request/config.py b/api/app/core/workflow/nodes/http_request/config.py index 427b3c87..406d4d0e 100644 --- a/api/app/core/workflow/nodes/http_request/config.py +++ b/api/app/core/workflow/nodes/http_request/config.py @@ -188,6 +188,50 @@ class HttpRequestNodeConfig(BaseNodeConfig): description="Configuration for handling HTTP request errors", ) + class Config: + json_schema_extra = { + "examples": [ + { + "method": "GET", + "url": "{{sys.message}}", + "auth": { + "auth_type": "none", + "header": "", + "api_key": "" + }, + "headers": { + # "Content-Type": "application/json", + # "User-Agent": "Workflow-HttpNode/1.0" + }, + "params": {}, + "body": { + "content_type": "none", + "data": "" + }, + "verify_ssl": True, + "timeouts": { + "connect_timeout": 5, + "read_timeout": 30, + "write_timeout": 10 + }, + "retry": { + "max_attempts": 3, + "retry_interval": 500 + }, + "error_handle": { + "method": "default", + "default": { + "body": "Upstream service unavailable", + "status_code": 502, + "headers": { + "Content-Type": "text/plain" + } + } + } + } + ] + } + class HttpRequestNodeOutput(BaseModel): body: str = Field( diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index 63da6783..3b3a8b1a 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -7,7 +7,7 @@ import httpx # import filetypes # TODO: File support (Feature) from httpx import AsyncClient, Response, Timeout -from app.core.workflow.nodes import BaseNode, WorkflowState +from app.core.workflow.nodes.base_node import BaseNode, WorkflowState from app.core.workflow.nodes.enums import HttpRequestMethod, HttpErrorHandle, HttpAuthType, HttpContentType from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig, HttpRequestNodeOutput @@ -204,6 +204,7 @@ class HttpRequestNode(BaseNode): timeout=self._build_timeout(), headers=self._build_header(state) | self._build_auth(state), params=self._build_params(state), + follow_redirects=True ) as client: retries = self.typed_config.retry.max_attempts while retries > 0: diff --git a/api/app/core/workflow/nodes/if_else/node.py b/api/app/core/workflow/nodes/if_else/node.py index aedf0727..579c2840 100644 --- a/api/app/core/workflow/nodes/if_else/node.py +++ b/api/app/core/workflow/nodes/if_else/node.py @@ -158,10 +158,21 @@ class IfElseNode(BaseNode): async def execute(self, state: WorkflowState) -> Any: """ + Execute the conditional branching logic of the node. + + Evaluates the node's configured conditional expressions (expressions) in order. + Once a condition is satisfied, it returns the corresponding CASE identifier. + If none of the conditions match, it returns the default last CASE. + + Args: + state (WorkflowState): The current workflow state, containing variables, messages, node outputs, etc. + + Returns: + str: The matched branch identifier, e.g., 'CASE1', 'CASE2', ..., used for node transitions. """ expressions = self.build_conditional_edge_expressions() for i in range(len(expressions)): logger.info(expressions[i]) if self._evaluate_condition(expressions[i], state): - return f'CASE{i+1}' + return f'CASE{i + 1}' return f'CASE{len(expressions)}' diff --git a/api/app/core/workflow/nodes/jinja_render/__init__.py b/api/app/core/workflow/nodes/jinja_render/__init__.py new file mode 100644 index 00000000..49f467f5 --- /dev/null +++ b/api/app/core/workflow/nodes/jinja_render/__init__.py @@ -0,0 +1,4 @@ +from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig +from app.core.workflow.nodes.jinja_render.node import JinjaRenderNode + +__all__ = ["JinjaRenderNode", "JinjaRenderNodeConfig"] diff --git a/api/app/core/workflow/nodes/jinja_render/config.py b/api/app/core/workflow/nodes/jinja_render/config.py new file mode 100644 index 00000000..5bea2e46 --- /dev/null +++ b/api/app/core/workflow/nodes/jinja_render/config.py @@ -0,0 +1,24 @@ +from pydantic import Field, BaseModel +from app.core.workflow.nodes.base_config import BaseNodeConfig + + +class VariablesMappingConfig(BaseModel): + name: str = Field( + ..., + description="The variable name to be rendered" + ) + value: str = Field( + ..., + description="The corresponding value from the workflow" + ) + + +class JinjaRenderNodeConfig(BaseNodeConfig): + template: str = Field( + ..., + description="The Jinja2 template string to be rendered" + ) + mapping: list[VariablesMappingConfig] = Field( + ..., + description="Mapping configuration for variables used in the template" + ) diff --git a/api/app/core/workflow/nodes/jinja_render/node.py b/api/app/core/workflow/nodes/jinja_render/node.py new file mode 100644 index 00000000..60beefb6 --- /dev/null +++ b/api/app/core/workflow/nodes/jinja_render/node.py @@ -0,0 +1,45 @@ +from typing import Any + +from app.core.workflow.nodes import WorkflowState +from app.core.workflow.nodes.base_node import BaseNode +from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig +from app.core.workflow.template_renderer import TemplateRenderer + + +class JinjaRenderNode(BaseNode): + def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): + super().__init__(node_config, workflow_config) + self.typed_config = JinjaRenderNodeConfig(**self.config) + + async def execute(self, state: WorkflowState) -> Any: + """ + Execute the node: render the Jinja2 template with mapped variables. + + The rendered result is returned in a structure compatible with WorkflowState + merging, so that downstream nodes can access it via node_outputs. + + Args: + state (WorkflowState): Current workflow state containing variables, + node outputs, and runtime variables. + + Returns: + dict[str, Any]: Node output dictionary containing the rendered result + under `node_outputs[self.node_id]["output"]["rendered"]` and a + status flag "completed". + + Raises: + RuntimeError: If Jinja2 template rendering fails due to invalid template + syntax or missing variables. + """ + render = TemplateRenderer(strict=False) + + context = {} + for variable in self.typed_config.mapping: + context[variable.name] = self._render_template(variable.value, state) + + try: + res = render.env.from_string(self.typed_config.template).render(**context) + except Exception as e: + raise RuntimeError(f"JinjaRender Node {self.node_name} render failed: {e}") from e + + return res diff --git a/api/app/core/workflow/nodes/knowledge/config.py b/api/app/core/workflow/nodes/knowledge/config.py index 530116ff..09c23855 100644 --- a/api/app/core/workflow/nodes/knowledge/config.py +++ b/api/app/core/workflow/nodes/knowledge/config.py @@ -36,3 +36,19 @@ class KnowledgeRetrievalNodeConfig(BaseNodeConfig): default=RetrieveType.PARTICIPLE, description="Retrieve type" ) + + class Config: + json_schema_extra = { + "examples": [ + { + "query": "{{sys.message}}", + "kb_ids": [ + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + ], + "similarity_threshold": 0.2, + "vector_similarity_weight": 0.3, + "top_k": 1, + "retrieve_type": "hybrid" + } + ] + } diff --git a/api/app/core/workflow/nodes/knowledge/node.py b/api/app/core/workflow/nodes/knowledge/node.py index 10b877d8..319a0b88 100644 --- a/api/app/core/workflow/nodes/knowledge/node.py +++ b/api/app/core/workflow/nodes/knowledge/node.py @@ -163,5 +163,6 @@ class KnowledgeRetrievalNode(BaseNode): indices=indices, score_threshold=self.typed_config.similarity_threshold) # Deduplicate hybrid retrieval results - rs = self._deduplicate_docs(rs1, rs2) + unique_rs = self._deduplicate_docs(rs1, rs2) + rs = vector_service.rerank(query=query, docs=unique_rs, top_k=self.typed_config.top_k) return [chunk.model_dump() for chunk in rs] diff --git a/api/app/core/workflow/nodes/node_factory.py b/api/app/core/workflow/nodes/node_factory.py index 98eafbf5..a6d735d0 100644 --- a/api/app/core/workflow/nodes/node_factory.py +++ b/api/app/core/workflow/nodes/node_factory.py @@ -7,17 +7,18 @@ import logging from typing import Any, Union -from app.core.workflow.nodes.knowledge import KnowledgeRetrievalNode -from app.core.workflow.nodes.http_request import HttpRequestNode from app.core.workflow.nodes.agent import AgentNode +from app.core.workflow.nodes.assigner import AssignerNode from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.end import EndNode from app.core.workflow.nodes.enums import NodeType +from app.core.workflow.nodes.http_request import HttpRequestNode from app.core.workflow.nodes.if_else import IfElseNode +from app.core.workflow.nodes.jinja_render import JinjaRenderNode +from app.core.workflow.nodes.knowledge import KnowledgeRetrievalNode from app.core.workflow.nodes.llm import LLMNode from app.core.workflow.nodes.start import StartNode from app.core.workflow.nodes.transform import TransformNode -from app.core.workflow.nodes.assigner import AssignerNode logger = logging.getLogger(__name__) @@ -32,6 +33,7 @@ WorkflowNode = Union[ AssignerNode, HttpRequestNode, KnowledgeRetrievalNode, + JinjaRenderNode, ] @@ -52,6 +54,7 @@ class NodeFactory: NodeType.KNOWLEDGE_RETRIEVAL: KnowledgeRetrievalNode, NodeType.ASSIGNER: AssignerNode, NodeType.HTTP_REQUEST: HttpRequestNode, + NodeType.JINJARENDER: JinjaRenderNode, } @classmethod diff --git a/api/app/core/workflow/template_renderer.py b/api/app/core/workflow/template_renderer.py index b927bd98..df6053b0 100644 --- a/api/app/core/workflow/template_renderer.py +++ b/api/app/core/workflow/template_renderer.py @@ -7,7 +7,7 @@ import logging from typing import Any -from jinja2 import Template, TemplateSyntaxError, UndefinedError, Environment, StrictUndefined +from jinja2 import TemplateSyntaxError, UndefinedError, Environment, StrictUndefined, Undefined logger = logging.getLogger(__name__) @@ -22,7 +22,7 @@ class TemplateRenderer: strict: 是否使用严格模式(未定义变量会抛出异常) """ self.env = Environment( - undefined=StrictUndefined if strict else None, + undefined=StrictUndefined if strict else Undefined, autoescape=False # 不自动转义,因为我们处理的是文本而非 HTML )