diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5174e3a..ce24bff 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -27,9 +27,14 @@ RUN uv python install 3.12 \ # Trust the workspace bind mount (source on Windows FS -> uid mismatch) RUN git config --system --add safe.directory /workspaces/kirche-einfach -# Pre-create named-volume targets (volumes inherit image content on first mount) -RUN mkdir -p /workspaces/kirche-einfach/backend/.venv \ - /workspaces/kirche-einfach/frontend/node_modules +# Pre-create named-volume targets (volumes inherit image content on first mount). +# The backend venv is created as `vscode` so VS Code's Python extension finds an +# interpreter immediately on first attach, before postCreateCommand runs `uv sync`. +USER vscode +RUN mkdir -p /workspaces/kirche-einfach/backend \ + && uv venv /workspaces/kirche-einfach/backend/.venv \ + && mkdir -p /workspaces/kirche-einfach/frontend/node_modules +USER root # opencode (as non-root user) USER vscode diff --git a/.devcontainer/Dockerfile.lfx b/.devcontainer/Dockerfile.lfx new file mode 100644 index 0000000..42c9480 --- /dev/null +++ b/.devcontainer/Dockerfile.lfx @@ -0,0 +1,19 @@ +# Stateless Langflow flow runtime (LFX). Serves versioned flow JSON files +# deployed by our backend via POST /flows/upload/ (persisted in /flows). +# LFX stays on the internal compose network; our FastAPI is the only +# public surface and proxies run/stream calls. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# lfx = flow runtime; requests = convenience for custom-component code. +RUN pip install "lfx==1.12.0" requests + +RUN mkdir -p /flows +VOLUME /flows +EXPOSE 6004 + +# Serve the /flows dir (uploaded flows persist there via --flow-dir). +# Flags verified against `lfx serve --help` (lfx 1.12.0). +CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1ce1b7e..a162f71 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,15 +1,10 @@ { "name": "kirche-einfach", - "build": { - "dockerfile": "Dockerfile", - "args": { - "FORGEJO_USERNAME": "jonas" - } - }, + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "runServices": ["db", "langflow", "lfx"], "mounts": [ - "source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly", - "source=kirche-einfach-venv,target=/workspaces/kirche-einfach/backend/.venv,type=volume", - "source=kirche-einfach-node-modules,target=/workspaces/kirche-einfach/frontend/node_modules,type=volume" + "source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly" ], "remoteUser": "vscode", "containerEnv": { @@ -18,7 +13,7 @@ "overrideCommand": false, "workspaceFolder": "/workspaces/kirche-einfach", "postCreateCommand": "sudo -E bash .devcontainer/setup.sh", - "postStartCommand": "mkdir -p ~/.ssh && cp /mnt/host-ssh/id_rsa /mnt/host-ssh/id_rsa.pub ~/.ssh/ && cp /mnt/host-ssh/known_hosts ~/.ssh/ 2>/dev/null; ssh-keyscan -t ed25519,rsa gitlab.jonas-miederer.com >> ~/.ssh/known_hosts 2>/dev/null; chmod 700 ~/.ssh && chmod 600 ~/.ssh/id_rsa && chmod 644 ~/.ssh/id_rsa.pub ~/.ssh/known_hosts", + "postStartCommand": "bash .devcontainer/postStart.sh", "customizations": { "vscode": { "extensions": [ diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..7c0b6a9 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,93 @@ +# Compose stack for the kirche-einfach devcontainer. +# VS Code attaches to the `app` service (see devcontainer.json); all values +# come from the repo-root `.env` via `env_file` (no ${} interpolation needed). +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + FORGEJO_USERNAME: jonas + command: sleep infinity + volumes: + - ..:/workspaces/kirche-einfach:cached + - kirche-einfach-venv:/workspaces/kirche-einfach/backend/.venv + - kirche-einfach-node-modules:/workspaces/kirche-einfach/frontend/node_modules + env_file: + - ../.env + ports: + - "8000:8000" # FastAPI + - "3000:3000" # Vite + + db: + image: postgres:16-trixie + env_file: + - ../.env + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 10 + + langflow: + image: langflowai/langflow:1.12.0 + depends_on: + db: + condition: service_healthy + env_file: + - ../.env + environment: + # Static values only; secrets/URLs come from ../.env via env_file + # (compose interpolates ${} from .devcontainer/.env, not the repo-root .env). + LANGFLOW_CONFIG_DIR: /app/langflow + LANGFLOW_HOST: 0.0.0.0 + PORT: "7860" + LANGFLOW_AUTO_LOGIN: "false" + LANGFLOW_API_KEY_SOURCE: env + volumes: + - langflow-data:/app/langflow + ports: + - "7860:7860" + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:7860/health', timeout=5)\"", + ] + interval: 10s + timeout: 5s + retries: 20 + start_period: 60s + + lfx: + build: + context: . + dockerfile: Dockerfile.lfx + env_file: + - ../.env + volumes: + - lfx-data:/flows + ports: + - "6004:6004" + healthcheck: + test: + [ + "CMD-SHELL", + "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:6004/health', timeout=5)\"", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + +volumes: + kirche-einfach-venv: + kirche-einfach-node-modules: + postgres-data: + langflow-data: + lfx-data: diff --git a/.devcontainer/init-db.sql b/.devcontainer/init-db.sql new file mode 100644 index 0000000..795450a --- /dev/null +++ b/.devcontainer/init-db.sql @@ -0,0 +1,7 @@ +-- Runs once on first init of the postgres-data volume. +-- The main app database ($POSTGRES_DB) is created by the image itself. +-- Langflow (authoring UI) gets its own database on the same instance. +CREATE DATABASE langflow; + +-- Isolated database for the pytest suite (migrated via Alembic in conftest). +CREATE DATABASE kirche_einfach_test; diff --git a/.devcontainer/postStart.sh b/.devcontainer/postStart.sh new file mode 100644 index 0000000..d1ec719 --- /dev/null +++ b/.devcontainer/postStart.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Post-start hook: copies SSH keys from the host into the container, then +# adds the git host. Never fails the devcontainer run: any missing file on +# the host (e.g. no id_rsa) must not abort the lifecycle command. +set -u + +mkdir -p ~/.ssh + +for f in id_rsa id_rsa.pub known_hosts; do + cp -f "/mnt/host-ssh/$f" ~/.ssh/ 2>/dev/null || true +done + +ssh-keyscan -t ed25519,rsa gitlab.jonas-miederer.com >> ~/.ssh/known_hosts 2>/dev/null || true + +chmod 700 ~/.ssh 2>/dev/null || true +chmod 600 ~/.ssh/id_rsa 2>/dev/null || true +chmod 644 ~/.ssh/id_rsa.pub ~/.ssh/known_hosts 2>/dev/null || true + +exit 0 \ No newline at end of file diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d2f9d7d..5b9be3b 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -13,6 +13,38 @@ echo "--- Installing Node dependencies ---" cd /workspaces/kirche-einfach/frontend npm install +# Compose services (db, langflow, lfx) must be up; fail loudly otherwise. +echo "--- Waiting for compose services ---" +if [ ! -f /workspaces/kirche-einfach/.env ]; then + echo "ERROR: /workspaces/kirche-einfach/.env is missing. Copy .env.example to .env and fill in secrets." >&2 + exit 1 +fi +python3 - <<'EOF' +import socket +import sys +import time + +targets = [("db", 5432), ("langflow", 7860), ("lfx", 6004)] +deadline = time.time() + 180 +pending = dict(targets) +while pending and time.time() < deadline: + for host, port in list(pending): + s = socket.socket() + s.settimeout(2) + try: + s.connect((host, port)) + print(f"OK {host}:{port}", flush=True) + del pending[(host, port)] + except OSError: + pass + finally: + s.close() + if pending: + time.sleep(2) +if pending: + sys.exit(f"ERROR: compose services not ready: {sorted(pending)}") +EOF + echo "" echo "=== Setup complete ===" echo "Forgejo username: $FORGEJO_USERNAME" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05f1325 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy to `.env` and fill in values. `.env` is gitignored. +FORGEJO_TOKEN= + +# --- databases (compose service `db`) --- +POSTGRES_USER=kirche +POSTGRES_PASSWORD= +POSTGRES_DB=kirche_einfach +DATABASE_URL=postgresql+psycopg://kirche:@db:5432/kirche_einfach +TEST_DATABASE_URL=postgresql+psycopg://kirche:@db:5432/kirche_einfach_test + +# --- langflow (authoring, dev only) --- +LANGFLOW_URL=http://langflow:7860 +LANGFLOW_SUPERUSER=langflow +LANGFLOW_SUPERUSER_PASSWORD= +LANGFLOW_SECRET_KEY= +# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. +LANGFLOW_API_KEY=sk- +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/.gitignore b/.gitignore index 32c1b74..ecebd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Node node_modules/ dist/ +*.tsbuildinfo # Environment .env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8b7efd1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Agents + +## Git + +When committing: `git add -A && git commit -m ""`. When pushing: `git push` (branch is tracking `origin/main`). diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..08f1d9f --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..668fede --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,36 @@ +"""Database engine, sessions, and FastAPI dependency.""" + +from collections.abc import Iterator + +from sqlalchemy import Engine +from sqlmodel import Session, SQLModel, create_engine + +from .settings import settings + +_engine: Engine | None = None + + +def get_engine(url: str | None = None) -> Engine: + """Process-wide engine singleton (override URL in tests via settings).""" + global _engine + if _engine is None: + _engine = create_engine(url or settings.database_url) + return _engine + + +def reset_engine() -> None: + """Drop the cached engine (used by the test suite between DB targets).""" + global _engine + if _engine is not None: + _engine.dispose() + _engine = None + + +def get_session() -> Iterator[Session]: + with Session(get_engine()) as session: + yield session + + +def create_all(url: str | None = None) -> None: + """Create tables directly (dev convenience; migrations own prod schema).""" + SQLModel.metadata.create_all(get_engine(url)) 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..aa155b9 --- /dev/null +++ b/backend/app/flows/api.py @@ -0,0 +1,455 @@ +"""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 .deploy import import_from_langflow +from .langflow_client import ( + LangflowClient, + LangflowError, + LangflowNotFound, + LangflowUnavailable, +) +from .lfx_client import ( + LfxClient, + LfxConflict, + LfxError, + LfxInvalidFlow, + LfxNotFound, + LfxRunFailed, + LfxUnavailable, +) +from .schemas import ( + ApprovalCreate, + ApprovalRead, + DecisionRead, + DecisionRequest, + FlowDefinitionCreate, + FlowDefinitionRead, + FlowImportRead, + FlowImportRequest, + 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 get_langflow() -> LangflowClient: + return LangflowClient( + settings.langflow_url, api_key=settings.langflow_api_key or "" + ) + + +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, + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_db), +): + try: + return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)] + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@flows_router.get("/langflow/available") +async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)): + """Browse flows on the Langflow authoring server (admin import source).""" + try: + return await langflow.list_flows() + except LangflowUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except LangflowError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@flows_router.post("/import", response_model=FlowImportRead, status_code=201) +async def import_flow( + data: FlowImportRequest, + session: Session = Depends(get_db), + langflow: LangflowClient = Depends(get_langflow), + lfx: LfxClient = Depends(get_lfx), +): + """Import a flow from Langflow: export -> version -> (deploy to LFX).""" + try: + definition, version = await import_from_langflow( + session, + langflow, + lfx, + data.langflow_flow_id, + name=data.name, + author=data.author, + deploy=data.deploy, + ) + except LangflowNotFound as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except LangflowUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except LangflowError as exc: + raise HTTPException(status_code=502, 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 + 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 FlowImportRead( + definition=_read_definition(definition), version=_read_version(version) + ) + + +@flows_router.get("/runs/{run_id}", response_model=RunRead) +def get_run( + run_id: UUID, + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_db), +): + """Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping).""" + try: + run = store.get_run(session, run_id) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + if tenant_id is not None and run.tenant_id != tenant_id: + raise HTTPException(status_code=404, detail=f"run {run_id} not found") + return _read_run(run) + + +@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)] + + +@approvals_router.post("/{task_id}/decision", response_model=DecisionRead) +async def decide_approval( + task_id: UUID, + data: DecisionRequest, + session: Session = Depends(get_db), + lfx: LfxClient = Depends(get_lfx), +): + """Decide an open task. Approve starts the continuation flow (if any).""" + try: + task = store.get_task(session, task_id) + except store.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + # Resolve the continuation first: bad references fail before any state change. + continuation_version = None + if data.decision == "approve" and task.continuation_flow_id is not None: + try: + continuation_version = store.get_latest_deployed_version( + session, task.continuation_flow_id + ) + except (store.NotFoundError, store.InvalidError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + try: + task = store.decide_task( + session, + task, + decision="approved" if data.decision == "approve" else "rejected", + approver=data.approver, + note=data.note, + ) + except store.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + # Note: the decision is committed before the continuation runs. If LFX is + # down, the task stays decided and the continuation can be started + # manually from the preserved task payload. + continuation_run = None + if continuation_version is not None: + payload = task.payload + if isinstance(payload.get("facts"), dict): + payload = payload["facts"] + try: + continuation_run = await runner.execute_run( + session, + lfx, + continuation_version, + payload=payload, + tenant_id=task.tenant_id, + session_id=f"continuation-of-{task.run_id}", + callback_base_url=data.callback_base_url or settings.callback_base_url, + ) + 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 DecisionRead( + task=_read_task(task), + continuation_run=_read_run(continuation_run) if continuation_run else None, + ) diff --git a/backend/app/flows/deploy.py b/backend/app/flows/deploy.py new file mode 100644 index 0000000..2d6e961 --- /dev/null +++ b/backend/app/flows/deploy.py @@ -0,0 +1,60 @@ +"""Authoring pipeline: Langflow export -> versioned definition -> LFX deploy.""" + +from __future__ import annotations + +from sqlmodel import Session + +from app.models import FlowDefinition, FlowVersion + +from . import store +from .langflow_client import LangflowClient +from .lfx_client import LfxClient +from .schemas import FlowDefinitionCreate, FlowVersionCreate + + +async def import_from_langflow( + session: Session, + langflow: LangflowClient, + lfx: LfxClient, + langflow_flow_id: str, + *, + name: str | None = None, + author: str = "", + deploy: bool = True, +) -> tuple[FlowDefinition, FlowVersion]: + """Export a flow from Langflow, store it as a new version, deploy to LFX. + + Definitions are matched by name (explicit `name` wins, else the exported + name); every import creates a new immutable version. + """ + export = await langflow.export_flow(langflow_flow_id) + flow_name = name or export.get("name") or f"langflow-{langflow_flow_id}" + langflow_json = { + "id": export.get("id", langflow_flow_id), + "name": flow_name, + "description": export.get("description") or "", + "endpoint_name": export.get("endpoint_name"), + "data": export.get("data", {}), + "tags": export.get("tags", []), + "webhook": export.get("webhook", False), + "is_component": export.get("is_component", False), + } + try: + definition = next( + d for d in store.list_definitions(session) if d.name == flow_name + ) + except StopIteration: + definition = store.create_definition( + session, FlowDefinitionCreate(name=flow_name) + ) + version = store.create_version( + session, + definition.id, + FlowVersionCreate(langflow_json=langflow_json, author=author), + ) + if deploy: + uploaded = await lfx.upload_flow( + version.langflow_json, flow_id=str(version.id), replace=True + ) + version = store.mark_deployed(session, version, uploaded.id) + return definition, version diff --git a/backend/app/flows/langflow_client.py b/backend/app/flows/langflow_client.py new file mode 100644 index 0000000..7414eb8 --- /dev/null +++ b/backend/app/flows/langflow_client.py @@ -0,0 +1,66 @@ +"""Async client for the Langflow authoring server (visual designer). + +Only used for the authoring pipeline (list/export flows). Execution always +goes through LFX (see lfx_client.py). +""" + +from __future__ import annotations + +import httpx + + +class LangflowError(Exception): + pass + + +class LangflowNotFound(LangflowError): + pass + + +class LangflowUnavailable(LangflowError): + pass + + +class LangflowClient: + """Thin async wrapper around the Langflow `/api/v1/flows` endpoints.""" + + def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + + def _headers(self) -> dict[str, str]: + return {"x-api-key": self.api_key} if self.api_key else {} + + async def list_flows(self) -> list[dict]: + """List flows (header mode keeps payloads small).""" + try: + async with httpx.AsyncClient( + base_url=self.base_url, timeout=self.timeout, headers=self._headers() + ) as client: + response = await client.get("/api/v1/flows/", params={"header_flows": "true"}) + if response.status_code == 404: + raise LangflowNotFound("flow list not found") + response.raise_for_status() + data = response.json() + return data if isinstance(data, list) else data.get("items", []) + except httpx.HTTPStatusError as exc: + raise LangflowError(f"Langflow error {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc + + async def export_flow(self, langflow_flow_id: str) -> dict: + """Export one flow as Langflow JSON (FlowRead: id/name/description/data).""" + try: + async with httpx.AsyncClient( + base_url=self.base_url, timeout=self.timeout, headers=self._headers() + ) as client: + response = await client.get(f"/api/v1/flows/{langflow_flow_id}") + if response.status_code == 404: + raise LangflowNotFound(f"flow {langflow_flow_id} not found") + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise LangflowError(f"Langflow error {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc 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..c024c34 --- /dev/null +++ b/backend/app/flows/schemas.py @@ -0,0 +1,112 @@ +"""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 + + +class FlowImportRequest(BaseModel): + """Import a flow from the Langflow authoring server by its Langflow id.""" + + langflow_flow_id: str = Field(min_length=1) + name: str | None = Field(default=None, max_length=200) + author: str = Field(default="", max_length=200) + deploy: bool = True + + +class FlowImportRead(BaseModel): + definition: FlowDefinitionRead + version: FlowVersionRead + + +class DecisionRequest(BaseModel): + decision: str = Field(pattern="^(approve|reject)$") + approver: str = Field(default="", max_length=200) + note: str = Field(default="", max_length=2000) + callback_base_url: str | None = None + + +class DecisionRead(BaseModel): + task: ApprovalRead + continuation_run: RunRead | None diff --git a/backend/app/flows/store.py b/backend/app/flows/store.py new file mode 100644 index 0000000..c5039f0 --- /dev/null +++ b/backend/app/flows/store.py @@ -0,0 +1,258 @@ +"""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, tenant_id: str | None = None +) -> list[FlowRun]: + get_definition(session, flow_id) + query = ( + select(FlowRun) + .join(FlowVersion, FlowVersion.id == FlowRun.version_id) + .where(FlowVersion.flow_id == flow_id) + .order_by(FlowRun.created_at.desc()) + ) + if tenant_id is not None: + query = query.where(FlowRun.tenant_id == tenant_id) + return list(session.exec(query).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()) + + +def get_task(session: Session, task_id: UUID) -> ApprovalTask: + task = session.get(ApprovalTask, task_id) + if task is None: + raise NotFoundError(f"approval task {task_id} not found") + return task + + +def decide_task( + session: Session, + task: ApprovalTask, + *, + decision: str, + approver: str = "", + note: str = "", +) -> ApprovalTask: + """Approve or reject an open task. Decided tasks are immutable (409).""" + if task.status != "open": + raise ConflictError(f"task {task.id} is already {task.status}") + if decision not in ("approved", "rejected"): + raise InvalidError(f"unknown decision {decision!r}") + task.status = decision + task.approver = approver + task.decision_note = note + task.decided_at = datetime.now(timezone.utc) + session.add(task) + session.commit() + session.refresh(task) + return task 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/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..6ff89f5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,3 @@ +from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion + +__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"] diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py new file mode 100644 index 0000000..4d45ca3 --- /dev/null +++ b/backend/app/models/flow.py @@ -0,0 +1,90 @@ +"""Flow engine domain models (SQLModel tables).""" + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column, DateTime, UniqueConstraint +from sqlmodel import Field, SQLModel + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _ts_column() -> Column: + return Column(DateTime(timezone=True)) + + +def _json_column() -> Column: + # Generic JSON: JSONB on Postgres, JSON/TEXT elsewhere. + return Column(JSON) + + +class FlowDefinition(SQLModel, table=True): + """A named decision flow (e.g. 'Bauvorhaben pruefen'). Versions live separately.""" + + __tablename__ = "flow_definitions" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(index=True, unique=True, max_length=200) + description: str = Field(default="", max_length=2000) + status: str = Field(default="active", max_length=20) # active | archived + # Flow to run after a human approval on this flow's runs (nullable). + continuation_flow_id: UUID | None = Field( + default=None, foreign_key="flow_definitions.id" + ) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + + +class FlowVersion(SQLModel, table=True): + """An immutable version of a flow: the exact Langflow JSON that runs.""" + + __tablename__ = "flow_versions" + __table_args__ = (UniqueConstraint("flow_id", "version_no"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + flow_id: UUID = Field(foreign_key="flow_definitions.id", index=True) + version_no: int = Field(index=True) + langflow_json: dict = Field(default_factory=dict, sa_column=_json_column()) + # Flow id under which LFX serves this version (set on deploy). + lfx_flow_id: str | None = Field(default=None, max_length=200) + deployed_at: datetime | None = Field(default=None, sa_column=_ts_column()) + author: str = Field(default="", max_length=200) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + + +class FlowRun(SQLModel, table=True): + """One execution of a flow version.""" + + __tablename__ = "flow_runs" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + version_id: UUID = Field(foreign_key="flow_versions.id", index=True) + tenant_id: str = Field(default="default", index=True, max_length=200) + # running | completed | pending_approval | failed + status: str = Field(default="running", index=True, max_length=30) + input: dict | None = Field(default=None, sa_column=_json_column()) + output: dict | None = Field(default=None, sa_column=_json_column()) + session_id: str | None = Field(default=None, max_length=200) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + finished_at: datetime | None = Field(default=None, sa_column=_ts_column()) + + +class ApprovalTask(SQLModel, table=True): + """Human task created by a flow that reached a terminal approval state.""" + + __tablename__ = "approval_tasks" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + run_id: UUID = Field(foreign_key="flow_runs.id", index=True) + version_id: UUID = Field(foreign_key="flow_versions.id", index=True) + tenant_id: str = Field(default="default", index=True, max_length=200) + title: str = Field(default="", max_length=500) + payload: dict = Field(default_factory=dict, sa_column=_json_column()) + continuation_flow_id: UUID | None = Field(default=None) + approver: str | None = Field(default=None, max_length=200) + # open | approved | rejected + status: str = Field(default="open", index=True, max_length=20) + decision_note: str = Field(default="", max_length=2000) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + decided_at: datetime | None = Field(default=None, sa_column=_ts_column()) diff --git a/backend/app/settings.py b/backend/app/settings.py index b8c93fb..8f4b7f5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,11 +1,31 @@ -from pydantic_settings import BaseSettings +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Repo-root .env (shared with the compose stack). Real environment variables +# always take precedence over .env values. +REPO_ROOT = Path(__file__).resolve().parent.parent.parent class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=REPO_ROOT / ".env", extra="ignore") + version: str = "0.1.0" debug: bool = False - model_config = {"env_prefix": "KIRCHE_"} + database_url: str = "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach" + test_database_url: str = ( + "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach_test" + ) + + 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/migrations/README b/backend/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..76c102d --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +from app.models import * # noqa: F401,F403 - register all tables +from app.settings import settings +from sqlmodel import SQLModel + +target_metadata = SQLModel.metadata + +# The URL always comes from settings (repo-root .env or environment), +# alembic.ini's sqlalchemy.url is only a fallback placeholder. +config.set_main_option("sqlalchemy.url", settings.database_url) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..62ae56b --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py b/backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py new file mode 100644 index 0000000..850f2f9 --- /dev/null +++ b/backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py @@ -0,0 +1,108 @@ +"""flow engine tables + +Revision ID: 62ea9a5d8f9d +Revises: +Create Date: 2026-09-06 22:27:07.129788 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '62ea9a5d8f9d' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('flow_definitions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('continuation_flow_id', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True) + op.create_table('flow_versions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('flow_id', sa.Uuid(), nullable=False), + sa.Column('version_no', sa.Integer(), nullable=False), + sa.Column('langflow_json', sa.JSON(), nullable=True), + sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True), + sa.Column('deployed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('flow_id', 'version_no') + ) + op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False) + op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False) + op.create_table('flow_runs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('version_id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False), + sa.Column('input', sa.JSON(), nullable=True), + sa.Column('output', sa.JSON(), nullable=True), + sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False) + op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False) + op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False) + op.create_table('approval_tasks', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('run_id', sa.Uuid(), nullable=False), + sa.Column('version_id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('continuation_flow_id', sa.Uuid(), nullable=True), + sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ), + sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False) + op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False) + op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False) + op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks') + op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks') + op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks') + op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks') + op.drop_table('approval_tasks') + op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs') + op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs') + op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs') + op.drop_table('flow_runs') + op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions') + op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions') + op.drop_table('flow_versions') + op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions') + op.drop_table('flow_definitions') + # ### end Alembic commands ### diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7c2c84d..d8ffaae 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,9 +4,12 @@ version = "0.1.0" description = "Kirche Einfach - KI-gestuetzte Verwaltungsplattform fuer Kirche" requires-python = ">=3.12" dependencies = [ + "alembic>=1.14", "fastapi[standard]>=0.115.0", - "django>=5.1", + "httpx>=0.28", + "psycopg[binary]>=3.2", "pydantic-settings>=2.0", + "sqlmodel>=0.0.24", "uvicorn[standard]>=0.30.0", ] @@ -17,3 +20,16 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "app" module-root = "" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[dependency-groups] +dev = [ + "lfx==1.12.0", + "pytest>=8", + "pytest-asyncio>=0.24", + "pytest-cov>=6", + "respx>=0.22", +] diff --git a/backend/scripts/deploy_flow.py b/backend/scripts/deploy_flow.py new file mode 100644 index 0000000..bcfa3ce --- /dev/null +++ b/backend/scripts/deploy_flow.py @@ -0,0 +1,54 @@ +"""Import a flow from Langflow into our backend via the running API. + +Usage: + uv run python scripts/deploy_flow.py --api http://localhost:8000 \\ + --langflow-id [--name my-flow] [--author jane] [--no-deploy] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.request + + +def post(api: str, path: str, body: dict) -> dict: + request = urllib.request.Request( + api.rstrip("/") + path, + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request) as response: + return {"status": response.status, "body": json.loads(response.read())} + except urllib.error.HTTPError as exc: + return {"status": exc.code, "body": exc.read().decode()[:2000]} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--api", default="http://localhost:8000") + parser.add_argument("--langflow-id", required=True) + parser.add_argument("--name", default=None) + parser.add_argument("--author", default="") + parser.add_argument("--no-deploy", action="store_true") + args = parser.parse_args() + + result = post( + args.api, + "/api/flows/import", + { + "langflow_flow_id": args.langflow_id, + "name": args.name, + "author": args.author, + "deploy": not args.no_deploy, + }, + ) + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 if result["status"] in (200, 201) else 1 + + +if __name__ == "__main__": + sys.exit(main()) 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/scripts/smoke_e2e.py b/backend/scripts/smoke_e2e.py new file mode 100644 index 0000000..90cb3d2 --- /dev/null +++ b/backend/scripts/smoke_e2e.py @@ -0,0 +1,127 @@ +"""Manual end-to-end smoke: real LFX + real API, frontend-shaped calls. + +Usage (from backend/): + TEST_DATABASE_URL=postgresql+psycopg://... uv run python scripts/smoke_e2e.py +Exits non-zero on any unexpected status/body. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +BACKEND = Path(__file__).resolve().parent.parent +VENV_BIN = BACKEND / ".venv" / "bin" +FIXTURES = BACKEND / "tests" / "fixtures" / "flows" + +LFX_PORT = 6004 +API_PORT = 8000 +LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs" + + +def wait_healthy(url: str, timeout: int = 180) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=3) as r: + if r.status == 200: + return + except OSError: + time.sleep(1) + raise RuntimeError(f"never healthy: {url}") + + +def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]: + import httpx + + with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c: + r = c.request(method, path, json=body) + try: + return r.status_code, r.json() + except ValueError: + return r.status_code, r.text + + +def check(name: str, cond: bool, detail: str = "") -> None: + print(f"[{'OK' if cond else 'FAIL'}] {name} {detail}") + if not cond: + raise SystemExit(f"smoke failed: {name} {detail}") + + +def main() -> None: + flow_dir = Path("/tmp/smoke-e2e-flows") + flow_dir.mkdir(exist_ok=True) + (flow_dir / "seed.json").write_text( + (FIXTURES / "simple_rule.json").read_text() + ) + env = { + **os.environ, + "LFX_API_KEY": LFX_KEY, + "DATABASE_URL": os.environ["TEST_DATABASE_URL"], + "LFX_URL": f"http://127.0.0.1:{LFX_PORT}", + "LFX_API_KEY": LFX_KEY, + "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", + } + lfx = subprocess.Popen( + [str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1", + "--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, + ) + uvicorn = subprocess.Popen( + [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", + "--port", str(API_PORT)], + cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env=env, + ) + try: + wait_healthy(f"http://127.0.0.1:{LFX_PORT}/health") + wait_healthy(f"http://127.0.0.1:{API_PORT}/health") + + s, flows = api("GET", "/api/flows") + check("list flows", s == 200 and isinstance(flows, list)) + + s, flow = api("POST", "/api/flows", {"name": "smoke-dach"}) + check("create flow", s == 201, json.dumps(flow)[:120]) + fid = flow["id"] + + fx = json.loads((FIXTURES / "simple_rule.json").read_text()) + s, ver = api( + "POST", f"/api/flows/{fid}/versions", + {"langflow_json": fx, "author": "smoke"}, + ) + check("create version", s == 201, f"v{ver.get('version_no')}") + + s, dep = api("POST", f"/api/flows/{fid}/versions/1/deploy") + check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160]) + + s, run = api( + "POST", f"/api/flows/{fid}/run", + {"input": {"betrag": 120000, "schwelle": 200000}}, + ) + check("run standard", s == 200 and run.get("status") == "completed", + json.dumps(run.get("output"))[:120]) + + s, run2 = api( + "POST", f"/api/flows/{fid}/run", + {"input": {"betrag": 999000, "schwelle": 200000}}, + ) + check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", "")) + + s, tasks = api("GET", "/api/approvals") + check("approvals empty (no callback flow)", s == 200 and tasks == []) + + print("smoke E2E: all green") + finally: + uvicorn.terminate() + lfx.terminate() + + +if __name__ == "__main__": + if "TEST_DATABASE_URL" not in os.environ: + sys.exit("TEST_DATABASE_URL must be set") + main() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7629df3 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,220 @@ +"""Shared pytest fixtures. + +Requires TEST_DATABASE_URL (a dedicated Postgres database, e.g. the +`kirche_einfach_test` database from the compose stack). The URL is mapped +onto DATABASE_URL *before* any app module is imported, so settings and the +alembic env both target the test database. Never point this at production. +""" + +import os +from pathlib import Path + +import pytest + +TEST_DB_URL = os.environ.get("TEST_DATABASE_URL") +if not TEST_DB_URL: + raise RuntimeError( + "TEST_DATABASE_URL is not set. Refusing to run tests without " + "an explicit test database (see .env.example)." + ) + +# Must precede all `app.*` imports: Settings is instantiated at import time. +os.environ["DATABASE_URL"] = TEST_DB_URL + +BACKEND_DIR = Path(__file__).resolve().parent.parent + + +@pytest.fixture(scope="session") +def db_engine(): + """Engine on the test DB, migrated to head once per session.""" + from alembic import command as alembic_command + from alembic.config import Config + + from app import db as app_db + + app_db.reset_engine() + engine = app_db.get_engine() + + cfg = Config() + cfg.set_main_option("script_location", str(BACKEND_DIR / "migrations")) + alembic_command.upgrade(cfg, "head") + + yield engine + + engine.dispose() + app_db.reset_engine() + + +@pytest.fixture() +def session(db_engine): + """Function-scoped session for direct DB access in tests.""" + from sqlmodel import Session + + with Session(db_engine) as s: + yield s + + +@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() +async def client(): + """ASGI test client for the FastAPI app.""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + 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() +def override_langflow(): + """Replace the Langflow dependency with a fake (real LFX stays untouched). + + Usage: override_langflow(export_doc) -- the fake export_flow() returns the + doc, or raises LangflowNotFound/Unavailable for status=404/0. + """ + from app.flows.api import get_langflow + from app.flows.langflow_client import LangflowNotFound, LangflowUnavailable + from app.main import app + + def _override(export_doc=None, status=200): + class FakeLangflow: + async def export_flow(self, flow_id): + if status == 404: + raise LangflowNotFound(f"flow {flow_id} not found") + if status == 0: + raise LangflowUnavailable("Langflow unreachable") + return export_doc + + async def list_flows(self): + return [{"id": "fake-1", "name": "fake"}] + + app.dependency_overrides[get_langflow] = lambda: FakeLangflow() + + yield _override + app.dependency_overrides.pop(get_langflow, None) + + +@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_approvals.py b/backend/tests/test_approvals.py new file mode 100644 index 0000000..bc8729d --- /dev/null +++ b/backend/tests/test_approvals.py @@ -0,0 +1,255 @@ +"""Step 5 tests: decisions + continuations, tenant isolation, error paths.""" + +import json +from pathlib import Path +from uuid import UUID, uuid4 + +from app.flows.schemas import ApprovalCreate + +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): + response = await client.post("/api/flows", json={"name": name}) + assert response.status_code == 201, response.text + return response.json() + + +async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"): + response = await client.post( + f"/api/flows/{flow_id}/versions", + json={"langflow_json": load_fixture(fixture)}, + ) + assert response.status_code == 201, response.text + response = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") + assert response.status_code == 200, response.text + return response.json() + + +async def _handoff_task(client, live_server, continuation_flow_id=None): + """Run approval_handoff over threshold; returns (run, task).""" + flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}") + await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json") + body = { + "input": {"betrag": 250000, "schwelle": 200000}, + "callback_base_url": live_server, + } + if continuation_flow_id is not None: + body["continuation_flow_id"] = continuation_flow_id + run = ( + await client.post(f"/api/flows/{flow['id']}/run", json=body) + ).json() + assert run["status"] == "pending_approval" + tasks = (await client.get("/api/approvals")).json() + task = next(t for t in tasks if t["run_id"] == run["id"]) + return run, task + + +async def test_approve_without_continuation(client, lfx_server, live_server): + _, task = await _handoff_task(client, live_server) + response = await client.post( + f"/api/approvals/{task['id']}/decision", + json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["task"]["status"] == "approved" + assert body["task"]["approver"] == "sachbearbeiter" + assert body["task"]["decided_at"] is not None + assert body["continuation_run"] is None + + +async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server): + cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}") + await _version_and_deploy(client, cont["id"], fixture="simple_rule.json") + + _, task = await _handoff_task( + client, live_server, continuation_flow_id=cont["id"] + ) + assert task["continuation_flow_id"] == cont["id"] + + response = await client.post( + f"/api/approvals/{task['id']}/decision", + json={"decision": "approve", "approver": "chef"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["task"]["status"] == "approved" + cont_run = body["continuation_run"] + assert cont_run is not None + assert cont_run["status"] == "completed" + assert cont_run["tenant_id"] == task["tenant_id"] + # Facts (betrag 250000 > Schwelle) flowed into the continuation. + assert "Sonderfall" in cont_run["output"]["result"] + + +async def test_reject_task(client, lfx_server, live_server): + _, task = await _handoff_task(client, live_server) + response = await client.post( + f"/api/approvals/{task['id']}/decision", + json={"decision": "reject", "approver": "chef", "note": "zu teuer"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["task"]["status"] == "rejected" + assert body["task"]["decision_note"] == "zu teuer" + assert body["continuation_run"] is None + + +async def test_decision_guards(client, lfx_server, live_server): + _, task = await _handoff_task(client, live_server) + url = f"/api/approvals/{task['id']}/decision" + + response = await client.post(url, json={"decision": "approve"}) + assert response.status_code == 200 + response = await client.post(url, json={"decision": "reject"}) + assert response.status_code == 409 + + response = await client.post( + f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"} + ) + assert response.status_code == 404 + + _, task2 = await _handoff_task(client, live_server) + response = await client.post( + f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"} + ) + assert response.status_code == 422 + + response = await client.post( + f"/api/approvals/{task2['id']}/decision", + json={"decision": "approve", "note": "x"}, + ) + # continuation unset -> fine, resolves to no continuation run + assert response.status_code == 200 + + +async def test_decision_bad_continuation_reference(client, session): + from app.flows import store as flow_store + + flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}") + version = ( + await client.post( + f"/api/flows/{flow['id']}/versions", + json={"langflow_json": load_fixture("simple_rule.json")}, + ) + ).json() + run = flow_store.create_run( + session, + flow_store.get_version(session, UUID(version["id"])), + input={}, + ) + task = flow_store.create_approval_task( + session, ApprovalCreate(run_id=run.id, title="t") + ) + # plant unknown continuation directly (store allows it pre-validation) + task.continuation_flow_id = uuid4() + session.add(task) + session.commit() + + response = await client.post( + f"/api/approvals/{task.id}/decision", json={"decision": "approve"} + ) + assert response.status_code == 422 + + +async def test_tenant_isolation(client, lfx_server): + flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}") + await _version_and_deploy(client, flow["id"]) + + for tenant in ("pfarramt-a", "pfarramt-b"): + response = await client.post( + f"/api/flows/{flow['id']}/run", + json={"input": {"betrag": 1}, "tenant_id": tenant}, + ) + assert response.status_code == 200 + + response = await client.get( + f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"} + ) + runs_a = response.json() + assert len(runs_a) == 1 + assert runs_a[0]["tenant_id"] == "pfarramt-a" + + response = await client.get(f"/api/flows/{flow['id']}/runs") + assert len(response.json()) == 2 + + # Cross-tenant read with scoping 404s. + response = await client.get( + f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"} + ) + assert response.status_code == 404 + response = await client.get( + f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"} + ) + assert response.status_code == 200 + + +async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch): + from app.settings import settings + + flow = await _create_flow(client, f"down-{uuid4().hex[:8]}") + await client.post( + f"/api/flows/{flow['id']}/versions", + json={"langflow_json": load_fixture("simple_rule.json")}, + ) + monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9") + response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy") + assert response.status_code == 503 + + # Deploy for real, then break the URL again for the run call. + monkeypatch.setattr(settings, "lfx_url", lfx_server) + deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy") + assert deployed.status_code == 200 + monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9") + response = await client.post( + f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}} + ) + assert response.status_code == 503 + + +async def test_lfx_drift_maps_to_502(client, lfx_server): + from app.flows.lfx_client import LfxClient + from app.settings import settings + + flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}") + deployed = await _version_and_deploy(client, flow["id"]) + await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow( + deployed["lfx_flow_id"] + ) + response = await client.post( + f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}} + ) + assert response.status_code == 502 + + +async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server): + flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}") + bogus = { + "name": "bogus", + "data": { + "nodes": [ + { + "id": "Nope-x1", + "data": { + "id": "Nope-x1", + "type": "Nope", + "showNode": True, + "node": {"template": {}, "outputs": []}, + }, + "type": "genericNode", + } + ], + "edges": [], + }, + } + response = await client.post( + f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus} + ) + assert response.status_code == 201, response.text + response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy") + assert response.status_code == 422 diff --git a/backend/tests/test_deploy.py b/backend/tests/test_deploy.py new file mode 100644 index 0000000..7af0843 --- /dev/null +++ b/backend/tests/test_deploy.py @@ -0,0 +1,109 @@ +"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real).""" + +import json +from pathlib import Path +from uuid import uuid4 + +FIXTURES = Path(__file__).parent / "fixtures" / "flows" +LANGFLOW_ID = "11111111-2222-4333-8444-555555555555" + + +def _export_doc(name="importiert"): + fixture = json.loads((FIXTURES / "simple_rule.json").read_text()) + return { + "id": LANGFLOW_ID, + "name": name, + "description": "aus Langflow", + "endpoint_name": None, + "data": fixture["data"], + "tags": [], + "webhook": False, + "is_component": False, + } + + +async def test_import_creates_definition_version_and_deploys( + client, lfx_server, override_langflow +): + override_langflow(_export_doc()) + response = await client.post( + "/api/flows/import", + json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["definition"]["name"] == "importiert" + assert body["version"]["version_no"] == 1 + assert body["version"]["author"] == "admin" + assert body["version"]["lfx_flow_id"] == body["version"]["id"] + assert body["version"]["deployed_at"] is not None + assert body["version"]["langflow_json"]["data"]["nodes"] + + # Imported flow actually runs on LFX. + definition_id = body["definition"]["id"] + response = await client.post( + f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}} + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "completed" + + +async def test_import_reuses_definition_and_bumps_version( + client, lfx_server, override_langflow +): + override_langflow(_export_doc()) + first = await client.post( + "/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} + ) + second = await client.post( + "/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} + ) + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json()["definition"]["id"] == second.json()["definition"]["id"] + assert second.json()["version"]["version_no"] == 2 + + +async def test_import_with_explicit_name(client, lfx_server, override_langflow): + override_langflow(_export_doc(name="langflow-name")) + response = await client.post( + "/api/flows/import", + json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"}, + ) + assert response.status_code == 201, response.text + assert response.json()["definition"]["name"] == "wunschname" + + +async def test_import_without_deploy(client, override_langflow): + override_langflow(_export_doc()) + response = await client.post( + "/api/flows/import", + json={"langflow_flow_id": LANGFLOW_ID, "deploy": False}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["version"]["lfx_flow_id"] is None + assert body["version"]["deployed_at"] is None + + +async def test_import_langflow_not_found(client, override_langflow): + override_langflow(status=404) + response = await client.post( + "/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} + ) + assert response.status_code == 404 + + +async def test_import_langflow_unreachable(client, override_langflow): + override_langflow(status=0) + response = await client.post( + "/api/flows/import", json={"langflow_flow_id": str(uuid4())} + ) + assert response.status_code == 503 + + +async def test_list_langflow_flows(client, override_langflow): + override_langflow(_export_doc()) + response = await client.get("/api/flows/langflow/available") + assert response.status_code == 200 + assert response.json() == [{"id": "fake-1", "name": "fake"}] 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 diff --git a/backend/tests/test_langflow_client.py b/backend/tests/test_langflow_client.py new file mode 100644 index 0000000..ad93ee4 --- /dev/null +++ b/backend/tests/test_langflow_client.py @@ -0,0 +1,52 @@ +"""LangflowClient HTTP mapping tests (Langflow mocked via respx, no LFX).""" + +import httpx +import pytest +import respx + +from app.flows.langflow_client import ( + LangflowClient, + LangflowNotFound, + LangflowUnavailable, +) + +BASE = "https://langflow.test" + + +def _client(): + return LangflowClient(BASE, api_key="test-key") + + +@respx.mock +async def test_export_flow_sends_api_key_and_returns_doc(): + doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}} + route = respx.get(f"{BASE}/api/v1/flows/abc").mock( + return_value=httpx.Response(200, json=doc) + ) + result = await _client().export_flow("abc") + assert result == doc + assert route.called + assert route.calls[0].request.headers["x-api-key"] == "test-key" + + +@respx.mock +async def test_export_flow_not_found(): + respx.get(f"{BASE}/api/v1/flows/missing").mock( + return_value=httpx.Response(404, json={"detail": "nope"}) + ) + with pytest.raises(LangflowNotFound): + await _client().export_flow("missing") + + +@respx.mock +async def test_list_flows_unwraps_items(): + respx.get(f"{BASE}/api/v1/flows/").mock( + return_value=httpx.Response(200, json={"items": [{"id": "1"}]}) + ) + assert await _client().list_flows() == [{"id": "1"}] + + +async def test_unreachable_raises_unavailable(): + client = LangflowClient("https://langflow.dead", api_key="k") + with pytest.raises(LangflowUnavailable): + await client.export_flow("whatever") diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..40a82de --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,82 @@ +"""Model-level tests: defaults, constraints, JSON roundtrip, linkage.""" + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlmodel import select + +from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion + + +def test_flow_definition_defaults(session): + d = FlowDefinition(name="bauvorhaben") + session.add(d) + session.commit() + session.refresh(d) + assert d.id is not None + assert d.status == "active" + assert d.continuation_flow_id is None + assert d.created_at is not None + assert d.created_at.tzinfo is not None + + +def test_flow_definition_name_unique(session): + session.add(FlowDefinition(name="doppelt")) + session.commit() + session.add(FlowDefinition(name="doppelt")) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + +def test_flow_version_unique_per_flow(session): + d = FlowDefinition(name="v-flow") + session.add(d) + session.commit() + session.add(FlowVersion(flow_id=d.id, version_no=1, author="a")) + session.commit() + session.add(FlowVersion(flow_id=d.id, version_no=1, author="b")) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + session.add(FlowVersion(flow_id=d.id, version_no=2, author="b")) + session.commit() + + +def test_flow_version_json_roundtrip(session): + payload = {"data": {"nodes": [{"id": "n1"}], "edges": []}, "name": "x"} + d = FlowDefinition(name="json-flow") + session.add(d) + session.commit() + v = FlowVersion(flow_id=d.id, version_no=1, langflow_json=payload) + session.add(v) + session.commit() + session.refresh(v) + assert v.langflow_json == payload + assert v.deployed_at is None + assert v.lfx_flow_id is None + + +def test_run_and_task_linkage_with_tenant_default(session): + d = FlowDefinition(name="linked") + session.add(d) + session.commit() + v = FlowVersion(flow_id=d.id, version_no=1) + session.add(v) + session.commit() + run = FlowRun(version_id=v.id, input={"a": 1}) + session.add(run) + session.commit() + task = ApprovalTask(run_id=run.id, version_id=v.id, title="bitte pruefen") + session.add(task) + session.commit() + + assert run.tenant_id == "default" + assert run.status == "running" + assert task.tenant_id == "default" + assert task.status == "open" + + runs = session.exec(select(FlowRun).where(FlowRun.version_id == v.id)).all() + assert len(runs) == 1 + tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all() + assert len(tasks) == 1 + assert tasks[0].payload == {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 721cd8a..a8ebbdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,39 @@ -function App() { +import { useState } from "react"; +import AdminTab from "./components/AdminTab"; +import ApprovalsTab from "./components/ApprovalsTab"; +import FlowsTab from "./components/FlowsTab"; + +type Tab = "flows" | "approvals" | "admin"; + +const tabStyle = (active: boolean): React.CSSProperties => ({ + fontWeight: active ? "bold" : "normal", + textDecoration: active ? "underline" : "none", +}); + +export default function App() { + const [tab, setTab] = useState("flows"); + return ( -
+

