diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a448b6d..7c0b6a9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -68,6 +68,8 @@ services: build: context: . dockerfile: Dockerfile.lfx + env_file: + - ../.env volumes: - lfx-data:/flows ports: diff --git a/.env.example b/.env.example index 6623210..05f1325 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ LANGFLOW_DATABASE_URL=postgresql://kirche:@db:5432/langflow # --- lfx (flow runtime) --- LFX_URL=http://lfx:6004 +LFX_API_KEY=lfx- + +# --- API self-reference for flow callbacks (must be reachable FROM lfx) --- +CALLBACK_BASE_URL=http://app:8000 # --- LLM (optional; tests mock the provider without it) --- # LLM_API_KEY= diff --git a/backend/app/flows/__init__.py b/backend/app/flows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py new file mode 100644 index 0000000..10eb03c --- /dev/null +++ b/backend/app/flows/api.py @@ -0,0 +1,311 @@ +"""Flows + approvals HTTP API.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Iterator +from uuid import UUID + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlmodel import Session + +from app.db import get_engine, get_session +from app.settings import settings + +from . import runner, store +from .lfx_client import ( + LfxClient, + LfxConflict, + LfxError, + LfxInvalidFlow, + LfxNotFound, + LfxRunFailed, + LfxUnavailable, +) +from .schemas import ( + ApprovalCreate, + ApprovalRead, + FlowDefinitionCreate, + FlowDefinitionRead, + FlowVersionCreate, + FlowVersionRead, + RunRead, + RunRequest, +) + +flows_router = APIRouter(prefix="/api/flows", tags=["flows"]) +approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"]) + + +def get_db() -> Iterator[Session]: + yield from get_session() + + +def get_lfx() -> LfxClient: + return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) + + +def _read_definition(d) -> FlowDefinitionRead: + return FlowDefinitionRead( + id=d.id, + name=d.name, + description=d.description, + status=d.status, + continuation_flow_id=d.continuation_flow_id, + created_at=d.created_at, + ) + + +def _read_version(v) -> FlowVersionRead: + return FlowVersionRead( + id=v.id, + flow_id=v.flow_id, + version_no=v.version_no, + langflow_json=v.langflow_json, + lfx_flow_id=v.lfx_flow_id, + deployed_at=v.deployed_at, + author=v.author, + created_at=v.created_at, + ) + + +def _read_run(r) -> RunRead: + return RunRead( + id=r.id, + version_id=r.version_id, + tenant_id=r.tenant_id, + status=r.status, + input=r.input, + output=r.output, + session_id=r.session_id, + created_at=r.created_at, + finished_at=r.finished_at, + ) + + +def _read_task(t) -> ApprovalRead: + return ApprovalRead( + id=t.id, + run_id=t.run_id, + version_id=t.version_id, + tenant_id=t.tenant_id, + title=t.title, + payload=t.payload, + continuation_flow_id=t.continuation_flow_id, + approver=t.approver, + status=t.status, + decision_note=t.decision_note, + created_at=t.created_at, + decided_at=t.decided_at, + ) + + +@flows_router.post("", response_model=FlowDefinitionRead, status_code=201) +def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)): + try: + return _read_definition(store.create_definition(session, data)) + except store.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except store.InvalidError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@flows_router.get("", response_model=list[FlowDefinitionRead]) +def list_flows(session: Session = Depends(get_db)): + return [_read_definition(d) for d in store.list_definitions(session)] + + +@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead) +def get_flow(flow_id: UUID, session: Session = Depends(get_db)): + try: + return _read_definition(store.get_definition(session, flow_id)) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@flows_router.post( + "/{flow_id}/versions", response_model=FlowVersionRead, status_code=201 +) +def create_version( + flow_id: UUID, data: FlowVersionCreate, session: Session = Depends(get_db) +): + try: + return _read_version(store.create_version(session, flow_id, data)) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except store.InvalidError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead]) +def list_versions(flow_id: UUID, session: Session = Depends(get_db)): + try: + return [_read_version(v) for v in store.list_versions(session, flow_id)] + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@flows_router.post( + "/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead +) +async def deploy_version( + flow_id: UUID, + version_no: int, + session: Session = Depends(get_db), + lfx: LfxClient = Depends(get_lfx), +): + try: + version = store.get_version_by_no(session, flow_id, version_no) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + try: + uploaded = await lfx.upload_flow( + version.langflow_json, flow_id=str(version.id), replace=True + ) + except LfxInvalidFlow as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except LfxConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except LfxUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except LfxError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return _read_version(store.mark_deployed(session, version, uploaded.id)) + + +def _resolve_version(session: Session, flow_id: UUID, version_no: int | None): + if version_no is not None: + return store.get_version_by_no(session, flow_id, version_no) + return store.get_latest_deployed_version(session, flow_id) + + +@flows_router.post("/{flow_id}/run", response_model=RunRead) +async def run_flow( + flow_id: UUID, + data: RunRequest, + stream: bool = Query(default=False), + session: Session = Depends(get_db), + lfx: LfxClient = Depends(get_lfx), +): + try: + version = _resolve_version(session, flow_id, data.version_no) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except store.InvalidError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if stream: + return await _run_stream(session, lfx, version, data) + try: + run = await runner.execute_run( + session, + lfx, + version, + payload=data.input, + tenant_id=data.tenant_id, + session_id=data.session_id, + callback_base_url=data.callback_base_url or settings.callback_base_url, + continuation_flow_id=data.continuation_flow_id, + ) + except LfxNotFound as exc: + raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc + except LfxUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except LfxError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return _read_run(run) + + +async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest): + if not version.lfx_flow_id: + raise HTTPException( + status_code=422, detail=f"version {version.id} is not deployed to LFX" + ) + run = store.create_run( + session, + version, + input=data.input, + tenant_id=data.tenant_id, + session_id=data.session_id, + ) + envelope = runner.build_envelope( + payload=data.input, + run_id=run.id, + callback_base_url=data.callback_base_url or settings.callback_base_url, + continuation_flow_id=data.continuation_flow_id, + ) + run_id = run.id + lfx_flow_id = version.lfx_flow_id + session_id = data.session_id or str(run.id) + + async def event_gen() -> AsyncIterator[str]: + yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n" + try: + async with httpx.AsyncClient( + base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers() + ) as client: + async with client.stream( + "POST", + f"/flows/{lfx_flow_id}/stream", + json={"input_value": envelope, "session_id": session_id}, + ) as response: + if response.status_code == 404: + raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") + response.raise_for_status() + async for chunk in response.aiter_text(): + yield chunk + except (LfxNotFound, httpx.HTTPError) as exc: + yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" + # Finalize from a fresh session: the request session may be closed. + with Session(get_engine()) as fresh: + try: + fresh_run = store.get_run(fresh, run_id) + status = ( + "pending_approval" + if runner._task_exists(fresh, run_id) + else "completed" + ) + store.set_run_result(fresh, fresh_run, status, {"streamed": True}) + except store.NotFoundError: + status = "failed" + yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n" + + return StreamingResponse(event_gen(), media_type="text/event-stream") + + +@flows_router.get("/{flow_id}/runs", response_model=list[RunRead]) +def list_runs(flow_id: UUID, session: Session = Depends(get_db)): + try: + return [_read_run(r) for r in store.list_runs(session, flow_id)] + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@flows_router.get("/runs/{run_id}", response_model=RunRead) +def get_run(run_id: UUID, session: Session = Depends(get_db)): + try: + return _read_run(store.get_run(session, run_id)) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@approvals_router.post("", response_model=ApprovalRead, status_code=201) +def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)): + """Flow callback contract: report a terminal approval state for a run.""" + try: + return _read_task(store.create_approval_task(session, data)) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except store.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except store.InvalidError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@approvals_router.get("", response_model=list[ApprovalRead]) +def list_approvals( + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_db), +): + return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)] diff --git a/backend/app/flows/lfx_client.py b/backend/app/flows/lfx_client.py new file mode 100644 index 0000000..8e4e1eb --- /dev/null +++ b/backend/app/flows/lfx_client.py @@ -0,0 +1,184 @@ +"""Async client for the LFX flow runtime (stateless `lfx serve` process).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + + +class LfxError(Exception): + """Base error for LFX communication problems.""" + + +class LfxNotFound(LfxError): + """Flow id unknown to LFX (HTTP 404).""" + + +class LfxInvalidFlow(LfxError): + """Flow JSON rejected by LFX (HTTP 422).""" + + +class LfxConflict(LfxError): + """Flow id already registered without replace=true (HTTP 409).""" + + +class LfxRunFailed(LfxError): + """Flow execution failed inside LFX (HTTP 500 or success=false).""" + + +class LfxUnavailable(LfxError): + """LFX process unreachable.""" + + +@dataclass +class LfxUploadResult: + id: str + name: str + description: str | None + run_url: str + + +@dataclass +class LfxRunResult: + result: str + success: bool + logs: str + type: str + component: str + + +class LfxClient: + """Thin async wrapper around the LFX serve HTTP API.""" + + def __init__( + self, base_url: str, timeout: float = 120.0, api_key: str | None = None + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.api_key = api_key + + def _headers(self) -> dict[str, str]: + return {"x-api-key": self.api_key} if self.api_key else {} + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self.base_url, timeout=self.timeout, headers=self._headers() + ) + + async def health(self) -> dict: + try: + async with self._client() as client: + response = await client.get("/health") + response.raise_for_status() + return response.json() + except httpx.HTTPError as exc: + raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc + + async def list_flows(self) -> list[dict]: + try: + async with self._client() as client: + response = await client.get("/flows") + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise self._mapped(exc) from exc + except httpx.HTTPError as exc: + raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc + + async def upload_flow( + self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False + ) -> LfxUploadResult: + """Register a Langflow-export-format flow JSON. Returns the LFX flow id.""" + data = flow_json.get("data") + if not isinstance(data, dict): + raise LfxInvalidFlow("flow JSON must contain a 'data' object") + body = { + "name": flow_json.get("name", "flow"), + "description": flow_json.get("description"), + "data": data, + "replace": replace, + } + if flow_id is not None: + body["id"] = flow_id + try: + async with self._client() as client: + response = await client.post("/flows/upload/", json=body) + response.raise_for_status() + payload = response.json() + return LfxUploadResult( + id=payload["id"], + name=payload.get("name", ""), + description=payload.get("description"), + run_url=payload.get("run_url", ""), + ) + except httpx.HTTPStatusError as exc: + raise self._mapped(exc) from exc + except httpx.HTTPError as exc: + raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc + + async def run_flow( + self, + lfx_flow_id: str, + *, + input_value: str, + session_id: str | None = None, + global_vars: dict[str, str] | None = None, + ) -> LfxRunResult: + """Execute a flow synchronously; raises on failure.""" + body: dict = {"input_value": input_value} + if session_id is not None: + body["session_id"] = session_id + if global_vars is not None: + body["global_vars"] = global_vars + try: + async with self._client() as client: + response = await client.post(f"/flows/{lfx_flow_id}/run", json=body) + if response.status_code == 404: + raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") + data = response.json() + result = LfxRunResult( + result=data.get("result", ""), + success=bool(data.get("success", False)), + logs=data.get("logs", ""), + type=data.get("type", ""), + component=data.get("component", ""), + ) + if response.status_code >= 400 or not result.success: + raise LfxRunFailed(f"LFX run failed: {result.result}") + return result + except httpx.HTTPError as exc: + raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc + + async def flow_info(self, lfx_flow_id: str) -> dict: + try: + async with self._client() as client: + response = await client.get(f"/flows/{lfx_flow_id}/info") + if response.status_code == 404: + raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") + response.raise_for_status() + return response.json() + except httpx.HTTPError as exc: + raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc + + async def delete_flow(self, lfx_flow_id: str) -> None: + try: + async with self._client() as client: + response = await client.delete(f"/flows/{lfx_flow_id}") + if response.status_code == 404: + raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") + response.raise_for_status() + except httpx.HTTPError as exc: + raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc + + @staticmethod + def _mapped(exc: httpx.HTTPStatusError) -> LfxError: + status = exc.response.status_code + detail = exc.response.text[:500] + if status == 404: + return LfxNotFound(detail) + if status == 409: + return LfxConflict(detail) + if status == 422: + return LfxInvalidFlow(detail) + return LfxError(f"LFX error {status}: {detail}") diff --git a/backend/app/flows/runner.py b/backend/app/flows/runner.py new file mode 100644 index 0000000..0f032e1 --- /dev/null +++ b/backend/app/flows/runner.py @@ -0,0 +1,93 @@ +"""Run orchestration: our DB rows + LFX execution + terminal-state mapping.""" + +from __future__ import annotations + +import json +from uuid import UUID + +from sqlmodel import Session, select + +from app.models import ApprovalTask, FlowRun, FlowVersion + +from . import store +from .lfx_client import LfxClient, LfxRunFailed +from .store import InvalidError + + +def build_envelope( + *, + payload: dict, + run_id: UUID, + callback_base_url: str, + continuation_flow_id: UUID | None, +) -> str: + return json.dumps( + { + "payload": payload, + "run_id": str(run_id), + "callback_url": callback_base_url.rstrip("/"), + "continuation_flow_id": ( + str(continuation_flow_id) if continuation_flow_id else None + ), + } + ) + + +def _task_exists(session: Session, run_id: UUID) -> bool: + return ( + session.exec( + select(ApprovalTask).where( + ApprovalTask.run_id == run_id, ApprovalTask.status == "open" + ) + ).first() + is not None + ) + + +async def execute_run( + session: Session, + lfx: LfxClient, + version: FlowVersion, + *, + payload: dict, + tenant_id: str = "default", + session_id: str | None = None, + callback_base_url: str, + continuation_flow_id: UUID | None = None, +) -> FlowRun: + """Run a deployed version to a terminal state (no interrupts). + + The flow always terminates: either it completes, or its approval branch + POSTs back to /api/approvals (creating a task) and terminates with a + pending marker. Status is derived from that side effect, not from + result-text parsing. + """ + if not version.lfx_flow_id: + raise InvalidError(f"version {version.id} is not deployed to LFX") + run = store.create_run( + session, version, input=payload, tenant_id=tenant_id, session_id=session_id + ) + envelope = build_envelope( + payload=payload, + run_id=run.id, + callback_base_url=callback_base_url, + continuation_flow_id=continuation_flow_id, + ) + try: + result = await lfx.run_flow( + version.lfx_flow_id, + input_value=envelope, + session_id=session_id or str(run.id), + ) + except LfxRunFailed as exc: + return store.set_run_result( + session, run, "failed", {"error": str(exc)} + ) + output = { + "result": result.result, + "component": result.component, + "type": result.type, + } + if _task_exists(session, run.id): + return store.set_run_result(session, run, "pending_approval", output) + return store.set_run_result(session, run, "completed", output) diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py new file mode 100644 index 0000000..01eaedf --- /dev/null +++ b/backend/app/flows/schemas.py @@ -0,0 +1,86 @@ +"""Pydantic contracts for the flows API.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class FlowDefinitionCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: str = Field(default="", max_length=2000) + continuation_flow_id: UUID | None = None + + +class FlowDefinitionRead(BaseModel): + id: UUID + name: str + description: str + status: str + continuation_flow_id: UUID | None + created_at: datetime + + +class FlowVersionCreate(BaseModel): + langflow_json: dict + author: str = Field(default="", max_length=200) + + +class FlowVersionRead(BaseModel): + id: UUID + flow_id: UUID + version_no: int + langflow_json: dict + lfx_flow_id: str | None + deployed_at: datetime | None + author: str + created_at: datetime + + +class RunRequest(BaseModel): + """Start a flow run. `input` is the domain payload handed to the flow.""" + + input: dict = Field(default_factory=dict) + version_no: int | None = None + tenant_id: str = Field(default="default", max_length=200) + session_id: str | None = None + # Base URL the flow uses for callbacks (e.g. approval tasks). + # Defaults to settings.callback_base_url; tests point it at a live server. + callback_base_url: str | None = None + continuation_flow_id: UUID | None = None + + +class RunRead(BaseModel): + id: UUID + version_id: UUID + tenant_id: str + status: str + input: dict | None + output: dict | None + session_id: str | None + created_at: datetime + finished_at: datetime | None + + +class ApprovalCreate(BaseModel): + """Callback contract: a flow reports a terminal approval state.""" + + run_id: UUID + title: str = Field(default="", max_length=500) + payload: dict = Field(default_factory=dict) + continuation_flow_id: UUID | None = None + + +class ApprovalRead(BaseModel): + id: UUID + run_id: UUID + version_id: UUID + tenant_id: str + title: str + payload: dict + continuation_flow_id: UUID | None + approver: str | None + status: str + decision_note: str + created_at: datetime + decided_at: datetime | None diff --git a/backend/app/flows/store.py b/backend/app/flows/store.py new file mode 100644 index 0000000..ac9527c --- /dev/null +++ b/backend/app/flows/store.py @@ -0,0 +1,225 @@ +"""Persistence operations for flow definitions, versions, runs, and tasks.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, func, select + +from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion + +from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate + + +class NotFoundError(Exception): + pass + + +class ConflictError(Exception): + pass + + +class InvalidError(Exception): + pass + + +def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition: + if data.continuation_flow_id is not None and session.get( + FlowDefinition, data.continuation_flow_id + ) is None: + raise InvalidError("continuation_flow_id unknown") + definition = FlowDefinition( + name=data.name, + description=data.description, + continuation_flow_id=data.continuation_flow_id, + ) + session.add(definition) + try: + session.commit() + except IntegrityError as exc: + session.rollback() + raise ConflictError(f"flow {data.name!r} already exists") from exc + session.refresh(definition) + return definition + + +def get_definition(session: Session, flow_id: UUID) -> FlowDefinition: + definition = session.get(FlowDefinition, flow_id) + if definition is None: + raise NotFoundError(f"flow {flow_id} not found") + return definition + + +def list_definitions(session: Session) -> list[FlowDefinition]: + return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all()) + + +def _validate_flow_json(flow_json: dict) -> None: + if not isinstance(flow_json, dict): + raise InvalidError("langflow_json must be an object") + data = flow_json.get("data") + if not isinstance(data, dict): + raise InvalidError("langflow_json must contain a 'data' object") + if not isinstance(data.get("nodes"), list) or not isinstance(data.get("edges"), list): + raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists") + + +def create_version( + session: Session, flow_id: UUID, data: FlowVersionCreate +) -> FlowVersion: + get_definition(session, flow_id) + _validate_flow_json(data.langflow_json) + max_no = session.exec( + select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id) + ).one() + version = FlowVersion( + flow_id=flow_id, + version_no=(max_no or 0) + 1, + langflow_json=data.langflow_json, + author=data.author, + ) + session.add(version) + session.commit() + session.refresh(version) + return version + + +def get_version(session: Session, version_id: UUID) -> FlowVersion: + version = session.get(FlowVersion, version_id) + if version is None: + raise NotFoundError(f"flow version {version_id} not found") + return version + + +def list_versions(session: Session, flow_id: UUID) -> list[FlowVersion]: + get_definition(session, flow_id) + return list( + session.exec( + select(FlowVersion) + .where(FlowVersion.flow_id == flow_id) + .order_by(FlowVersion.version_no) + ).all() + ) + + +def get_version_by_no(session: Session, flow_id: UUID, version_no: int) -> FlowVersion: + version = session.exec( + select(FlowVersion).where( + FlowVersion.flow_id == flow_id, FlowVersion.version_no == version_no + ) + ).first() + if version is None: + raise NotFoundError(f"flow {flow_id} has no version {version_no}") + return version + + +def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion: + version = session.exec( + select(FlowVersion) + .where(FlowVersion.flow_id == flow_id, FlowVersion.deployed_at.is_not(None)) + .order_by(FlowVersion.version_no.desc()) + ).first() + if version is None: + raise InvalidError(f"flow {flow_id} has no deployed version") + return version + + +def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion: + version.lfx_flow_id = lfx_flow_id + version.deployed_at = datetime.now(timezone.utc) + session.add(version) + session.commit() + session.refresh(version) + return version + + +def create_run( + session: Session, + version: FlowVersion, + *, + input: dict | None, + tenant_id: str = "default", + session_id: str | None = None, +) -> FlowRun: + run = FlowRun( + version_id=version.id, + tenant_id=tenant_id, + input=input, + session_id=session_id or str(version.id), + ) + session.add(run) + session.commit() + session.refresh(run) + return run + + +def set_run_result( + session: Session, run: FlowRun, status: str, output: dict | None +) -> FlowRun: + run.status = status + run.output = output + run.finished_at = datetime.now(timezone.utc) + session.add(run) + session.commit() + session.refresh(run) + return run + + +def get_run(session: Session, run_id: UUID) -> FlowRun: + run = session.get(FlowRun, run_id) + if run is None: + raise NotFoundError(f"run {run_id} not found") + return run + + +def list_runs(session: Session, flow_id: UUID) -> list[FlowRun]: + get_definition(session, flow_id) + return list( + session.exec( + select(FlowRun) + .join(FlowVersion, FlowVersion.id == FlowRun.version_id) + .where(FlowVersion.flow_id == flow_id) + .order_by(FlowRun.created_at.desc()) + ).all() + ) + + +def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask: + run = session.get(FlowRun, data.run_id) + if run is None: + raise NotFoundError(f"run {data.run_id} not found") + if data.continuation_flow_id is not None and session.get( + FlowDefinition, data.continuation_flow_id + ) is None: + raise InvalidError("continuation_flow_id unknown") + existing = session.exec( + select(ApprovalTask).where( + ApprovalTask.run_id == data.run_id, ApprovalTask.status == "open" + ) + ).first() + if existing is not None: + raise ConflictError(f"run {data.run_id} already has an open approval task") + task = ApprovalTask( + run_id=run.id, + version_id=run.version_id, + tenant_id=run.tenant_id, + title=data.title, + payload=data.payload, + continuation_flow_id=data.continuation_flow_id, + ) + session.add(task) + run.status = "pending_approval" + run.finished_at = datetime.now(timezone.utc) + session.add(run) + session.commit() + session.refresh(task) + return task + + +def list_approval_tasks(session: Session, tenant_id: str | None = None) -> list[ApprovalTask]: + query = select(ApprovalTask).order_by(ApprovalTask.created_at.desc()) + if tenant_id is not None: + query = query.where(ApprovalTask.tenant_id == tenant_id) + return list(session.exec(query).all()) diff --git a/backend/app/main.py b/backend/app/main.py index 144ab98..99d3397 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI +from .flows.api import approvals_router, flows_router from .settings import settings app = FastAPI( @@ -11,6 +12,9 @@ app = FastAPI( openapi_url="/api/openapi.json", ) +app.include_router(flows_router) +app.include_router(approvals_router) + @app.get("/") async def root(): diff --git a/backend/app/settings.py b/backend/app/settings.py index d3a51f5..8f4b7f5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -21,7 +21,11 @@ class Settings(BaseSettings): langflow_url: str = "http://localhost:7860" langflow_api_key: str = "" lfx_url: str = "http://localhost:6004" + lfx_api_key: str = "" llm_api_key: str = "" + # Base URL flows use for callbacks into this API. Must be reachable + # FROM the LFX process (compose: http://app:8000). + callback_base_url: str = "http://localhost:8000" settings = Settings() diff --git a/backend/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py new file mode 100644 index 0000000..0fd6c45 --- /dev/null +++ b/backend/scripts/make_flow_fixtures.py @@ -0,0 +1,444 @@ +"""Generate Langflow-export-format flow JSON fixtures. + +Uses lfx's own serializers (Component.to_frontend_node / +build_custom_component_template), so the output is byte-faithful to what the +Langflow UI would export. Run with the backend venv (has the `lfx` dev dep):: + + uv run python scripts/make_flow_fixtures.py + +Then verify:: + + uv run lfx validate --level 4 backend/tests/fixtures/flows/ +""" + +from __future__ import annotations + +import importlib +import json +import sys +import uuid +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE_DIR = REPO_ROOT / "backend" / "tests" / "fixtures" / "flows" + +HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports + + +# -------------------------------------------------------------------------- +# node builders +# -------------------------------------------------------------------------- +def _normalize(node_dict: dict, node_id: str, comp_name: str, x: int, y: int) -> dict: + # to_frontend_node() returns an envelope {"data": {...}, "id": ...}; + # build_custom_component_template() returns the bare node dict. + if "data" not in node_dict: + node_dict = { + "data": {"node": node_dict, "type": comp_name, "id": node_id}, + "id": node_id, + } + else: + node_dict["id"] = node_id + node_dict["data"]["id"] = node_id + node_dict["data"]["type"] = comp_name + node_dict["data"]["showNode"] = True + node_dict["measured"] = {"width": 320, "height": 200} + node_dict["position"] = {"x": x, "y": y} + node_dict["type"] = "genericNode" + return node_dict + + +def builtin_node( + module_path: str, + class_name: str, + node_id: str, + values: dict | None = None, + position: tuple[int, int] = (0, 0), +) -> dict: + mod = importlib.import_module(module_path) + cls = getattr(mod, class_name) + inst = cls() + node_dict = _normalize(inst.to_frontend_node(), node_id, cls.name, *position) + for field, value in (values or {}).items(): + node_dict["data"]["node"]["template"][field]["value"] = value + return node_dict + + +def custom_node( + code: str, + node_id: str, + values: dict | None = None, + position: tuple[int, int] = (0, 0), +) -> dict: + from lfx.custom import validate as validate_mod + from lfx.custom.utils import build_custom_component_template + + cls = validate_mod.create_class(code, validate_mod.extract_class_name(code)) + inst = cls() + inst._code = code + bare, _ = build_custom_component_template(inst) + node_dict = _normalize(bare, node_id, "CustomComponent", *position) + for field, value in (values or {}).items(): + node_dict["data"]["node"]["template"][field]["value"] = value + return node_dict + + +# -------------------------------------------------------------------------- +# edges +# -------------------------------------------------------------------------- +def _output_def(node: dict, output_name: str) -> dict: + for out in node["data"]["node"]["outputs"]: + if out["name"] == output_name: + return out + raise KeyError(f"output {output_name!r} not in {node['id']}") + + +def _input_def(node: dict, input_name: str) -> dict: + field = node["data"]["node"]["template"][input_name] + return field + + +def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict: + out = _output_def(src, src_output) + inp = _input_def(dst, dst_input) + source_handle = { + "dataType": src["data"]["type"], + "id": src["id"], + "name": src_output, + "output_types": out.get("types", []), + } + target_handle = { + "fieldName": dst_input, + "id": dst["id"], + "inputTypes": inp.get("input_types", []), + "type": inp.get("type", "other"), + } + sh = json.dumps(source_handle).replace('"', HANDLE_QUOTE) + th = json.dumps(target_handle).replace('"', HANDLE_QUOTE) + return { + "animated": False, + "className": "", + "data": {"sourceHandle": source_handle, "targetHandle": target_handle}, + "id": f"xy-edge__{sh}-{th}", + "selected": False, + "source": src["id"], + "sourceHandle": sh, + "target": dst["id"], + "targetHandle": th, + } + + +# -------------------------------------------------------------------------- +# flow document +# -------------------------------------------------------------------------- +def flow_doc( + name: str, description: str, nodes: list[dict], edges: list[dict] +) -> dict: + return { + "data": { + "nodes": nodes, + "edges": edges, + "viewport": {"x": 0, "y": 0, "zoom": 1}, + }, + "description": description, + "endpoint_name": None, + "icon": None, + "icon_bg_color": None, + "id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{name}")), + "is_component": False, + "locked": False, + "mcp_enabled": False, + "name": name, + "tags": [], + "webhook": False, + } + + +# -------------------------------------------------------------------------- +# custom component code +# -------------------------------------------------------------------------- +RULE_CHECK_CODE = '''from lfx.custom import Component +from lfx.io import MessageTextInput, Output +from lfx.schema.message import Message +import json + + +class RuleCheckComponent(Component): + display_name = "Rule Check" + description = "Bewertet Betrag gegen Schwelle aus JSON-Eingabe." + + inputs = [ + MessageTextInput(name="payload", display_name="Payload"), + ] + outputs = [ + Output(display_name="Verdict", name="verdict", method="check"), + ] + + def _text(self, value) -> str: + if value is None: + return "" + if hasattr(value, "text"): + return value.text or "" + return str(value) + + def check(self) -> Message: + try: + data = json.loads(self._text(self.payload) or "{}") + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + # Die API ruft Flows immer mit Huelle auf + # {"payload": {...}, "run_id": ..., ...}; rohe Payloads gehen auch. + if isinstance(data.get("payload"), dict): + data = data["payload"] + try: + betrag = float(data.get("betrag", 0) or 0) + except (TypeError, ValueError): + betrag = 0.0 + try: + schwelle = float(data.get("schwelle", 200000) or 200000) + except (TypeError, ValueError): + schwelle = 200000.0 + return Message(text="PRUEFUNG" if betrag > schwelle else "OK") +''' + +ENVELOPE_CODE = '''from lfx.custom import Component +from lfx.io import MessageTextInput, Output +from lfx.schema.message import Message +import json + + +class EnvelopeComponent(Component): + display_name = "Envelope" + description = "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext." + + inputs = [ + MessageTextInput(name="raw", display_name="Raw Input"), + ] + outputs = [ + Output(display_name="Payload", name="payload", method="get_payload"), + Output(display_name="Run ID", name="run_id", method="get_run_id"), + Output( + display_name="Callback URL", + name="callback_url", + method="get_callback_url", + ), + Output( + display_name="Continuation", + name="continuation", + method="get_continuation", + ), + ] + + def _text(self, value) -> str: + if value is None: + return "" + if hasattr(value, "text"): + return value.text or "" + return str(value) + + def _env(self) -> dict: + if not hasattr(self, "_env_cache"): + try: + self._env_cache = json.loads(self._text(self.raw) or "{}") + except Exception: + self._env_cache = {} + if not isinstance(self._env_cache, dict): + self._env_cache = {} + return self._env_cache + + def get_payload(self) -> Message: + return Message(text=json.dumps(self._env().get("payload", {}))) + + def get_run_id(self) -> Message: + return Message(text=str(self._env().get("run_id", ""))) + + def get_callback_url(self) -> Message: + return Message(text=str(self._env().get("callback_url", ""))) + + def get_continuation(self) -> Message: + value = self._env().get("continuation_flow_id") + return Message(text=str(value) if value else "") +''' + +APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component +from lfx.io import MessageTextInput, Output +from lfx.schema.message import Message + + +class ApprovalCallbackComponent(Component): + display_name = "Approval Callback" + description = "Meldet einen Sonderfall per HTTP an unsere API und terminiert." + + inputs = [ + MessageTextInput(name="trigger", display_name="Trigger"), + MessageTextInput(name="run_id", display_name="Run ID"), + MessageTextInput(name="callback_url", display_name="Callback URL"), + MessageTextInput( + name="continuation_flow_id", + display_name="Continuation Flow ID", + ), + MessageTextInput(name="facts", display_name="Facts"), + ] + outputs = [ + Output(display_name="Result", name="result", method="notify"), + ] + + def _text(self, value) -> str: + if value is None: + return "" + if hasattr(value, "text"): + return value.text or "" + return str(value) + + def notify(self) -> Message: + import httpx + import json + + trigger = self._text(self.trigger).strip() + if not trigger: + # Zweig wurde nicht genommen: kein Seiteneffekt. + return Message(text="") + try: + facts = json.loads(self._text(self.facts) or "{}") + except Exception: + facts = {} + url = self._text(self.callback_url).rstrip("/") + "/api/approvals" + body = { + "run_id": self._text(self.run_id), + "title": f"Freigabe erforderlich ({trigger})", + "payload": {"verdict": trigger, "facts": facts}, + "continuation_flow_id": self._text(self.continuation_flow_id) or None, + } + try: + response = httpx.post(url, json=body, timeout=10.0) + response.raise_for_status() + except Exception as exc: + return Message(text=f"CALLBACK_FAILED: {exc}") + return Message(text="PENDING_HUMAN_APPROVAL") +''' + + +# -------------------------------------------------------------------------- +# flows +# -------------------------------------------------------------------------- +ROUTER_VALUES = { + "operator": "equals", + "match_text": "PRUEFUNG", + "true_case_message": "Sonderfall: menschliche Pruefung erforderlich", + "false_case_message": "Standardfall: automatisch freigegeben", +} + + +def build_simple_rule() -> dict: + entry = builtin_node( + "lfx.components.input_output.chat", + "ChatInput", + "ChatInput-entry", + position=(0, 200), + ) + rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(400, 200)) + router = builtin_node( + "lfx.components.flow_controls.conditional_router", + "ConditionalRouterComponent", + "ConditionalRouter-router", + values=ROUTER_VALUES, + position=(800, 200), + ) + out_ok = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-ok", + position=(1200, 60), + ) + out_sonder = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-sonder", + position=(1200, 340), + ) + nodes = [entry, rule, router, out_ok, out_sonder] + edges = [ + edge(entry, "message", rule, "payload"), + edge(rule, "verdict", router, "input_text"), + edge(router, "false_result", out_ok, "input_value"), + edge(router, "true_result", out_sonder, "input_value"), + ] + return flow_doc( + "simple_rule", + "Regelpruefung ohne Seiteneffekt (Spike-Fixture, kein LLM).", + nodes, + edges, + ) + + +def build_approval_handoff() -> dict: + entry = builtin_node( + "lfx.components.input_output.chat", + "ChatInput", + "ChatInput-entry", + position=(0, 300), + ) + envelope = custom_node( + ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300) + ) + rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300)) + router = builtin_node( + "lfx.components.flow_controls.conditional_router", + "ConditionalRouterComponent", + "ConditionalRouter-router", + values=ROUTER_VALUES, + position=(1200, 300), + ) + callback = custom_node( + APPROVAL_CALLBACK_CODE, + "CustomComponent-callback", + position=(1600, 160), + ) + out_pending = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-pending", + position=(2000, 160), + ) + out_ok = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-ok", + position=(2000, 440), + ) + nodes = [entry, envelope, rule, router, callback, out_pending, out_ok] + edges = [ + edge(entry, "message", envelope, "raw"), + edge(envelope, "payload", rule, "payload"), + edge(rule, "verdict", router, "input_text"), + edge(router, "true_result", callback, "trigger"), + edge(envelope, "run_id", callback, "run_id"), + edge(envelope, "callback_url", callback, "callback_url"), + edge(envelope, "continuation", callback, "continuation_flow_id"), + edge(envelope, "payload", callback, "facts"), + edge(callback, "result", out_pending, "input_value"), + edge(router, "false_result", out_ok, "input_value"), + ] + return flow_doc( + "approval_handoff", + "Sonderfall meldet sich per HTTP an unsere API und terminiert (Spike).", + nodes, + edges, + ) + + +def main() -> None: + FIXTURE_DIR.mkdir(parents=True, exist_ok=True) + flows = { + "simple_rule.json": build_simple_rule(), + "approval_handoff.json": build_approval_handoff(), + } + for filename, doc in flows.items(): + path = FIXTURE_DIR / filename + path.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n") + print(f"wrote {path}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0083486..ef3e9e1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -47,14 +47,26 @@ def db_engine(): @pytest.fixture() def session(db_engine): - """Function-scoped session; tables are truncated after each test.""" - from sqlmodel import Session, SQLModel + """Function-scoped session for direct DB access in tests.""" + from sqlmodel import Session with Session(db_engine) as s: yield s - with db_engine.begin() as conn: - for table in reversed(SQLModel.metadata.sorted_tables): - conn.execute(table.delete()) + + +@pytest.fixture(autouse=True) +def _clean_tables(db_engine): + """Truncate all domain tables before and after every test (isolation).""" + from sqlmodel import SQLModel + + def _truncate() -> None: + with db_engine.begin() as conn: + for table in reversed(SQLModel.metadata.sorted_tables): + conn.execute(table.delete()) + + _truncate() + yield + _truncate() @pytest.fixture() @@ -67,3 +79,113 @@ async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c + + +@pytest.fixture(scope="session") +def lfx_server(tmp_path_factory): + """Real `lfx serve` subprocess on a free port; points settings at it. + + Serves an (initially seeded) flow dir; tests upload fixture flows through + the same deploy path production uses. + """ + import shutil + import socket + import subprocess + import sys + import time + import urllib.request + from pathlib import Path + + from app.settings import settings + + bindir = Path(sys.executable).parent + lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx" + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + flow_dir = tmp_path_factory.mktemp("lfx-flows") + seed = ( + Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json" + ).read_text() + (flow_dir / "seed.json").write_text(seed) + + env = { + **os.environ, + # lfx serve refuses to start without a serve key; requests need it too. + "LFX_API_KEY": "test-lfx-key", + } + proc = subprocess.Popen( + [ + lfx_bin, + "serve", + "--host", + "127.0.0.1", + "--port", + str(port), + "--flow-dir", + str(flow_dir), + str(flow_dir), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + url = f"http://127.0.0.1:{port}" + deadline = time.time() + 240 + healthy = False + while time.time() < deadline: + if proc.poll() is not None: + out = proc.stdout.read() if proc.stdout else "" + raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}") + try: + with urllib.request.urlopen(url + "/health", timeout=3) as response: + if response.status == 200: + healthy = True + break + except OSError: + time.sleep(1) + if not healthy: + proc.terminate() + raise RuntimeError("lfx serve did not become healthy in time") + + old_url = settings.lfx_url + old_key = settings.lfx_api_key + settings.lfx_url = url + settings.lfx_api_key = "test-lfx-key" + yield url + settings.lfx_url = old_url + settings.lfx_api_key = old_key + proc.terminate() + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="session") +def live_server(): + """Real uvicorn server (ephemeral port) so LFX flows can call back + into our API over HTTP, exactly like production.""" + import threading + import time + + import uvicorn + + from app.main import app + + config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.time() + 30 + while not server.started and time.time() < deadline: + time.sleep(0.05) + assert server.started, "uvicorn test server did not start" + port = server.servers[0].sockets[0].getsockname()[1] + yield f"http://127.0.0.1:{port}" + server.should_exit = True + thread.join(timeout=15) diff --git a/backend/tests/fixtures/flows/approval_handoff.json b/backend/tests/fixtures/flows/approval_handoff.json new file mode 100644 index 0000000..cb70cf3 --- /dev/null +++ b/backend/tests/fixtures/flows/approval_handoff.json @@ -0,0 +1,1945 @@ +{ + "data": { + "nodes": [ + { + "data": { + "node": { + "template": { + "_type": "Component", + "files": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "temp_file": true, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "files", + "value": "", + "display_name": "Files", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Files to be sent with the message.", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "input_value": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Input Text", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Message to be passed as input.", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "User", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "User", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Get chat inputs from the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Chat Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatInput", + "id": "ChatInput-entry", + "showNode": true + }, + "id": "ChatInput-entry", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 0, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "raw": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "raw", + "value": "", + "display_name": "Raw Input", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + } + }, + "description": "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.", + "base_classes": [ + "Message" + ], + "display_name": "Envelope", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "payload", + "display_name": "Payload", + "method": "get_payload", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + }, + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "run_id", + "display_name": "Run ID", + "method": "get_run_id", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + }, + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "callback_url", + "display_name": "Callback URL", + "method": "get_callback_url", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + }, + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "continuation", + "display_name": "Continuation", + "method": "get_continuation", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [ + "raw" + ], + "beta": false, + "legacy": false, + "edited": false, + "metadata": { + "module": "custom_components.envelope", + "code_hash": "2b0bd3c3d529", + "dependencies": { + "total_dependencies": 1, + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ] + } + }, + "tool_mode": false + }, + "type": "CustomComponent", + "id": "CustomComponent-envelope", + "showNode": true + }, + "id": "CustomComponent-envelope", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass RuleCheckComponent(Component):\n display_name = \"Rule Check\"\n description = \"Bewertet Betrag gegen Schwelle aus JSON-Eingabe.\"\n\n inputs = [\n MessageTextInput(name=\"payload\", display_name=\"Payload\"),\n ]\n outputs = [\n Output(display_name=\"Verdict\", name=\"verdict\", method=\"check\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def check(self) -> Message:\n try:\n data = json.loads(self._text(self.payload) or \"{}\")\n except Exception:\n data = {}\n if not isinstance(data, dict):\n data = {}\n # Die API ruft Flows immer mit Huelle auf\n # {\"payload\": {...}, \"run_id\": ..., ...}; rohe Payloads gehen auch.\n if isinstance(data.get(\"payload\"), dict):\n data = data[\"payload\"]\n try:\n betrag = float(data.get(\"betrag\", 0) or 0)\n except (TypeError, ValueError):\n betrag = 0.0\n try:\n schwelle = float(data.get(\"schwelle\", 200000) or 200000)\n except (TypeError, ValueError):\n schwelle = 200000.0\n return Message(text=\"PRUEFUNG\" if betrag > schwelle else \"OK\")\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "payload": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "payload", + "value": "", + "display_name": "Payload", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + } + }, + "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.", + "base_classes": [ + "Message" + ], + "display_name": "Rule Check", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "verdict", + "display_name": "Verdict", + "method": "check", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [ + "payload" + ], + "beta": false, + "legacy": false, + "edited": false, + "metadata": { + "module": "custom_components.rule_check", + "code_hash": "0b9f8a009fcf", + "dependencies": { + "total_dependencies": 1, + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ] + } + }, + "tool_mode": false + }, + "type": "CustomComponent", + "id": "CustomComponent-rule", + "showNode": true + }, + "id": "CustomComponent-rule", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 800, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "case_sensitive": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "case_sensitive", + "value": true, + "display_name": "Case Sensitive", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "If true, the comparison will be case sensitive.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "import re\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n documentation: str = \"https://docs.langflow.org/if-else\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n \"\"\"Handles cycle iteration counting and branch exclusion.\n\n Uses two complementary mechanisms:\n 1. stop() - ACTIVE/INACTIVE state for cycle management (gets reset each iteration)\n 2. exclude_branch_conditionally() - Persistent exclusion for conditional routing\n\n When max_iterations is reached, breaks the cycle by allowing the default_route to execute.\n \"\"\"\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n\n # Check if max iterations reached and we're trying to stop the default route\n if current_iteration >= self.max_iterations and route_to_stop == self.default_route:\n # Clear ALL conditional exclusions to allow default route to execute\n if self._id in self.graph.conditional_exclusion_sources:\n previous_exclusions = self.graph.conditional_exclusion_sources[self._id]\n self.graph.conditionally_excluded_vertices -= previous_exclusions\n del self.graph.conditional_exclusion_sources[self._id]\n\n # Switch which route to stop - stop the NON-default route to break the cycle\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n\n # Call stop to break the cycle\n self.stop(route_to_stop)\n # Don't apply conditional exclusion when breaking cycle\n return\n\n # Normal case: Use BOTH mechanisms\n # 1. stop() for cycle management (marks INACTIVE, updates run manager, gets reset)\n self.stop(route_to_stop)\n\n # 2. Conditional exclusion for persistent routing (doesn't get reset except by this router)\n self.graph.exclude_branch_conditionally(self._id, output_name=route_to_stop)\n\n def _resolve_case_message(self, case_message: Message | str | None) -> Message:\n \"\"\"Return the override case message, falling back to the input text when it is blank.\n\n ``Case True`` and ``Case False`` are optional overrides. When left blank the\n ``MessageInput`` resolves to an empty ``Message``, so the original ``Text Input``\n is routed through instead of an empty message.\n\n A ``Message`` is only treated as blank when it carries no payload at all. An\n override with empty text but meaningful content (files or content blocks) is\n preserved as-is so that payload is not dropped.\n\n Blankness is judged by payload only (``text``, ``files``, ``content_blocks``).\n A ``Message`` carrying just metadata (``sender``, ``properties``, ``error`` ...)\n and no payload is intentionally treated as blank: every empty ``Message`` also\n defaults ``timestamp`` / ``category`` / ``properties``, so keying on metadata\n would defeat the blank-field fallback. With no payload there is nothing to\n route, so the ``Text Input`` is used.\n\n ``case_message`` is typed defensively as ``Message | str | None``. ``MessageInput``\n wraps user input into a ``Message`` on the validated graph path, but a raw ``str``\n can still arrive via direct/programmatic assignment (``.set(...)`` stores the raw\n value), so the ``str`` branch is retained rather than dropping such an override.\n \"\"\"\n if isinstance(case_message, Message):\n if case_message.text or case_message.files or case_message.content_blocks:\n return case_message\n return Message(text=self.input_text)\n if isinstance(case_message, str) and case_message:\n return Message(text=case_message)\n return Message(text=self.input_text)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n # Check if we should force output due to max_iterations on default route\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n force_output = current_iteration >= self.max_iterations and self.default_route == \"true_result\"\n\n if result or force_output:\n true_message = self._resolve_case_message(self.true_case_message)\n self.status = true_message\n if not force_output: # Only stop the other branch if not forcing due to max iterations\n self.iterate_and_stop_once(\"false_result\")\n return true_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(text=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n if not result:\n false_message = self._resolve_case_message(self.false_case_message)\n self.status = false_message\n self.iterate_and_stop_once(\"true_result\")\n return false_message\n\n self.iterate_and_stop_once(\"false_result\")\n return Message(text=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "default_route": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "true_result", + "false_result" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "default_route", + "value": "false_result", + "display_name": "Default Route", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "The default route to take when max iterations are reached.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "false_case_message": { + "trace_as_input": true, + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "false_case_message", + "value": "Standardfall: automatisch freigegeben", + "display_name": "Case False", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The message to pass if the condition is False.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageInput" + }, + "input_text": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_text", + "value": "", + "display_name": "Text Input", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The primary text input for the operation.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "match_text": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "match_text", + "value": "PRUEFUNG", + "display_name": "Match Text", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The text input to compare against.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "max_iterations": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "max_iterations", + "value": 10, + "display_name": "Max Iterations", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "The maximum number of iterations for the conditional router.", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "operator": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "equals", + "not equals", + "contains", + "starts with", + "ends with", + "regex", + "less than", + "less than or equal", + "greater than", + "greater than or equal" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "operator", + "value": "equals", + "display_name": "Operator", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "The operator to apply for comparing the texts.", + "real_time_refresh": true, + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "true_case_message": { + "trace_as_input": true, + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "true_case_message", + "value": "Sonderfall: menschliche Pruefung erforderlich", + "display_name": "Case True", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The message to pass if the condition is True.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageInput" + } + }, + "description": "Routes an input message to a corresponding output based on text comparison.", + "icon": "split", + "base_classes": [ + "Message" + ], + "display_name": "If-Else", + "documentation": "https://docs.langflow.org/if-else", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "true_result", + "display_name": "True", + "method": "true_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": true, + "tool_mode": true + }, + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "false_result", + "display_name": "False", + "method": "false_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": true, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ConditionalRouter", + "id": "ConditionalRouter-router", + "showNode": true + }, + "id": "ConditionalRouter-router", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 1200, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "callback_url": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "callback_url", + "value": "", + "display_name": "Callback URL", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "continuation_flow_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "continuation_flow_id", + "value": "", + "display_name": "Continuation Flow ID", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "facts": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "facts", + "value": "", + "display_name": "Facts", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "run_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "run_id", + "value": "", + "display_name": "Run ID", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "trigger": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "trigger", + "value": "", + "display_name": "Trigger", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + } + }, + "description": "Meldet einen Sonderfall per HTTP an unsere API und terminiert.", + "base_classes": [ + "Message" + ], + "display_name": "Approval Callback", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "result", + "display_name": "Result", + "method": "notify", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [ + "trigger", + "run_id", + "callback_url", + "continuation_flow_id", + "facts" + ], + "beta": false, + "legacy": false, + "edited": false, + "metadata": { + "module": "custom_components.approval_callback", + "code_hash": "a334376410f7", + "dependencies": { + "total_dependencies": 2, + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + }, + { + "name": "httpx", + "version": "0.28.1" + } + ] + } + }, + "tool_mode": false + }, + "type": "CustomComponent", + "id": "CustomComponent-callback", + "showNode": true + }, + "id": "CustomComponent-callback", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 1600, + "y": 160 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-pending", + "showNode": true + }, + "id": "ChatOutput-pending", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 2000, + "y": 160 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-ok", + "showNode": true + }, + "id": "ChatOutput-ok", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 2000, + "y": 440 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "raw", + "id": "CustomComponent-envelope", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œrawœ, œidœ: œCustomComponent-envelopeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-envelope", + "targetHandle": "{œfieldNameœ: œrawœ, œidœ: œCustomComponent-envelopeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-envelope", + "name": "payload", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "payload", + "id": "CustomComponent-rule", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-envelope", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-rule", + "targetHandle": "{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-rule", + "name": "verdict", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_text", + "id": "ConditionalRouter-router", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-rule", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}", + "target": "ConditionalRouter-router", + "targetHandle": "{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ConditionalRouter", + "id": "ConditionalRouter-router", + "name": "true_result", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "trigger", + "id": "CustomComponent-callback", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œtriggerœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ConditionalRouter-router", + "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-callback", + "targetHandle": "{œfieldNameœ: œtriggerœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-envelope", + "name": "run_id", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "run_id", + "id": "CustomComponent-callback", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œrun_idœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œrun_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-envelope", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œrun_idœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-callback", + "targetHandle": "{œfieldNameœ: œrun_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-envelope", + "name": "callback_url", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "callback_url", + "id": "CustomComponent-callback", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_urlœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-envelope", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_urlœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-callback", + "targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-envelope", + "name": "continuation", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "continuation_flow_id", + "id": "CustomComponent-callback", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcontinuationœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcontinuation_flow_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-envelope", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcontinuationœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-callback", + "targetHandle": "{œfieldNameœ: œcontinuation_flow_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-envelope", + "name": "payload", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "facts", + "id": "CustomComponent-callback", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œfactsœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-envelope", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-callback", + "targetHandle": "{œfieldNameœ: œfactsœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-callback", + "name": "result", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-pending", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-callbackœ, œnameœ: œresultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-pendingœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomComponent-callback", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-callbackœ, œnameœ: œresultœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-pending", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-pendingœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ConditionalRouter", + "id": "ConditionalRouter-router", + "name": "false_result", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-ok", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "ConditionalRouter-router", + "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-ok", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Sonderfall meldet sich per HTTP an unsere API und terminiert (Spike).", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "7a0a0ba8-cac3-58c4-ac65-dd6f973ab546", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "approval_handoff", + "tags": [], + "webhook": false +} diff --git a/backend/tests/fixtures/flows/simple_rule.json b/backend/tests/fixtures/flows/simple_rule.json new file mode 100644 index 0000000..aba77ad --- /dev/null +++ b/backend/tests/fixtures/flows/simple_rule.json @@ -0,0 +1,1393 @@ +{ + "data": { + "nodes": [ + { + "data": { + "node": { + "template": { + "_type": "Component", + "files": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "temp_file": true, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "files", + "value": "", + "display_name": "Files", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Files to be sent with the message.", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "input_value": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Input Text", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Message to be passed as input.", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "User", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "User", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Get chat inputs from the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Chat Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatInput", + "id": "ChatInput-entry", + "showNode": true + }, + "id": "ChatInput-entry", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass RuleCheckComponent(Component):\n display_name = \"Rule Check\"\n description = \"Bewertet Betrag gegen Schwelle aus JSON-Eingabe.\"\n\n inputs = [\n MessageTextInput(name=\"payload\", display_name=\"Payload\"),\n ]\n outputs = [\n Output(display_name=\"Verdict\", name=\"verdict\", method=\"check\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def check(self) -> Message:\n try:\n data = json.loads(self._text(self.payload) or \"{}\")\n except Exception:\n data = {}\n if not isinstance(data, dict):\n data = {}\n # Die API ruft Flows immer mit Huelle auf\n # {\"payload\": {...}, \"run_id\": ..., ...}; rohe Payloads gehen auch.\n if isinstance(data.get(\"payload\"), dict):\n data = data[\"payload\"]\n try:\n betrag = float(data.get(\"betrag\", 0) or 0)\n except (TypeError, ValueError):\n betrag = 0.0\n try:\n schwelle = float(data.get(\"schwelle\", 200000) or 200000)\n except (TypeError, ValueError):\n schwelle = 200000.0\n return Message(text=\"PRUEFUNG\" if betrag > schwelle else \"OK\")\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "payload": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "payload", + "value": "", + "display_name": "Payload", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + } + }, + "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.", + "base_classes": [ + "Message" + ], + "display_name": "Rule Check", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "verdict", + "display_name": "Verdict", + "method": "check", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [ + "payload" + ], + "beta": false, + "legacy": false, + "edited": false, + "metadata": { + "module": "custom_components.rule_check", + "code_hash": "0b9f8a009fcf", + "dependencies": { + "total_dependencies": 1, + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ] + } + }, + "tool_mode": false + }, + "type": "CustomComponent", + "id": "CustomComponent-rule", + "showNode": true + }, + "id": "CustomComponent-rule", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "case_sensitive": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "case_sensitive", + "value": true, + "display_name": "Case Sensitive", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "If true, the comparison will be case sensitive.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "import re\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n documentation: str = \"https://docs.langflow.org/if-else\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n \"\"\"Handles cycle iteration counting and branch exclusion.\n\n Uses two complementary mechanisms:\n 1. stop() - ACTIVE/INACTIVE state for cycle management (gets reset each iteration)\n 2. exclude_branch_conditionally() - Persistent exclusion for conditional routing\n\n When max_iterations is reached, breaks the cycle by allowing the default_route to execute.\n \"\"\"\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n\n # Check if max iterations reached and we're trying to stop the default route\n if current_iteration >= self.max_iterations and route_to_stop == self.default_route:\n # Clear ALL conditional exclusions to allow default route to execute\n if self._id in self.graph.conditional_exclusion_sources:\n previous_exclusions = self.graph.conditional_exclusion_sources[self._id]\n self.graph.conditionally_excluded_vertices -= previous_exclusions\n del self.graph.conditional_exclusion_sources[self._id]\n\n # Switch which route to stop - stop the NON-default route to break the cycle\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n\n # Call stop to break the cycle\n self.stop(route_to_stop)\n # Don't apply conditional exclusion when breaking cycle\n return\n\n # Normal case: Use BOTH mechanisms\n # 1. stop() for cycle management (marks INACTIVE, updates run manager, gets reset)\n self.stop(route_to_stop)\n\n # 2. Conditional exclusion for persistent routing (doesn't get reset except by this router)\n self.graph.exclude_branch_conditionally(self._id, output_name=route_to_stop)\n\n def _resolve_case_message(self, case_message: Message | str | None) -> Message:\n \"\"\"Return the override case message, falling back to the input text when it is blank.\n\n ``Case True`` and ``Case False`` are optional overrides. When left blank the\n ``MessageInput`` resolves to an empty ``Message``, so the original ``Text Input``\n is routed through instead of an empty message.\n\n A ``Message`` is only treated as blank when it carries no payload at all. An\n override with empty text but meaningful content (files or content blocks) is\n preserved as-is so that payload is not dropped.\n\n Blankness is judged by payload only (``text``, ``files``, ``content_blocks``).\n A ``Message`` carrying just metadata (``sender``, ``properties``, ``error`` ...)\n and no payload is intentionally treated as blank: every empty ``Message`` also\n defaults ``timestamp`` / ``category`` / ``properties``, so keying on metadata\n would defeat the blank-field fallback. With no payload there is nothing to\n route, so the ``Text Input`` is used.\n\n ``case_message`` is typed defensively as ``Message | str | None``. ``MessageInput``\n wraps user input into a ``Message`` on the validated graph path, but a raw ``str``\n can still arrive via direct/programmatic assignment (``.set(...)`` stores the raw\n value), so the ``str`` branch is retained rather than dropping such an override.\n \"\"\"\n if isinstance(case_message, Message):\n if case_message.text or case_message.files or case_message.content_blocks:\n return case_message\n return Message(text=self.input_text)\n if isinstance(case_message, str) and case_message:\n return Message(text=case_message)\n return Message(text=self.input_text)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n # Check if we should force output due to max_iterations on default route\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n force_output = current_iteration >= self.max_iterations and self.default_route == \"true_result\"\n\n if result or force_output:\n true_message = self._resolve_case_message(self.true_case_message)\n self.status = true_message\n if not force_output: # Only stop the other branch if not forcing due to max iterations\n self.iterate_and_stop_once(\"false_result\")\n return true_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(text=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n if not result:\n false_message = self._resolve_case_message(self.false_case_message)\n self.status = false_message\n self.iterate_and_stop_once(\"true_result\")\n return false_message\n\n self.iterate_and_stop_once(\"false_result\")\n return Message(text=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "default_route": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "true_result", + "false_result" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "default_route", + "value": "false_result", + "display_name": "Default Route", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "The default route to take when max iterations are reached.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "false_case_message": { + "trace_as_input": true, + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "false_case_message", + "value": "Standardfall: automatisch freigegeben", + "display_name": "Case False", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The message to pass if the condition is False.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageInput" + }, + "input_text": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_text", + "value": "", + "display_name": "Text Input", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The primary text input for the operation.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "match_text": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "match_text", + "value": "PRUEFUNG", + "display_name": "Match Text", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The text input to compare against.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "max_iterations": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "max_iterations", + "value": 10, + "display_name": "Max Iterations", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "The maximum number of iterations for the conditional router.", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "operator": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "equals", + "not equals", + "contains", + "starts with", + "ends with", + "regex", + "less than", + "less than or equal", + "greater than", + "greater than or equal" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "operator", + "value": "equals", + "display_name": "Operator", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "The operator to apply for comparing the texts.", + "real_time_refresh": true, + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "true_case_message": { + "trace_as_input": true, + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "true_case_message", + "value": "Sonderfall: menschliche Pruefung erforderlich", + "display_name": "Case True", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The message to pass if the condition is True.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageInput" + } + }, + "description": "Routes an input message to a corresponding output based on text comparison.", + "icon": "split", + "base_classes": [ + "Message" + ], + "display_name": "If-Else", + "documentation": "https://docs.langflow.org/if-else", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "true_result", + "display_name": "True", + "method": "true_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": true, + "tool_mode": true + }, + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "false_result", + "display_name": "False", + "method": "false_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": true, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ConditionalRouter", + "id": "ConditionalRouter-router", + "showNode": true + }, + "id": "ConditionalRouter-router", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-ok", + "showNode": true + }, + "id": "ChatOutput-ok", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 1200, + "y": 60 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-sonder", + "showNode": true + }, + "id": "ChatOutput-sonder", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 1200, + "y": 340 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "payload", + "id": "CustomComponent-rule", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomComponent-rule", + "targetHandle": "{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "CustomComponent-rule", + "name": "verdict", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_text", + "id": "ConditionalRouter-router", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "CustomComponent-rule", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}", + "target": "ConditionalRouter-router", + "targetHandle": "{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ConditionalRouter", + "id": "ConditionalRouter-router", + "name": "false_result", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-ok", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "ConditionalRouter-router", + "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-ok", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ConditionalRouter", + "id": "ConditionalRouter-router", + "name": "true_result", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-sonder", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-sonderœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "ConditionalRouter-router", + "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-sonder", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-sonderœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Regelpruefung ohne Seiteneffekt (Spike-Fixture, kein LLM).", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "5adedb2b-d50b-5c3f-9951-2e76b8b94869", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "simple_rule", + "tags": [], + "webhook": false +} diff --git a/backend/tests/test_flows_api.py b/backend/tests/test_flows_api.py new file mode 100644 index 0000000..d27e0d0 --- /dev/null +++ b/backend/tests/test_flows_api.py @@ -0,0 +1,248 @@ +"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E.""" + +import json +from pathlib import Path +from uuid import uuid4 + +FIXTURES = Path(__file__).parent / "fixtures" / "flows" + + +def load_fixture(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + + +async def _create_flow(client, name="regeltest"): + response = await client.post( + "/api/flows", json={"name": name, "description": "test flow"} + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _create_version(client, flow_id, fixture="simple_rule.json"): + response = await client.post( + f"/api/flows/{flow_id}/versions", + json={"langflow_json": load_fixture(fixture), "author": "test"}, + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _deploy(client, flow_id, version_no=1): + response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy") + assert response.status_code == 200, response.text + return response.json() + + +async def test_flow_crud(client): + created = await _create_flow(client, "crud-flow") + assert created["status"] == "active" + + response = await client.get(f"/api/flows/{created['id']}") + assert response.status_code == 200 + assert response.json()["name"] == "crud-flow" + + response = await client.get("/api/flows") + assert response.status_code == 200 + assert any(f["id"] == created["id"] for f in response.json()) + + response = await client.post("/api/flows", json={"name": "crud-flow"}) + assert response.status_code == 409 + + response = await client.get(f"/api/flows/{uuid4()}") + assert response.status_code == 404 + + +async def test_version_validation_and_numbering(client): + flow = await _create_flow(client, "versioned") + + v1 = await _create_version(client, flow["id"]) + assert v1["version_no"] == 1 + v2 = await _create_version(client, flow["id"]) + assert v2["version_no"] == 2 + assert v1["deployed_at"] is None + + response = await client.post( + f"/api/flows/{flow['id']}/versions", json={"langflow_json": {"nope": 1}} + ) + assert response.status_code == 422 + + response = await client.post( + f"/api/flows/{uuid4()}/versions", + json={"langflow_json": load_fixture("simple_rule.json")}, + ) + assert response.status_code == 404 + + response = await client.get(f"/api/flows/{flow['id']}/versions") + assert [v["version_no"] for v in response.json()] == [1, 2] + + +async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server): + flow = await _create_flow(client, "dachsanierung") + await _create_version(client, flow["id"]) + deployed = await _deploy(client, flow["id"]) + assert deployed["lfx_flow_id"] == deployed["id"] + assert deployed["deployed_at"] is not None + + response = await client.post( + f"/api/flows/{flow['id']}/run", + json={"input": {"betrag": 120000, "schwelle": 200000}}, + ) + assert response.status_code == 200, response.text + run = response.json() + assert run["status"] == "completed" + assert "Standardfall" in run["output"]["result"] + assert run["finished_at"] is not None + + response = await client.post( + f"/api/flows/{flow['id']}/run", + json={"input": {"betrag": 250000, "schwelle": 200000}}, + ) + assert response.status_code == 200, response.text + run = response.json() + assert run["status"] == "completed" + assert "Sonderfall" in run["output"]["result"] + + response = await client.get(f"/api/flows/runs/{run['id']}") + assert response.status_code == 200 + assert response.json()["id"] == run["id"] + + +async def test_run_undeployed_version_fails(client): + flow = await _create_flow(client, "undeployed") + await _create_version(client, flow["id"]) + response = await client.post( + f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}} + ) + assert response.status_code == 422 + + +async def test_run_explicit_version_no(client, lfx_server): + flow = await _create_flow(client, "explicit-version") + await _create_version(client, flow["id"]) + await _create_version(client, flow["id"]) + await _deploy(client, flow["id"], version_no=2) + response = await client.post( + f"/api/flows/{flow['id']}/run", + json={"input": {"betrag": 1}, "version_no": 2}, + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "completed" + + +async def test_run_stream_events(client, lfx_server): + flow = await _create_flow(client, "streamed") + await _create_version(client, flow["id"]) + await _deploy(client, flow["id"]) + + events: list[tuple[str, str]] = [] + async with client.stream( + "POST", + f"/api/flows/{flow['id']}/run?stream=true", + json={"input": {"betrag": 1}}, + ) as response: + assert response.status_code == 200, await response.aread() + current_event = "message" + async for line in response.aiter_lines(): + if line.startswith("event:"): + current_event = line.split(":", 1)[1].strip() + elif line.startswith("data:"): + events.append((current_event, line.split(":", 1)[1].strip())) + current_event = "message" + + assert events, "expected SSE events" + assert events[0][0] == "run" + first_payload = json.loads(events[0][1]) + assert "run_id" in first_payload + assert events[-1][0] == "run_finished" + last_payload = json.loads(events[-1][1]) + assert last_payload["status"] == "completed" + + response = await client.get(f"/api/flows/runs/{first_payload['run_id']}") + assert response.status_code == 200 + assert response.json()["status"] == "completed" + + +async def test_approval_handoff_end_to_end(client, lfx_server, live_server): + flow = await _create_flow(client, "freigabe") + await _create_version(client, flow["id"], fixture="approval_handoff.json") + await _deploy(client, flow["id"]) + + response = await client.post( + f"/api/flows/{flow['id']}/run", + json={ + "input": {"betrag": 250000, "schwelle": 200000}, + "callback_base_url": live_server, + }, + ) + assert response.status_code == 200, response.text + run = response.json() + assert run["status"] == "pending_approval" + assert "PENDING_HUMAN_APPROVAL" in run["output"]["result"] + + response = await client.get("/api/approvals") + assert response.status_code == 200 + tasks = response.json() + assert len(tasks) == 1 + task = tasks[0] + assert task["run_id"] == run["id"] + assert task["status"] == "open" + assert task["payload"]["facts"]["betrag"] == 250000 + + +async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server): + flow = await _create_flow(client, "freigabe-auto") + await _create_version(client, flow["id"], fixture="approval_handoff.json") + await _deploy(client, flow["id"]) + + response = await client.post( + f"/api/flows/{flow['id']}/run", + json={ + "input": {"betrag": 50000, "schwelle": 200000}, + "callback_base_url": live_server, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "completed" + + response = await client.get("/api/approvals") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_approval_callback_contract(client, session): + from uuid import UUID + + from app.flows import store as flow_store + + flow = await _create_flow(client, "callback-target") + version = await _create_version(client, flow["id"]) + version_obj = flow_store.get_version(session, UUID(version["id"])) + + run = flow_store.create_run(session, version_obj, input={}, tenant_id="default") + + response = await client.post( + "/api/approvals", + json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}}, + ) + assert response.status_code == 201, response.text + task = response.json() + assert task["status"] == "open" + assert task["tenant_id"] == "default" + assert task["version_id"] == version["id"] + + response = await client.get(f"/api/flows/runs/{run.id}") + assert response.json()["status"] == "pending_approval" + + response = await client.post("/api/approvals", json={"run_id": str(run.id)}) + assert response.status_code == 409 + + run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default") + response = await client.post( + "/api/approvals", + json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())}, + ) + assert response.status_code == 422 + + response = await client.post("/api/approvals", json={"run_id": str(uuid4())}) + assert response.status_code == 404