Kirche Einfach

KI-gestützte Verwaltungsplattform für Kirche

+ + {tab === "flows" && } + {tab === "approvals" && } + {tab === "admin" && }
); } - -export default App; diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..1dbc676 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,138 @@ +/* Typed client for the kirche-einfach API (served via vite /api proxy). */ + +export interface FlowDefinition { + id: string; + name: string; + description: string; + status: string; + continuation_flow_id: string | null; + created_at: string; +} + +export interface FlowVersion { + id: string; + flow_id: string; + version_no: number; + langflow_json: unknown; + lfx_flow_id: string | null; + deployed_at: string | null; + author: string; + created_at: string; +} + +export interface RunOutput { + result?: string; + component?: string; + type?: string; + error?: string; + [key: string]: unknown; +} + +export interface Run { + id: string; + version_id: string; + tenant_id: string; + status: string; + input: unknown; + output: RunOutput | null; + session_id: string | null; + created_at: string; + finished_at: string | null; +} + +export interface ApprovalTask { + id: string; + run_id: string; + version_id: string; + tenant_id: string; + title: string; + payload: unknown; + continuation_flow_id: string | null; + approver: string | null; + status: string; + decision_note: string; + created_at: string; + decided_at: string | null; +} + +export interface Decision { + task: ApprovalTask; + continuation_run: Run | null; +} + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...init, + }); + if (!res.ok) { + let detail = res.statusText; + try { + const body: unknown = await res.json(); + if ( + typeof body === "object" && + body !== null && + "detail" in body && + typeof (body as { detail: unknown }).detail === "string" + ) { + detail = (body as { detail: string }).detail; + } + } catch { + /* keep statusText */ + } + throw new ApiError(res.status, detail); + } + return (await res.json()) as T; +} + +export const LANGFLOW_URL = + (import.meta.env.VITE_LANGFLOW_URL as string | undefined) ?? + "http://localhost:7860"; + +export const api = { + listFlows: () => req("/api/flows"), + listVersions: (flowId: string) => + req(`/api/flows/${flowId}/versions`), + deployVersion: (flowId: string, versionNo: number) => + req(`/api/flows/${flowId}/versions/${versionNo}/deploy`, { + method: "POST", + }), + runFlow: (flowId: string, input: unknown, tenantId: string) => + req(`/api/flows/${flowId}/run`, { + method: "POST", + body: JSON.stringify({ input, tenant_id: tenantId }), + }), + listRuns: (flowId: string) => req(`/api/flows/${flowId}/runs`), + listApprovals: () => req("/api/approvals"), + decide: ( + taskId: string, + decision: "approve" | "reject", + approver: string, + note: string, + ) => + req(`/api/approvals/${taskId}/decision`, { + method: "POST", + body: JSON.stringify({ decision, approver, note }), + }), + importFlow: (langflowFlowId: string, name?: string) => + req<{ definition: FlowDefinition; version: FlowVersion }>( + "/api/flows/import", + { + method: "POST", + body: JSON.stringify({ + langflow_flow_id: langflowFlowId, + ...(name ? { name } : {}), + }), + }, + ), + listLangflow: () => + req<{ id: string; name: string }[]>("/api/flows/langflow/available"), +}; diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx new file mode 100644 index 0000000..23908ac --- /dev/null +++ b/frontend/src/components/AdminTab.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useState } from "react"; +import { + ApiError, + LANGFLOW_URL, + api, + type FlowDefinition, + type FlowVersion, +} from "../api"; + +export default function AdminTab() { + const [flows, setFlows] = useState([]); + const [versions, setVersions] = useState>({}); + const [langflowId, setLangflowId] = useState(""); + const [importName, setImportName] = useState(""); + const [error, setError] = useState(""); + const [info, setInfo] = useState(""); + + const load = useCallback(async () => { + try { + const list = await api.listFlows(); + setFlows(list); + const all: Record = {}; + for (const f of list) { + all[f.id] = await api.listVersions(f.id); + } + setVersions(all); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function onImport() { + setError(""); + setInfo(""); + try { + const result = await api.importFlow( + langflowId, + importName.trim() ? importName.trim() : undefined, + ); + setInfo( + `Importiert: ${result.definition.name} (Version ${result.version.version_no})`, + ); + setLangflowId(""); + setImportName(""); + await load(); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + } + + async function onDeploy(flowId: string, versionNo: number) { + setError(""); + setInfo(""); + try { + const version = await api.deployVersion(flowId, versionNo); + setInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`); + await load(); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + } + + return ( +
+

Administration

+ {error &&

{error}

} + {info &&

{info}

} +

+ Flows werden visuell in Langflow entworfen:{" "} + + Langflow öffnen + +

+

Import aus Langflow

+
+ setLangflowId(e.target.value)} + placeholder="Langflow Flow-ID (UUID)" + style={{ flex: 1, fontFamily: "monospace" }} + /> + setImportName(e.target.value)} + placeholder="Name (optional)" + /> + +
+

Flows + Versionen

+ {flows.length === 0 &&

Keine Flows vorhanden.

} +
    + {flows.map((f) => ( +
  • + {f.name} +
      + {(versions[f.id] ?? []).map((v) => ( +
    • + v{v.version_no} ·{" "} + {v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "} + {v.author || "–"}{" "} + +
    • + ))} +
    +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/ApprovalsTab.tsx b/frontend/src/components/ApprovalsTab.tsx new file mode 100644 index 0000000..8c9c57e --- /dev/null +++ b/frontend/src/components/ApprovalsTab.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError, api, type ApprovalTask } from "../api"; + +export default function ApprovalsTab() { + const [tasks, setTasks] = useState([]); + const [approver, setApprover] = useState(""); + const [note, setNote] = useState(""); + const [error, setError] = useState(""); + const [busyId, setBusyId] = useState(""); + + const load = useCallback(async () => { + try { + setTasks(await api.listApprovals()); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function decide(taskId: string, decision: "approve" | "reject") { + setBusyId(taskId); + setError(""); + try { + const result = await api.decide(taskId, decision, approver, note); + setTasks((prev) => + prev.map((t) => (t.id === taskId ? result.task : t)), + ); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } finally { + setBusyId(""); + } + } + + const open = tasks.filter((t) => t.status === "open"); + const done = tasks.filter((t) => t.status !== "open"); + + return ( +
+

Freigaben

+ {error &&

{error}

} +
+ setApprover(e.target.value)} + placeholder="Name (Bearbeiter)" + /> + setNote(e.target.value)} + placeholder="Notiz" + style={{ flex: 1 }} + /> + +
+

Offen ({open.length})

+ {open.length === 0 &&

Keine offenen Freigaben.

} +
    + {open.map((t) => ( +
  • + {t.title || t.id} · {t.tenant_id} +
    + {JSON.stringify(t.payload)} +
    + {" "} + +
  • + ))} +
+

Entschieden ({done.length})

+
    + {done.map((t) => ( +
  • + {t.status} · {t.title || t.id} ·{" "} + {t.approver ?? "–"} + {t.decision_note ? ` · „${t.decision_note}“` : ""} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/FlowsTab.tsx b/frontend/src/components/FlowsTab.tsx new file mode 100644 index 0000000..98cfdbe --- /dev/null +++ b/frontend/src/components/FlowsTab.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError, api, type FlowDefinition, type Run } from "../api"; + +const inputStyle: React.CSSProperties = { + width: "100%", + fontFamily: "monospace", + fontSize: 13, +}; + +export default function FlowsTab() { + const [flows, setFlows] = useState([]); + const [flowId, setFlowId] = useState(""); + const [inputJson, setInputJson] = useState( + '{\n "betrag": 120000,\n "schwelle": 200000\n}', + ); + const [tenant, setTenant] = useState("default"); + const [runs, setRuns] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const loadFlows = useCallback(async () => { + try { + const list = await api.listFlows(); + setFlows(list); + const first = list[0]; + if (first && !flowId) setFlowId(first.id); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, [flowId]); + + const loadRuns = useCallback(async (id: string) => { + if (!id) return; + try { + setRuns(await api.listRuns(id)); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void loadFlows(); + }, [loadFlows]); + + useEffect(() => { + void loadRuns(flowId); + }, [flowId, loadRuns]); + + async function onRun() { + setBusy(true); + setError(""); + try { + const payload: unknown = JSON.parse(inputJson); + const run = await api.runFlow(flowId, payload, tenant); + setRuns((prev) => [run, ...prev]); + } catch (e) { + if (e instanceof SyntaxError) setError("Ungültiges JSON in der Eingabe."); + else setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+

Vorgang starten

+ {error &&

{error}

} +
+ + setTenant(e.target.value)} + placeholder="Mandant" + style={{ width: 160 }} + /> + +
+