+
+
diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py
index ca1062f..df840dd 100644
--- a/backend/app/flows/api.py
+++ b/backend/app/flows/api.py
@@ -17,12 +17,13 @@ from pathlib import Path
from typing import Annotated
from uuid import UUID
-from fastapi import APIRouter, Depends, Header, HTTPException, Query
+from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin
from app.db import get_db
+from app.middleware import limiter, run_limit
from app.models import User
from app.settings import settings
@@ -50,6 +51,7 @@ from .schemas import (
FlowVersionCreate,
FlowVersionRead,
InputSchemaRead,
+ NamedRunRead,
RunRead,
RunRequest,
SyncReport,
@@ -58,6 +60,7 @@ from .sync import lfx_id_for
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
+runs_router = APIRouter(prefix="/api/runs", tags=["runs"])
logger = logging.getLogger(__name__)
@@ -211,7 +214,9 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
@flows_router.post("/{flow_id}/run", response_model=RunRead)
+@limiter.limit(run_limit)
async def run_flow(
+ request: Request,
flow_id: UUID,
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
@@ -279,6 +284,18 @@ async def _run_stream(
return StreamingResponse(event_gen(), media_type="text/event-stream")
+@runs_router.get("", response_model=list[NamedRunRead])
+def list_all_runs(
+ session: Annotated[Session, Depends(get_db)],
+ user: Annotated[User, Depends(get_current_user)],
+):
+ """All runs of the session tenant with flow names (single query)."""
+ return [
+ NamedRunRead(flow_name=name, run=_read_run(run))
+ for run, name in store.list_all_runs(session, user.tenant_id)
+ ]
+
+
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(
flow_id: UUID,
diff --git a/backend/app/flows/base_client.py b/backend/app/flows/base_client.py
index c49216d..10b7286 100644
--- a/backend/app/flows/base_client.py
+++ b/backend/app/flows/base_client.py
@@ -2,12 +2,19 @@
Consolidates base_url stripping, header building, per-request client
construction, and HTTPError → domain-error mapping. Subclasses only
-declare their error types and endpoint calls.
+declare their error types and endpoint calls. Transport-level failures
+(connect/timeout, never 4xx) retry with exponential backoff via tenacity.
"""
from __future__ import annotations
import httpx
+from tenacity import (
+ retry,
+ retry_if_exception_type,
+ stop_after_attempt,
+ wait_exponential,
+)
class BaseApiClient:
@@ -25,3 +32,13 @@ class BaseApiClient:
return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
)
+
+
+def transport_retry(func): # type: ignore[no-untyped-def]
+ """Retry transport errors 3x with backoff; HTTP status errors pass through."""
+ return retry(
+ stop=stop_after_attempt(3),
+ wait=wait_exponential(multiplier=0.5, max=4),
+ retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
+ reraise=True,
+ )(func)
diff --git a/backend/app/flows/export.py b/backend/app/flows/export.py
index 7910269..1235604 100644
--- a/backend/app/flows/export.py
+++ b/backend/app/flows/export.py
@@ -10,7 +10,6 @@ import hashlib
import json
import os
import re
-import tempfile
from pathlib import Path
FLOWS_GLOB = "*.json"
@@ -20,18 +19,32 @@ FLOWS_GLOB = "*.json"
#: validated, or shown in the frontend.
IGNORE_FILENAME = ".flowsignore"
+# mtime-keyed cache: the watcher re-reads per event, the file rarely changes.
+_ignore_cache: dict[str, tuple[float, set[str]]] = {}
+
def ignored_stems(flows_dir: str | Path) -> set[str]:
"""Stems excluded from the pipeline (empty when no ignore file)."""
+ key = str(Path(flows_dir))
+ try:
+ mtime = (Path(flows_dir) / IGNORE_FILENAME).stat().st_mtime
+ except OSError:
+ _ignore_cache.pop(key, None)
+ return set()
+ cached = _ignore_cache.get(key)
+ if cached is not None and cached[0] == mtime:
+ return cached[1]
try:
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
except OSError:
return set()
- return {
+ stems = {
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
}
+ _ignore_cache[key] = (mtime, stems)
+ return stems
def filename_stem(name: str) -> str:
@@ -97,25 +110,16 @@ def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool
def write_flow_bytes(target: Path, doc: dict) -> Path:
"""Write a flow document to an explicit path atomically (tmp + rename).
- The file ends up world-readable (0o644): mkstemp creates 0o600, which
- would lock out the other containers (Langflow/LFX run as different
- users and crash on unreadable seed files).
+ The file ends up world-readable (0o644): atomicwrites follows the
+ umask, which would lock out the other containers (Langflow/LFX run
+ as different users and crash on unreadable seed files).
"""
+ from atomicwrites import atomic_write
+
target.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp_name = tempfile.mkstemp(
- dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp"
- )
- try:
- with os.fdopen(fd, "wb") as tmp:
- tmp.write(canonical_bytes(doc))
- os.replace(tmp_name, target)
- os.chmod(target, 0o644)
- except BaseException:
- try:
- os.unlink(tmp_name)
- except OSError:
- pass
- raise
+ with atomic_write(target, mode="wb", overwrite=True) as tmp:
+ tmp.write(canonical_bytes(doc))
+ os.chmod(target, 0o644)
return target
diff --git a/backend/app/flows/langflow_client.py b/backend/app/flows/langflow_client.py
index 9a3d687..02d4d31 100644
--- a/backend/app/flows/langflow_client.py
+++ b/backend/app/flows/langflow_client.py
@@ -8,7 +8,7 @@ from __future__ import annotations
import httpx
-from .base_client import BaseApiClient
+from .base_client import BaseApiClient, transport_retry
class LangflowError(Exception):
@@ -29,6 +29,7 @@ class LangflowClient(BaseApiClient):
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key)
+ @transport_retry
async def list_flows(self) -> list[dict]:
"""List flows (header mode keeps payloads small)."""
try:
@@ -44,6 +45,7 @@ class LangflowClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
+ @transport_retry
async def export_flow(self, langflow_flow_id: str) -> dict:
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
try:
@@ -58,6 +60,7 @@ class LangflowClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
+ @transport_retry
async def upsert_flow(self, doc: dict) -> dict:
"""Create or update a flow in Langflow by name (authoring UI refresh).
diff --git a/backend/app/flows/lfx_client.py b/backend/app/flows/lfx_client.py
index ed2b156..66a2fc8 100644
--- a/backend/app/flows/lfx_client.py
+++ b/backend/app/flows/lfx_client.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
import httpx
-from .base_client import BaseApiClient
+from .base_client import BaseApiClient, transport_retry
class LfxError(Exception):
@@ -56,6 +56,7 @@ class LfxClient(BaseApiClient):
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key)
+ @transport_retry
async def health(self) -> dict:
try:
async with self._client() as client:
@@ -65,6 +66,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
+ @transport_retry
async def list_flows(self) -> list[dict]:
try:
async with self._client() as client:
@@ -76,6 +78,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
+ @transport_retry
async def upload_flow(
self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False
) -> LfxUploadResult:
@@ -140,6 +143,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
+ @transport_retry
async def flow_info(self, lfx_flow_id: str) -> dict:
try:
async with self._client() as client:
@@ -151,6 +155,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
+ @transport_retry
async def delete_flow(self, lfx_flow_id: str) -> None:
try:
async with self._client() as client:
diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py
index f2616a4..1d263d1 100644
--- a/backend/app/flows/schemas.py
+++ b/backend/app/flows/schemas.py
@@ -76,6 +76,13 @@ class RunRead(BaseModel):
finished_at: datetime | None
+class NamedRunRead(BaseModel):
+ """A run with its flow name (GET /api/runs aggregate, kills N+1)."""
+
+ flow_name: str
+ run: RunRead
+
+
class ApprovalCreate(BaseModel):
"""Callback contract: a flow reports a terminal approval state."""
diff --git a/backend/app/flows/store.py b/backend/app/flows/store.py
index 54fe291..385a52d 100644
--- a/backend/app/flows/store.py
+++ b/backend/app/flows/store.py
@@ -129,12 +129,18 @@ def validate_flow_json(flow_json: dict) -> None:
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
-def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
+def create_version(
+ session: Session,
+ flow_id: UUID,
+ data: FlowVersionCreate,
+ *,
+ version_no: int | None = None,
+) -> FlowVersion:
get_definition(session, flow_id)
validate_flow_json(data.langflow_json)
version = FlowVersion(
flow_id=flow_id,
- version_no=next_version_no(session, flow_id),
+ version_no=version_no if version_no is not None else next_version_no(session, flow_id),
langflow_json=data.langflow_json,
author=data.author,
# Single choke point: sync, import, and API creation all cache the
@@ -256,6 +262,18 @@ def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) ->
return list(session.exec(query).all())
+def list_all_runs(session: Session, tenant_id: str) -> list[tuple[FlowRun, str]]:
+ """All runs of a tenant with flow names (single query, kills N+1)."""
+ rows = session.exec(
+ select(FlowRun, FlowDefinition.name)
+ .join(FlowVersion, FlowVersion.id == FlowRun.version_id)
+ .join(FlowDefinition, FlowDefinition.id == FlowVersion.flow_id)
+ .where(FlowRun.tenant_id == tenant_id)
+ .order_by(FlowRun.created_at.desc())
+ ).all()
+ return [(run, name) for run, name in rows]
+
+
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
run = session.get(FlowRun, data.run_id)
if run is None:
diff --git a/backend/app/flows/sync.py b/backend/app/flows/sync.py
index d47af34..fd8304e 100644
--- a/backend/app/flows/sync.py
+++ b/backend/app/flows/sync.py
@@ -64,8 +64,8 @@ async def sync_document(
"skipped" (content unchanged). Raises store.ConflictError on name
clashes, store.InvalidError on invalid JSON, LfxError when deploy fails.
With deploy=False a version is recorded without touching LFX.
+ Validation lives in store.create_version (single choke point).
"""
- store.validate_flow_json(doc)
name = doc.get("name") or Path(source_filename).stem
with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename)
@@ -102,9 +102,8 @@ async def sync_document(
session,
definition.id,
FlowVersionCreate(langflow_json=doc, author=author),
+ version_no=version_no,
)
- if version.version_no != version_no: # pragma: no cover - lock prevents this
- raise store.ConflictError(f"concurrent version write for flow {definition.id}")
version = store.mark_deployed(session, version, uploaded.id)
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
try:
diff --git a/backend/app/main.py b/backend/app/main.py
index 47fb2cb..3859851 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,13 +4,16 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+from slowapi.errors import RateLimitExceeded
+from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router
-from .flows.api import approvals_router, flows_router
+from .flows.api import approvals_router, flows_router, runs_router
from .flows.sync import format_report, run_sync_blocking, watch_forever
-from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
+from .middleware import SecurityHeadersMiddleware, limiter
from .settings import settings
logger = logging.getLogger(__name__)
@@ -64,12 +67,23 @@ app = FastAPI(
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
# IPs (throttle) and absolute URLs are correct. The API is not directly
# internet-reachable (proxy-only), so trusting them is safe.
+app.state.limiter = limiter
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
-app.add_middleware(RunThrottleMiddleware)
+app.add_middleware(SlowAPIMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
+
+@app.exception_handler(RateLimitExceeded)
+async def _throttle_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
+ response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
+ retry_after = getattr(exc, "retry_after", None) or "60"
+ response.headers["Retry-After"] = str(retry_after)
+ return response
+
+
app.include_router(flows_router)
app.include_router(approvals_router)
+app.include_router(runs_router)
app.include_router(access_router)
app.include_router(auth_router)
diff --git a/backend/app/middleware.py b/backend/app/middleware.py
index eb38321..0dcfa86 100644
--- a/backend/app/middleware.py
+++ b/backend/app/middleware.py
@@ -1,79 +1,59 @@
-"""Edge hardening: run throttling, security headers.
+"""Edge hardening: run throttling (slowapi), security headers (secure).
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
-main.py). All limits are settings-driven so tests can shrink them. State
-is in-process memory: correct for the single-replica SQLite deployment;
-revisit with Redis if the API ever scales horizontally.
+main.py). The run throttle is settings-driven so tests can shrink it.
+State is in-process memory: correct for the single-replica SQLite
+deployment; revisit with Redis if the API ever scales horizontally.
"""
from __future__ import annotations
-import time
-from collections import defaultdict
-
+from slowapi import Limiter
+from slowapi.util import get_remote_address
from starlette.datastructures import MutableHeaders
-from starlette.requests import Request
-from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.settings import settings
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
-# per client IP and minute.
-RUN_PATH_SUFFIX = "/run"
-THROTTLE_WINDOW_SECONDS = 60.0
+# per client IP and minute, limit read live from settings so tests can
+# shrink it without re-creating the app.
+limiter = Limiter(
+ key_func=get_remote_address,
+ storage_uri="memory://",
+ default_limits=[],
+)
-_hits: dict[str, list[float]] = defaultdict(list)
+
+def run_limit() -> str:
+ return f"{settings.run_throttle_per_minute}/minute"
def reset_throttle() -> None:
"""Drop all counters (tests)."""
- _hits.clear()
-
-
-def _client_ip(request: Request) -> str:
- # Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
- # scope["client"] from X-Forwarded-For.
- if request.client is not None:
- return request.client.host
- forwarded = request.headers.get("x-forwarded-for", "")
- return forwarded.split(",")[0].strip() or "unknown"
-
-
-class RunThrottleMiddleware:
- """Per-IP sliding window on POST .../run (429 + Retry-After)."""
-
- def __init__(self, app: ASGIApp) -> None:
- self.app = app
-
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if (
- scope["type"] != "http"
- or scope.get("method") != "POST"
- or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
- ):
- await self.app(scope, receive, send)
- return
- request = Request(scope)
- now = time.monotonic()
- window_start = now - THROTTLE_WINDOW_SECONDS
- bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
- if len(bucket) >= settings.run_throttle_per_minute:
- retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
- response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
- response.headers["Retry-After"] = str(retry_after)
- await response(scope, receive, send)
- return
- bucket.append(now)
- _hits[_client_ip(request)] = bucket
- await self.app(scope, receive, send)
+ try:
+ limiter._storage.reset() # type: ignore[attr-defined]
+ except AttributeError:
+ pass
class SecurityHeadersMiddleware:
- """Baseline response headers (TLS/HSTS live on the proxy)."""
+ """Baseline response headers (TLS/HSTS live on the proxy).
+
+ Built with the ``secure`` package; values stay byte-identical to the
+ previous hand-rolled version (see test_middleware).
+ """
def __init__(self, app: ASGIApp) -> None:
+ import secure as secure_lib
+
self.app = app
+ self.secure_headers = secure_lib.Secure(
+ xcto=secure_lib.XContentTypeOptions().nosniff(),
+ xfo=secure_lib.XFrameOptions().sameorigin(),
+ referrer=secure_lib.ReferrerPolicy().strict_origin_when_cross_origin(),
+ csp=secure_lib.ContentSecurityPolicy().frame_ancestors("'self'"),
+ )
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
@@ -83,10 +63,8 @@ class SecurityHeadersMiddleware:
async def secured(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(raw=message.setdefault("headers", []))
- headers["X-Content-Type-Options"] = "nosniff"
- headers["X-Frame-Options"] = "SAMEORIGIN"
- headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
- headers["Content-Security-Policy"] = "frame-ancestors 'self'"
+ for key, value in self.secure_headers.headers.items():
+ headers[key] = value
await send(message)
await self.app(scope, receive, secured)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 42dcc82..3fb933f 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -1,5 +1,5 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
-from .user import AccessRequest, MagicLinkToken, User, UserSession
+from .user import AccessRequest, MagicLinkToken, User
__all__ = [
"AccessRequest",
@@ -9,5 +9,4 @@ __all__ = [
"FlowVersion",
"MagicLinkToken",
"User",
- "UserSession",
]
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 384dd69..5e719aa 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -47,19 +47,7 @@ class MagicLinkToken(SQLModel, table=True):
user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=ts_column())
- # Set before the session row is created, so a reused token can never
+ # Set before the session JWT is minted, so a reused token can never
# mint a second session.
used_at: datetime | None = Field(default=None, sa_column=ts_column())
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
-
-
-class UserSession(SQLModel, table=True):
- """An opaque session id in the DB (cookie value). Revocable, expirable."""
-
- __tablename__ = "user_sessions"
-
- id: str = Field(primary_key=True, max_length=128)
- user_id: UUID = Field(foreign_key="users.id", index=True)
- created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
- expires_at: datetime = Field(sa_column=ts_column())
- revoked_at: datetime | None = Field(default=None, sa_column=ts_column())
diff --git a/backend/app/settings.py b/backend/app/settings.py
index 9738bde..2d9ea80 100644
--- a/backend/app/settings.py
+++ b/backend/app/settings.py
@@ -42,7 +42,9 @@ class Settings(BaseSettings):
run_throttle_per_minute: int = 60
# --- access management (magic-link auth, see app/auth) ---
- # Public frontend URL (magic links point at /login?token= there).
+ # Secret signing session JWTs (stateless cookies). Long random value;
+ # empty fails closed at login time — set it in prod (.env JWT_SECRET).
+ jwt_secret: str = "" # Public frontend URL (magic links point at /login?token= there).
frontend_url: str = "http://localhost:3000"
# Comma-separated emails that become admins (role assignment + bootstrap).
admin_emails: str = ""
diff --git a/backend/components/kirche/README.md b/backend/components/kirche/README.md
index 95b050f..6f75ca5 100644
--- a/backend/components/kirche/README.md
+++ b/backend/components/kirche/README.md
@@ -44,10 +44,30 @@ derives a JSON Schema + uiSchema from the node's template
The API watcher syncs + deploys it automatically; the form schema is
regenerated per version.
-## Branched questionnaires (`visible_if`)
+## Structured `visible_if` (preferred) + markers (fallback)
A field can appear only when other fields have certain values — nested
-to any depth. Declare it in the code editor with a marker in `info`:
+to any depth. Preferred: structured `visible_if` key (no quoting
+mini-language, rename-refactorable, validated at sync time with warnings
+instead of silent fail-open):
+
+```python
+(
+ DropdownInput(
+ name="bestandteil",
+ display_name="Welcher Bestandteil der Kirche?",
+ options=["Glocke", "Orgel"],
+ value=[],
+ info="Mehrfachauswahl möglich",
+ visible_if=[{"field": "gebaeude", "value": "Kirche"}],
+ required=True,
+ ),
+)
+```
+
+Legacy `[visible_if ...]` markers in `info` still work as deprecated
+fallback (same syntax, stripped from help text). The parser emits
+`allOf`/`if`/`then` branches either way (see below).
```python
(
@@ -149,20 +169,23 @@ envelope and merge normally), and unasked code defaults stay out of
the output. Without the list — raw dicts, Langflow playground — all
visible declared defaults apply as before.
-## Variant flows (own component per flow)
+## Variant flows (own component per flow, shared core)
-A flow whose fields differ from `CustomInputForm` gets its own
-standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
-in the palette): same class pattern, own `inputs` list. Standalone
-means it: no imports between form modules — the LFX runtime executes
-the embedded module source without our `kirche` package, so every form
-component must be self-contained (stdlib + `lfx` only). Copy the file
-as the starting point for future forms.
+A flow whose fields differ from `CustomInputForm` gets its own thin
+component (see `bauvorhaben_form.py` → "Bauvorhaben Form" in the
+palette): same class pattern, own `inputs` list, shared runtime from
+`kirche._form_core` (`from kirche._form_core import ...` plus the
+`# <-- _form_core inlined ... -->` marker). The LFX runtime executes
+the embedded module source without our `kirche` package, so
+`scripts/make_flow_fixtures.py:kirche_node()` inlines the core into the
+exported `code` — one truth in the repo, self-contained JSON. Copy the
+file as the starting point for future forms.
Rule: code-declared inputs must equal template fields
-(`test_embedded_code_matches_template_fields` enforces it; required
-Multiselect uses the `[" "]` sentinel). Generate node + flow with
-`kirche_node(..., component_cls, code_override)` and
+(`test_embedded_code_matches_template_fields` enforces it, plus
+no-sibling-import + core-present asserts; required Multiselect uses
+the `[" "]` sentinel). Generate node + flow with `kirche_node(...)`
+(which composes thin source + `_inline_core()` automatically) and
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
rather than hand-editing JSON. Restart Langflow afterwards so the
palette picks up the new component, then build/upload the flow.
diff --git a/backend/components/kirche/bauvorhaben_form.py b/backend/components/kirche/bauvorhaben_form.py
index 348e064..026277a 100644
--- a/backend/components/kirche/bauvorhaben_form.py
+++ b/backend/components/kirche/bauvorhaben_form.py
@@ -1,11 +1,9 @@
"""Bauvorhaben form component for kirche-einfach flows.
-Standalone by design: this module shares NO code with ``form_inputs``
-(no imports from it). The LFX runtime executes the embedded module
-source without our ``kirche`` package, so every form component must be
-self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
-machinery below into a shared import — it would break flow execution.
-Copy this file as the starting point for future forms.
+Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
+for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
+inlines the core into the exported ``code`` so the LFX runtime stays
+self-contained. Copy this file as the starting point for future forms.
A flow that starts with :class:`CustomInputBauvorhaben` declares the
fields of its web form. The API reads the exported node template
@@ -32,8 +30,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations
-import json
-import re
+import json # noqa: F401 -- used by inlined _form_core at runtime
+import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@@ -48,94 +46,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
+from kirche._form_core import (
+ PLUMBING_INPUTS,
+ _field_conditions,
+ _json_safe,
+ collect_form_data,
+ parse_envelope,
+)
-def _text(value) -> str:
- if value is None:
- return ""
- if hasattr(value, "text"):
- return value.text or ""
- return str(value)
-
-
-def _json_safe(value):
- """Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
- if value is None or isinstance(value, (bool, int, float, str)):
- return value
- if isinstance(value, list):
- return [_json_safe(item) for item in value]
- if isinstance(value, dict):
- return {str(key): _json_safe(item) for key, item in value.items()}
- text = getattr(value, "text", None)
- if isinstance(text, str):
- return text
- data = getattr(value, "data", None)
- if isinstance(data, dict):
- return _json_safe(data)
- return str(value)
-
-
-def _split_conditions(text: str) -> list:
- parts: list = []
- buf: list = []
- quoted = False
- for char in text:
- if char == '"':
- quoted = not quoted
- buf.append(char)
- elif char == "&" and not quoted:
- parts.append("".join(buf))
- buf = []
- else:
- buf.append(char)
- parts.append("".join(buf))
- return [part.strip() for part in parts if part.strip()]
-
-
-def _parse_condition(token: str):
- field, sep, value = token.partition("=")
- field, value = field.strip(), value.strip()
- if not sep or not field or not value:
- return None
- if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
- value = value[1:-1]
- lowered = value.lower()
- if lowered == "true":
- return field, True
- if lowered == "false":
- return field, False
- try:
- return field, int(value)
- except ValueError:
- pass
- try:
- return field, float(value)
- except ValueError:
- pass
- return field, value
-
-
-def _field_conditions(info: object) -> list:
- """``[(trigger, value)]`` from a field's ``info`` markers."""
- conditions: list = []
-
- def _collect(match) -> str:
- for token in _split_conditions(match.group(1)):
- parsed = _parse_condition(token)
- if parsed is not None:
- conditions.append(parsed)
- return ""
-
- _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
- return conditions
-
-
-#: Reserved input carrying the run envelope — a real runtime input, so it
-#: must stay out of the output. Never name a form field ``form_values``.
-PLUMBING_INPUTS = frozenset({"form_values"})
-
-#: ``[visible_if ...]`` marker (same syntax as the backend parser in
-#: ``app/flows/input_schema.py`` — keep the two in sync).
-_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
+# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
+# (LFX executes embedded source without the kirche package) -->
class CustomInputBauvorhaben(Component):
@@ -246,9 +166,11 @@ class CustomInputBauvorhaben(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
+ structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
- for trigger, expected in _field_conditions(info):
+ structured = getattr(inputs[name], "visible_if", None)
+ for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@@ -262,33 +184,14 @@ class CustomInputBauvorhaben(Component):
return True
def collect(self) -> Data:
- try:
- envelope = json.loads(_text(self.form_values) or "{}")
- except (json.JSONDecodeError, ValueError, AttributeError):
- envelope = {}
- payload: dict = {}
- asked: list | None = None
- if isinstance(envelope, dict):
- # Asked-field list counts only in true envelope shape
- # ({"payload": {...}, "fields": [...]}); a raw dict or a form
- # field literally named "fields" must not confuse it.
- if isinstance(envelope.get("payload"), dict):
- payload = envelope["payload"]
- fields = envelope.get("fields")
- if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
- asked = [item for item in fields if item]
- else:
- payload = envelope
+ payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
- effective = {name: _json_safe(self._field_value(name)) for name in declared}
- for key, val in payload.items():
- if isinstance(key, str) and key:
- effective[key] = _json_safe(val)
+ field_values = {name: _json_safe(self._field_value(name)) for name in declared}
+ effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
- scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
diff --git a/backend/components/kirche/form_inputs.py b/backend/components/kirche/form_inputs.py
index 090ccdb..4b228ac 100644
--- a/backend/components/kirche/form_inputs.py
+++ b/backend/components/kirche/form_inputs.py
@@ -34,8 +34,8 @@ upload comes later.
from __future__ import annotations
-import json
-import re
+import json # noqa: F401 -- used by inlined _form_core at runtime
+import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@@ -56,97 +56,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
+from kirche._form_core import (
+ PLUMBING_INPUTS,
+ _field_conditions,
+ _json_safe,
+ collect_form_data,
+ parse_envelope,
+)
-def _text(value) -> str:
- if value is None:
- return ""
- if hasattr(value, "text"):
- return value.text or ""
- return str(value)
-
-
-def _json_safe(value):
- """Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
- if value is None or isinstance(value, (bool, int, float, str)):
- return value
- if isinstance(value, list):
- return [_json_safe(item) for item in value]
- if isinstance(value, dict):
- return {str(key): _json_safe(item) for key, item in value.items()}
- text = getattr(value, "text", None)
- if isinstance(text, str):
- return text
- data = getattr(value, "data", None)
- if isinstance(data, dict):
- return _json_safe(data)
- return str(value)
-
-
-def _split_conditions(text: str) -> list:
- parts: list = []
- buf: list = []
- quoted = False
- for char in text:
- if char == '"':
- quoted = not quoted
- buf.append(char)
- elif char == "&" and not quoted:
- parts.append("".join(buf))
- buf = []
- else:
- buf.append(char)
- parts.append("".join(buf))
- return [part.strip() for part in parts if part.strip()]
-
-
-def _parse_condition(token: str):
- field, sep, value = token.partition("=")
- field, value = field.strip(), value.strip()
- if not sep or not field or not value:
- return None
- if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
- value = value[1:-1]
- lowered = value.lower()
- if lowered == "true":
- return field, True
- if lowered == "false":
- return field, False
- try:
- return field, int(value)
- except ValueError:
- pass
- try:
- return field, float(value)
- except ValueError:
- pass
- return field, value
-
-
-def _field_conditions(info: object) -> list:
- """``[(trigger, value)]`` from a field's ``info`` markers."""
- conditions: list = []
-
- def _collect(match) -> str:
- for token in _split_conditions(match.group(1)):
- parsed = _parse_condition(token)
- if parsed is not None:
- conditions.append(parsed)
- return ""
-
- _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
- return conditions
-
-
-#: Reserved input carrying the run envelope — a real runtime input, so it
-#: must stay out of the output (otherwise the whole envelope JSON would
-#: leak in as a field value on every path without an asked-fields list).
-#: Never name a form field ``form_values``.
-PLUMBING_INPUTS = frozenset({"form_values"})
-
-#: ``[visible_if ...]`` marker (same syntax as the backend parser in
-#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
-#: stay import-light for the LFX runtime and cannot import it).
-_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
+# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
+# (LFX executes embedded source without the kirche package) -->
class CustomInputForm(Component):
@@ -272,9 +191,11 @@ class CustomInputForm(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
+ structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
- for trigger, expected in _field_conditions(info):
+ structured = getattr(inputs[name], "visible_if", None)
+ for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@@ -288,33 +209,14 @@ class CustomInputForm(Component):
return True
def collect(self) -> Data:
- try:
- envelope = json.loads(_text(self.form_values) or "{}")
- except (json.JSONDecodeError, ValueError, AttributeError):
- envelope = {}
- payload: dict = {}
- asked: list | None = None
- if isinstance(envelope, dict):
- # Asked-field list counts only in true envelope shape
- # ({"payload": {...}, "fields": [...]}); a raw dict or a form
- # field literally named "fields" must not confuse it.
- if isinstance(envelope.get("payload"), dict):
- payload = envelope["payload"]
- fields = envelope.get("fields")
- if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
- asked = [item for item in fields if item]
- else:
- payload = envelope
+ payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
- effective = {name: _json_safe(self._field_value(name)) for name in declared}
- for key, val in payload.items():
- if isinstance(key, str) and key:
- effective[key] = _json_safe(val)
+ field_values = {name: _json_safe(self._field_value(name)) for name in declared}
+ effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
- scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
diff --git a/backend/components/kirche/mitarbeiter_form.py b/backend/components/kirche/mitarbeiter_form.py
index d27e217..5d215f5 100644
--- a/backend/components/kirche/mitarbeiter_form.py
+++ b/backend/components/kirche/mitarbeiter_form.py
@@ -1,11 +1,9 @@
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
-Standalone by design: this module shares NO code with the other form
-modules (no imports from them). The LFX runtime executes the embedded
-module source without our ``kirche`` package, so every form component
-must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
-duplicated machinery below into a shared import — it would break flow
-execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
+Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
+for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
+inlines the core into the exported ``code`` so the LFX runtime stays
+self-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting
point for future forms.
A flow that starts with :class:`CustomInputMitarbeiter` declares the
@@ -33,8 +31,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations
-import json
-import re
+import json # noqa: F401 -- used by inlined _form_core at runtime
+import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@@ -46,94 +44,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
+from kirche._form_core import (
+ PLUMBING_INPUTS,
+ _field_conditions,
+ _json_safe,
+ collect_form_data,
+ parse_envelope,
+)
-def _text(value) -> str:
- if value is None:
- return ""
- if hasattr(value, "text"):
- return value.text or ""
- return str(value)
-
-
-def _json_safe(value):
- """Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
- if value is None or isinstance(value, (bool, int, float, str)):
- return value
- if isinstance(value, list):
- return [_json_safe(item) for item in value]
- if isinstance(value, dict):
- return {str(key): _json_safe(item) for key, item in value.items()}
- text = getattr(value, "text", None)
- if isinstance(text, str):
- return text
- data = getattr(value, "data", None)
- if isinstance(data, dict):
- return _json_safe(data)
- return str(value)
-
-
-def _split_conditions(text: str) -> list:
- parts: list = []
- buf: list = []
- quoted = False
- for char in text:
- if char == '"':
- quoted = not quoted
- buf.append(char)
- elif char == "&" and not quoted:
- parts.append("".join(buf))
- buf = []
- else:
- buf.append(char)
- parts.append("".join(buf))
- return [part.strip() for part in parts if part.strip()]
-
-
-def _parse_condition(token: str):
- field, sep, value = token.partition("=")
- field, value = field.strip(), value.strip()
- if not sep or not field or not value:
- return None
- if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
- value = value[1:-1]
- lowered = value.lower()
- if lowered == "true":
- return field, True
- if lowered == "false":
- return field, False
- try:
- return field, int(value)
- except ValueError:
- pass
- try:
- return field, float(value)
- except ValueError:
- pass
- return field, value
-
-
-def _field_conditions(info: object) -> list:
- """``[(trigger, value)]`` from a field's ``info`` markers."""
- conditions: list = []
-
- def _collect(match) -> str:
- for token in _split_conditions(match.group(1)):
- parsed = _parse_condition(token)
- if parsed is not None:
- conditions.append(parsed)
- return ""
-
- _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
- return conditions
-
-
-#: Reserved input carrying the run envelope — a real runtime input, so it
-#: must stay out of the output. Never name a form field ``form_values``.
-PLUMBING_INPUTS = frozenset({"form_values"})
-
-#: ``[visible_if ...]`` marker (same syntax as the backend parser in
-#: ``app/flows/input_schema.py`` — keep the two in sync).
-_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
+# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
+# (LFX executes embedded source without the kirche package) -->
class CustomInputMitarbeiter(Component):
@@ -223,9 +143,11 @@ class CustomInputMitarbeiter(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
+ structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
- for trigger, expected in _field_conditions(info):
+ structured = getattr(inputs[name], "visible_if", None)
+ for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@@ -239,33 +161,14 @@ class CustomInputMitarbeiter(Component):
return True
def collect(self) -> Data:
- try:
- envelope = json.loads(_text(self.form_values) or "{}")
- except (json.JSONDecodeError, ValueError, AttributeError):
- envelope = {}
- payload: dict = {}
- asked: list | None = None
- if isinstance(envelope, dict):
- # Asked-field list counts only in true envelope shape
- # ({"payload": {...}, "fields": [...]}); a raw dict or a form
- # field literally named "fields" must not confuse it.
- if isinstance(envelope.get("payload"), dict):
- payload = envelope["payload"]
- fields = envelope.get("fields")
- if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
- asked = [item for item in fields if item]
- else:
- payload = envelope
+ payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
- effective = {name: _json_safe(self._field_value(name)) for name in declared}
- for key, val in payload.items():
- if isinstance(key, str) and key:
- effective[key] = _json_safe(val)
+ field_values = {name: _json_safe(self._field_value(name)) for name in declared}
+ effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
- scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
diff --git a/backend/flows/approval_handoff.json b/backend/flows/approval_handoff.json
index 387ace3..fbc7091 100644
--- a/backend/flows/approval_handoff.json
+++ b/backend/flows/approval_handoff.json
@@ -1,5 +1,1681 @@
{
"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 Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\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\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\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
+ },
+ {
+ "types": [
+ "Message"
+ ],
+ "selected": "Message",
+ "name": "callback_token",
+ "display_name": "Callback Token",
+ "method": "get_callback_token",
+ "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": "02e69613e210",
+ "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_token": {
+ "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_token",
+ "value": "",
+ "display_name": "Callback Token",
+ "advanced": false,
+ "api_editable": false,
+ "input_types": [
+ "Message"
+ ],
+ "dynamic": false,
+ "info": "",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "type": "str",
+ "_input_type": "MessageTextInput"
+ },
+ "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=\"callback_token\",\n display_name=\"Callback Token\",\n ),\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 headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, 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",
+ "callback_token",
+ "continuation_flow_id",
+ "facts"
+ ],
+ "beta": false,
+ "legacy": false,
+ "edited": false,
+ "metadata": {
+ "module": "custom_components.approval_callback",
+ "code_hash": "28b2f5894566",
+ "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,
@@ -318,1682 +1994,6 @@
"targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}"
}
],
- "nodes": [
- {
- "data": {
- "id": "ChatInput-entry",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Get chat inputs from the Playground.",
- "display_name": "Chat Input",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Chat Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "files": {
- "_input_type": "FileInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Files",
- "dynamic": false,
- "fileTypes": [
- "csv",
- "json",
- "pdf",
- "txt",
- "md",
- "mdx",
- "yaml",
- "yml",
- "xml",
- "html",
- "htm",
- "docx",
- "py",
- "sh",
- "sql",
- "js",
- "ts",
- "tsx",
- "jpg",
- "jpeg",
- "png",
- "bmp",
- "image"
- ],
- "file_path": "",
- "info": "Files to be sent with the message.",
- "list": true,
- "list_add_label": "Add More",
- "name": "files",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "temp_file": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "file",
- "value": ""
- },
- "input_value": {
- "_input_type": "MultilineInput",
- "advanced": false,
- "ai_enabled": false,
- "api_editable": false,
- "copy_field": false,
- "display_name": "Input Text",
- "dynamic": false,
- "info": "Message to be passed as input.",
- "input_types": [],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "multiline": true,
- "name": "input_value",
- "override_skip": false,
- "password": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "User"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "User"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatInput"
- },
- "id": "ChatInput-entry",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 0,
- "y": 300
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "CustomComponent-envelope",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.",
- "display_name": "Envelope",
- "documentation": "",
- "edited": false,
- "field_order": [
- "raw"
- ],
- "frozen": false,
- "legacy": false,
- "metadata": {
- "code_hash": "02e69613e210",
- "dependencies": {
- "dependencies": [
- {
- "name": "lfx",
- "version": "1.12.0"
- }
- ],
- "total_dependencies": 1
- },
- "module": "custom_components.envelope"
- },
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Payload",
- "group_outputs": false,
- "method": "get_payload",
- "name": "payload",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- },
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Run ID",
- "group_outputs": false,
- "method": "get_run_id",
- "name": "run_id",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- },
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Callback URL",
- "group_outputs": false,
- "method": "get_callback_url",
- "name": "callback_url",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- },
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Continuation",
- "group_outputs": false,
- "method": "get_continuation",
- "name": "continuation",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- },
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Callback Token",
- "group_outputs": false,
- "method": "get_callback_token",
- "name": "callback_token",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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 Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\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\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n"
- },
- "raw": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Raw Input",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "raw",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "CustomComponent"
- },
- "id": "CustomComponent-envelope",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 400,
- "y": 300
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "CustomComponent-rule",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.",
- "display_name": "Rule Check",
- "documentation": "",
- "edited": false,
- "field_order": [
- "payload"
- ],
- "frozen": false,
- "legacy": false,
- "metadata": {
- "code_hash": "0b9f8a009fcf",
- "dependencies": {
- "dependencies": [
- {
- "name": "lfx",
- "version": "1.12.0"
- }
- ],
- "total_dependencies": 1
- },
- "module": "custom_components.rule_check"
- },
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Verdict",
- "group_outputs": false,
- "method": "check",
- "name": "verdict",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "payload": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Payload",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "payload",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "CustomComponent"
- },
- "id": "CustomComponent-rule",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 800,
- "y": 300
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ConditionalRouter-router",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Routes an input message to a corresponding output based on text comparison.",
- "display_name": "If-Else",
- "documentation": "https://docs.langflow.org/if-else",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "split",
- "legacy": false,
- "metadata": {},
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "True",
- "group_outputs": true,
- "method": "true_response",
- "name": "true_result",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- },
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "False",
- "group_outputs": true,
- "method": "false_response",
- "name": "false_result",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "case_sensitive": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Case Sensitive",
- "dynamic": false,
- "info": "If true, the comparison will be case sensitive.",
- "list": false,
- "list_add_label": "Add More",
- "name": "case_sensitive",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "default_route": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Default Route",
- "dynamic": false,
- "external_options": {},
- "info": "The default route to take when max iterations are reached.",
- "name": "default_route",
- "options": [
- "true_result",
- "false_result"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "false_result"
- },
- "false_case_message": {
- "_input_type": "MessageInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Case False",
- "dynamic": false,
- "info": "The message to pass if the condition is False.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "false_case_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "Standardfall: automatisch freigegeben"
- },
- "input_text": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Text Input",
- "dynamic": false,
- "info": "The primary text input for the operation.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "input_text",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "match_text": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Match Text",
- "dynamic": false,
- "info": "The text input to compare against.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "match_text",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "PRUEFUNG"
- },
- "max_iterations": {
- "_input_type": "IntInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Max Iterations",
- "dynamic": false,
- "info": "The maximum number of iterations for the conditional router.",
- "list": false,
- "list_add_label": "Add More",
- "name": "max_iterations",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "int",
- "value": 10
- },
- "operator": {
- "_input_type": "DropdownInput",
- "advanced": false,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Operator",
- "dynamic": false,
- "external_options": {},
- "info": "The operator to apply for comparing the texts.",
- "name": "operator",
- "options": [
- "equals",
- "not equals",
- "contains",
- "starts with",
- "ends with",
- "regex",
- "less than",
- "less than or equal",
- "greater than",
- "greater than or equal"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "real_time_refresh": true,
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "equals"
- },
- "true_case_message": {
- "_input_type": "MessageInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Case True",
- "dynamic": false,
- "info": "The message to pass if the condition is True.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "true_case_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "Sonderfall: menschliche Pruefung erforderlich"
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ConditionalRouter"
- },
- "id": "ConditionalRouter-router",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 1200,
- "y": 300
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "CustomComponent-callback",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Meldet einen Sonderfall per HTTP an unsere API und terminiert.",
- "display_name": "Approval Callback",
- "documentation": "",
- "edited": false,
- "field_order": [
- "trigger",
- "run_id",
- "callback_url",
- "callback_token",
- "continuation_flow_id",
- "facts"
- ],
- "frozen": false,
- "legacy": false,
- "metadata": {
- "code_hash": "28b2f5894566",
- "dependencies": {
- "dependencies": [
- {
- "name": "lfx",
- "version": "1.12.0"
- },
- {
- "name": "httpx",
- "version": "0.28.1"
- }
- ],
- "total_dependencies": 2
- },
- "module": "custom_components.approval_callback"
- },
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Result",
- "group_outputs": false,
- "method": "notify",
- "name": "result",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "callback_token": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Callback Token",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "callback_token",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "callback_url": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Callback URL",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "callback_url",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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=\"callback_token\",\n display_name=\"Callback Token\",\n ),\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 headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, 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"
- },
- "continuation_flow_id": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Continuation Flow ID",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "continuation_flow_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "facts": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Facts",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "facts",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "run_id": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Run ID",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "run_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "trigger": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Trigger",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "trigger",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "CustomComponent"
- },
- "id": "CustomComponent-callback",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 1600,
- "y": 160
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ChatOutput-pending",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Display a chat message in the Playground.",
- "display_name": "Chat Output",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Output Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "clean_data": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Basic Clean Data",
- "dynamic": false,
- "info": "Whether to clean data before converting to string.",
- "list": false,
- "list_add_label": "Add More",
- "name": "clean_data",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "data_template": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Data Template",
- "dynamic": false,
- "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "data_template",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "{text}"
- },
- "input_value": {
- "_input_type": "HandleInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Inputs",
- "dynamic": false,
- "info": "Message to be passed as output.",
- "input_types": [
- "Data",
- "JSON",
- "DataFrame",
- "Table",
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "name": "input_value",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "other",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "Machine"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "AI"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatOutput"
- },
- "id": "ChatOutput-pending",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 2000,
- "y": 160
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ChatOutput-ok",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Display a chat message in the Playground.",
- "display_name": "Chat Output",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Output Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "clean_data": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Basic Clean Data",
- "dynamic": false,
- "info": "Whether to clean data before converting to string.",
- "list": false,
- "list_add_label": "Add More",
- "name": "clean_data",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "data_template": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Data Template",
- "dynamic": false,
- "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "data_template",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "{text}"
- },
- "input_value": {
- "_input_type": "HandleInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Inputs",
- "dynamic": false,
- "info": "Message to be passed as output.",
- "input_types": [
- "Data",
- "JSON",
- "DataFrame",
- "Table",
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "name": "input_value",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "other",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "Machine"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "AI"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatOutput"
- },
- "id": "ChatOutput-ok",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 2000,
- "y": 440
- },
- "type": "genericNode"
- }
- ],
"viewport": {
"x": 0,
"y": 0,
@@ -2002,8 +2002,12 @@
},
"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/flows/bauvorhaben.json b/backend/flows/bauvorhaben.json
index ee0b311..4a093a8 100644
--- a/backend/flows/bauvorhaben.json
+++ b/backend/flows/bauvorhaben.json
@@ -509,7 +509,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "\"\"\"Bauvorhaben form component for kirche-einfach flows.\n\nStandalone by design: this module shares NO code with ``form_inputs``\n(no imports from it). The LFX runtime executes the embedded module\nsource without our ``kirche`` package, so every form component must be\nself-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated\nmachinery below into a shared import — it would break flow execution.\nCopy this file as the starting point for future forms.\n\nA flow that starts with :class:`CustomInputBauvorhaben` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches, see below),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultiselectInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputBauvorhaben(Component):\n display_name = \"Bauvorhaben Form\"\n description = \"Collects Bauvorhaben form values into a single JSON object.\"\n icon = \"Church\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"gebaeude\",\n display_name=\"Welches Gebäude soll verändert werden?\",\n info=\"\",\n options=[\"Kirche\", \"Anderes Gebäude\"],\n value=\"\",\n required=True,\n ),\n MultiselectInput(\n name=\"bestandteil\",\n display_name=\"Welcher Bestandteil der Kirche?\",\n info=\"Mehrfachauswahl möglich [visible_if gebaeude=Kirche]\",\n options=[\"Glocke\", \"Orgel\", \"Innenraum\", \"Fenster\", \"Fassade\"],\n value=[],\n required=True,\n ),\n DropdownInput(\n name=\"glockenmassnahme\",\n display_name=\"Welche Maßnahme an der Glocke?\",\n info=\"[visible_if bestandteil=Glocke]\",\n options=[\"Reinigung\", \"Reparatur\", \"Neuguss\"],\n value=\"Reinigung\",\n ),\n IntInput(\n name=\"gussjahr\",\n display_name=\"Gussjahr (nur bei Neuguss)?\",\n info=\"[visible_if glockenmassnahme=Neuguss]\",\n value=0,\n ),\n DropdownInput(\n name=\"gebaeudeart\",\n display_name=\"Art des anderen Gebäudes?\",\n info='[visible_if gebaeude=\"Anderes Gebäude\"]',\n options=[\"Wohnhaus\", \"Scheune\", \"Sonstiges\"],\n value=\"Wohnhaus\",\n ),\n BoolInput(\n name=\"denkmalschutz\",\n display_name=\"Steht das Gebäude unter Denkmalschutz?\",\n info=\"\",\n value=False,\n ),\n StrInput(\n name=\"denkmalnummer\",\n display_name=\"Aktenzeichen Denkmalschutz?\",\n info=\"[visible_if denkmalschutz=true]\",\n value=\"\",\n required=True,\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n"
+ "value": "\"\"\"Bauvorhaben form component for kirche-einfach flows.\n\nThin variant: shared runtime lives in ``kirche._form_core`` (imported here\nfor the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``\ninlines the core into the exported ``code`` so the LFX runtime stays\nself-contained. Copy this file as the starting point for future forms.\n\nA flow that starts with :class:`CustomInputBauvorhaben` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches, see below),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json # noqa: F401 -- used by inlined _form_core at runtime\nimport re # noqa: F401 -- used by inlined _form_core at runtime\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultiselectInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n#: Reserved input carrying the run envelope — never a form field name.\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object, structured=None) -> list:\n \"\"\"[(trigger, value)] from structured key or info markers.\n\n Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``\n dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as\n deprecated fallback.\n \"\"\"\n if structured:\n conditions: list = []\n items = structured.items() if isinstance(structured, dict) else structured\n for item in items or []:\n if isinstance(item, dict) and \"field\" in item:\n conditions.append((item[\"field\"], item.get(\"value\")))\n elif isinstance(item, (list, tuple)) and len(item) == 2:\n conditions.append((item[0], item[1]))\n if conditions:\n return conditions\n conditions = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\ndef collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:\n \"\"\"Shared collect(): effective values, visibility scope, submitted wins.\"\"\"\n effective = dict(field_values)\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n scope = [n for n in (asked if asked is not None else field_names) if n in field_names]\n return effective, scope\n\n\ndef parse_envelope(text_value) -> tuple[dict, list | None]:\n \"\"\"(payload, asked-fields) from the run envelope text.\"\"\"\n try:\n envelope = json.loads(_text(text_value) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n return {}, None\n if not isinstance(envelope, dict):\n return {}, None\n if isinstance(envelope.get(\"payload\"), dict):\n fields = envelope.get(\"fields\")\n asked = (\n [i for i in fields if i]\n if isinstance(fields, list) and all(isinstance(i, str) for i in fields)\n else None\n )\n return envelope[\"payload\"], asked\n return envelope, None\n\n# (LFX executes embedded source without the kirche package) -->\n\n\nclass CustomInputBauvorhaben(Component):\n display_name = \"Bauvorhaben Form\"\n description = \"Collects Bauvorhaben form values into a single JSON object.\"\n icon = \"Church\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"gebaeude\",\n display_name=\"Welches Gebäude soll verändert werden?\",\n info=\"\",\n options=[\"Kirche\", \"Anderes Gebäude\"],\n value=\"\",\n required=True,\n ),\n MultiselectInput(\n name=\"bestandteil\",\n display_name=\"Welcher Bestandteil der Kirche?\",\n info=\"Mehrfachauswahl möglich [visible_if gebaeude=Kirche]\",\n options=[\"Glocke\", \"Orgel\", \"Innenraum\", \"Fenster\", \"Fassade\"],\n value=[],\n required=True,\n ),\n DropdownInput(\n name=\"glockenmassnahme\",\n display_name=\"Welche Maßnahme an der Glocke?\",\n info=\"[visible_if bestandteil=Glocke]\",\n options=[\"Reinigung\", \"Reparatur\", \"Neuguss\"],\n value=\"Reinigung\",\n ),\n IntInput(\n name=\"gussjahr\",\n display_name=\"Gussjahr (nur bei Neuguss)?\",\n info=\"[visible_if glockenmassnahme=Neuguss]\",\n value=0,\n ),\n DropdownInput(\n name=\"gebaeudeart\",\n display_name=\"Art des anderen Gebäudes?\",\n info='[visible_if gebaeude=\"Anderes Gebäude\"]',\n options=[\"Wohnhaus\", \"Scheune\", \"Sonstiges\"],\n value=\"Wohnhaus\",\n ),\n BoolInput(\n name=\"denkmalschutz\",\n display_name=\"Steht das Gebäude unter Denkmalschutz?\",\n info=\"\",\n value=False,\n ),\n StrInput(\n name=\"denkmalnummer\",\n display_name=\"Aktenzeichen Denkmalschutz?\",\n info=\"[visible_if denkmalschutz=true]\",\n value=\"\",\n required=True,\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n structured = None\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n structured = getattr(inputs[name], \"visible_if\", None)\n for trigger, expected in _field_conditions(info, structured):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n payload, asked = parse_envelope(self.form_values)\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n field_values = {name: _json_safe(self._field_value(name)) for name in declared}\n effective, scope = collect_form_data(declared, field_values, payload, asked)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)"
}
},
"description": "Collects Bauvorhaben form values into a single JSON object.",
diff --git a/backend/flows/intake_form.json b/backend/flows/intake_form.json
index 4d14be7..6fa9665 100644
--- a/backend/flows/intake_form.json
+++ b/backend/flows/intake_form.json
@@ -1,5 +1,914 @@
{
"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",
+ "attachment": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "file_path": "",
+ "fileTypes": [
+ "pdf",
+ "png",
+ "jpg"
+ ],
+ "temp_file": false,
+ "list": false,
+ "list_add_label": "Add More",
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "attachment",
+ "value": "",
+ "display_name": "Attachment",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "String reference only for now (no upload yet)",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "type": "file",
+ "_input_type": "FileInput"
+ },
+ "age": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "list": false,
+ "list_add_label": "Add More",
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "age",
+ "value": 18,
+ "display_name": "Age",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "Your age in years",
+ "title_case": false,
+ "track_in_telemetry": true,
+ "type": "int",
+ "_input_type": "IntInput"
+ },
+ "amount": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "list": false,
+ "list_add_label": "Add More",
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "amount",
+ "value": 0.0,
+ "display_name": "Amount",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "Amount in euros",
+ "title_case": false,
+ "track_in_telemetry": true,
+ "type": "float",
+ "_input_type": "FloatInput"
+ },
+ "department": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "options": [
+ "Music",
+ "Youth",
+ "Seniors",
+ "Admin"
+ ],
+ "options_metadata": [],
+ "combobox": false,
+ "dialog_inputs": {},
+ "toggle": false,
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "department",
+ "value": "Music",
+ "display_name": "Department",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "Which team is this for",
+ "title_case": false,
+ "track_in_telemetry": true,
+ "external_options": {},
+ "type": "str",
+ "_input_type": "DropdownInput"
+ },
+ "form_values": {
+ "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": false,
+ "name": "form_values",
+ "value": "",
+ "display_name": "Form values (JSON envelope)",
+ "advanced": false,
+ "api_editable": false,
+ "input_types": [
+ "Message"
+ ],
+ "dynamic": false,
+ "info": "Submitted values as JSON; merged over the defaults",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "type": "str",
+ "_input_type": "MessageTextInput"
+ },
+ "full_name": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "load_from_db": false,
+ "list": false,
+ "list_add_label": "Add More",
+ "override_skip": false,
+ "required": true,
+ "placeholder": "",
+ "show": true,
+ "name": "full_name",
+ "value": " ",
+ "display_name": "Full name",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "Your first and last name",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "type": "str",
+ "_input_type": "StrInput"
+ },
+ "newsletter": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "list": false,
+ "list_add_label": "Add More",
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "newsletter",
+ "value": false,
+ "display_name": "Newsletter",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "Send me the parish newsletter",
+ "title_case": false,
+ "track_in_telemetry": true,
+ "type": "bool",
+ "_input_type": "BoolInput"
+ },
+ "notes": {
+ "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": "notes",
+ "value": "",
+ "display_name": "Notes",
+ "advanced": false,
+ "api_editable": false,
+ "input_types": [
+ "Message"
+ ],
+ "dynamic": false,
+ "info": "Anything else we should know",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "copy_field": false,
+ "password": false,
+ "type": "str",
+ "_input_type": "MultilineInput"
+ },
+ "password": {
+ "load_from_db": true,
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "password",
+ "value": "",
+ "display_name": "Password",
+ "advanced": false,
+ "api_editable": false,
+ "input_types": [],
+ "dynamic": false,
+ "info": "Optional access phrase",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "password": true,
+ "type": "str",
+ "_input_type": "SecretStrInput"
+ },
+ "satisfaction": {
+ "tool_mode": false,
+ "min_label": "",
+ "max_label": "",
+ "min_label_icon": "",
+ "max_label_icon": "",
+ "slider_buttons": false,
+ "slider_buttons_options": [],
+ "slider_input": false,
+ "range_spec": {
+ "step_type": "float",
+ "min": 1.0,
+ "max": 5.0,
+ "step": 1.0
+ },
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "satisfaction",
+ "value": 3,
+ "display_name": "Satisfaction",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "How satisfied are you (1-5)",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "type": "slider",
+ "_input_type": "SliderInput"
+ },
+ "topics": {
+ "tool_mode": false,
+ "trace_as_metadata": true,
+ "options": [
+ "Worship",
+ "Outreach",
+ "Courses"
+ ],
+ "combobox": false,
+ "toggle": false,
+ "list": true,
+ "list_add_label": "Add More",
+ "override_skip": false,
+ "required": false,
+ "placeholder": "",
+ "show": true,
+ "name": "topics",
+ "value": [],
+ "display_name": "Topics",
+ "advanced": false,
+ "api_editable": false,
+ "dynamic": false,
+ "info": "What are you interested in",
+ "title_case": false,
+ "track_in_telemetry": false,
+ "type": "str",
+ "_input_type": "MultiselectInput"
+ },
+ "code": {
+ "advanced": true,
+ "api_editable": false,
+ "dynamic": true,
+ "fileTypes": [],
+ "info": "",
+ "list": false,
+ "load_from_db": false,
+ "multiline": true,
+ "name": "code",
+ "password": false,
+ "placeholder": "",
+ "required": true,
+ "show": true,
+ "title_case": false,
+ "type": "code",
+ "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\nones (``form_values``, ``code``), so fields can be renamed, added, or\nremoved in the Langflow code editor without touching this method.\n``name`` is the stable JSON key (freely renamable, must stay unique and\nnon-empty); downstream components read the keys as-is.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked fields — the template may declare fields unknown to this code and\nunasked code defaults stay out of the output. Without the list (raw dict,\nLangflow playground), all declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json # noqa: F401 -- used by inlined _form_core at runtime\nimport re # noqa: F401 -- used by inlined _form_core at runtime\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n#: Reserved input carrying the run envelope — never a form field name.\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object, structured=None) -> list:\n \"\"\"[(trigger, value)] from structured key or info markers.\n\n Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``\n dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as\n deprecated fallback.\n \"\"\"\n if structured:\n conditions: list = []\n items = structured.items() if isinstance(structured, dict) else structured\n for item in items or []:\n if isinstance(item, dict) and \"field\" in item:\n conditions.append((item[\"field\"], item.get(\"value\")))\n elif isinstance(item, (list, tuple)) and len(item) == 2:\n conditions.append((item[0], item[1]))\n if conditions:\n return conditions\n conditions = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\ndef collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:\n \"\"\"Shared collect(): effective values, visibility scope, submitted wins.\"\"\"\n effective = dict(field_values)\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n scope = [n for n in (asked if asked is not None else field_names) if n in field_names]\n return effective, scope\n\n\ndef parse_envelope(text_value) -> tuple[dict, list | None]:\n \"\"\"(payload, asked-fields) from the run envelope text.\"\"\"\n try:\n envelope = json.loads(_text(text_value) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n return {}, None\n if not isinstance(envelope, dict):\n return {}, None\n if isinstance(envelope.get(\"payload\"), dict):\n fields = envelope.get(\"fields\")\n asked = (\n [i for i in fields if i]\n if isinstance(fields, list) and all(isinstance(i, str) for i in fields)\n else None\n )\n return envelope[\"payload\"], asked\n return envelope, None\n\n# (LFX executes embedded source without the kirche package) -->\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n structured = None\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n structured = getattr(inputs[name], \"visible_if\", None)\n for trigger, expected in _field_conditions(info, structured):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n payload, asked = parse_envelope(self.form_values)\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n field_values = {name: _json_safe(self._field_value(name)) for name in declared}\n effective, scope = collect_form_data(declared, field_values, payload, asked)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)"
+ }
+ },
+ "description": "Collects structured form values into a single JSON object.",
+ "icon": "ClipboardList",
+ "base_classes": [],
+ "display_name": "CustomInput Form",
+ "documentation": "",
+ "minimized": false,
+ "custom_fields": {},
+ "output_types": [],
+ "pinned": false,
+ "conditional_paths": [],
+ "frozen": false,
+ "outputs": [
+ {
+ "types": [
+ "JSON"
+ ],
+ "selected": "JSON",
+ "name": "form_data",
+ "display_name": "Form Data",
+ "method": "collect",
+ "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": "CustomInputForm",
+ "id": "CustomInputForm-intake",
+ "showNode": true
+ },
+ "id": "CustomInputForm-intake",
+ "measured": {
+ "width": 320,
+ "height": 200
+ },
+ "position": {
+ "x": 400,
+ "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-collected",
+ "showNode": true
+ },
+ "id": "ChatOutput-collected",
+ "measured": {
+ "width": 320,
+ "height": 200
+ },
+ "position": {
+ "x": 800,
+ "y": 200
+ },
+ "type": "genericNode"
+ }
+ ],
"edges": [
{
"animated": false,
@@ -62,915 +971,6 @@
"targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}"
}
],
- "nodes": [
- {
- "data": {
- "id": "ChatInput-entry",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Get chat inputs from the Playground.",
- "display_name": "Chat Input",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Chat Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "files": {
- "_input_type": "FileInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Files",
- "dynamic": false,
- "fileTypes": [
- "csv",
- "json",
- "pdf",
- "txt",
- "md",
- "mdx",
- "yaml",
- "yml",
- "xml",
- "html",
- "htm",
- "docx",
- "py",
- "sh",
- "sql",
- "js",
- "ts",
- "tsx",
- "jpg",
- "jpeg",
- "png",
- "bmp",
- "image"
- ],
- "file_path": "",
- "info": "Files to be sent with the message.",
- "list": true,
- "list_add_label": "Add More",
- "name": "files",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "temp_file": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "file",
- "value": ""
- },
- "input_value": {
- "_input_type": "MultilineInput",
- "advanced": false,
- "ai_enabled": false,
- "api_editable": false,
- "copy_field": false,
- "display_name": "Input Text",
- "dynamic": false,
- "info": "Message to be passed as input.",
- "input_types": [],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "multiline": true,
- "name": "input_value",
- "override_skip": false,
- "password": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "User"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "User"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatInput"
- },
- "id": "ChatInput-entry",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 0,
- "y": 200
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "CustomInputForm-intake",
- "node": {
- "base_classes": [],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Collects structured form values into a single JSON object.",
- "display_name": "CustomInput Form",
- "documentation": "",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "ClipboardList",
- "legacy": false,
- "metadata": {},
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Form Data",
- "group_outputs": false,
- "method": "collect",
- "name": "form_data",
- "selected": "JSON",
- "tool_mode": true,
- "types": [
- "JSON"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "age": {
- "_input_type": "IntInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Age",
- "dynamic": false,
- "info": "Your age in years",
- "list": false,
- "list_add_label": "Add More",
- "name": "age",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "int",
- "value": 18
- },
- "amount": {
- "_input_type": "FloatInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Amount",
- "dynamic": false,
- "info": "Amount in euros",
- "list": false,
- "list_add_label": "Add More",
- "name": "amount",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "float",
- "value": 0.0
- },
- "attachment": {
- "_input_type": "FileInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Attachment",
- "dynamic": false,
- "fileTypes": [
- "pdf",
- "png",
- "jpg"
- ],
- "file_path": "",
- "info": "String reference only for now (no upload yet)",
- "list": false,
- "list_add_label": "Add More",
- "name": "attachment",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "temp_file": false,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "file",
- "value": ""
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\nones (``form_values``, ``code``), so fields can be renamed, added, or\nremoved in the Langflow code editor without touching this method.\n``name`` is the stable JSON key (freely renamable, must stay unique and\nnon-empty); downstream components read the keys as-is.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\n#: Inputs that are plumbing, not form fields (never part of the output).\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def collect(self) -> Data:\n data = {}\n for name in self._field_names():\n data[name] = _json_safe(self._field_value(name))\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n # Unknown keys are kept: the frontend may already know renamed\n # fields this code revision does not declare.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key, val in override.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n"
- },
- "department": {
- "_input_type": "DropdownInput",
- "advanced": false,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Department",
- "dynamic": false,
- "external_options": {},
- "info": "Which team is this for",
- "name": "department",
- "options": [
- "Music",
- "Youth",
- "Seniors",
- "Admin"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "Music"
- },
- "form_values": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Form values (JSON envelope)",
- "dynamic": false,
- "info": "Submitted values as JSON; merged over the defaults",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "form_values",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": false,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "full_name": {
- "_input_type": "StrInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Full name",
- "dynamic": false,
- "info": "Your first and last name",
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "full_name",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": " "
- },
- "newsletter": {
- "_input_type": "BoolInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Newsletter",
- "dynamic": false,
- "info": "Send me the parish newsletter",
- "list": false,
- "list_add_label": "Add More",
- "name": "newsletter",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": false
- },
- "notes": {
- "_input_type": "MultilineInput",
- "advanced": false,
- "ai_enabled": false,
- "api_editable": false,
- "copy_field": false,
- "display_name": "Notes",
- "dynamic": false,
- "info": "Anything else we should know",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "multiline": true,
- "name": "notes",
- "override_skip": false,
- "password": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "password": {
- "_input_type": "SecretStrInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Password",
- "dynamic": false,
- "info": "Optional access phrase",
- "input_types": [],
- "load_from_db": true,
- "name": "password",
- "override_skip": false,
- "password": true,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "satisfaction": {
- "_input_type": "SliderInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Satisfaction",
- "dynamic": false,
- "info": "How satisfied are you (1-5)",
- "max_label": "",
- "max_label_icon": "",
- "min_label": "",
- "min_label_icon": "",
- "name": "satisfaction",
- "override_skip": false,
- "placeholder": "",
- "range_spec": {
- "max": 5.0,
- "min": 1.0,
- "step": 1.0,
- "step_type": "float"
- },
- "required": false,
- "show": true,
- "slider_buttons": false,
- "slider_buttons_options": [],
- "slider_input": false,
- "title_case": false,
- "tool_mode": false,
- "track_in_telemetry": false,
- "type": "slider",
- "value": 3
- },
- "topics": {
- "_input_type": "MultiselectInput",
- "advanced": false,
- "api_editable": false,
- "combobox": false,
- "display_name": "Topics",
- "dynamic": false,
- "info": "What are you interested in",
- "list": true,
- "list_add_label": "Add More",
- "name": "topics",
- "options": [
- "Worship",
- "Outreach",
- "Courses"
- ],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": []
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "CustomInputForm"
- },
- "id": "CustomInputForm-intake",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 400,
- "y": 200
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ChatOutput-collected",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Display a chat message in the Playground.",
- "display_name": "Chat Output",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Output Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "clean_data": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Basic Clean Data",
- "dynamic": false,
- "info": "Whether to clean data before converting to string.",
- "list": false,
- "list_add_label": "Add More",
- "name": "clean_data",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "data_template": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Data Template",
- "dynamic": false,
- "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "data_template",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "{text}"
- },
- "input_value": {
- "_input_type": "HandleInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Inputs",
- "dynamic": false,
- "info": "Message to be passed as output.",
- "input_types": [
- "Data",
- "JSON",
- "DataFrame",
- "Table",
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "name": "input_value",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "other",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "Machine"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "AI"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatOutput"
- },
- "id": "ChatOutput-collected",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 800,
- "y": 200
- },
- "type": "genericNode"
- }
- ],
"viewport": {
"x": 0,
"y": 0,
@@ -979,8 +979,12 @@
},
"description": "Structured intake via CustomInputForm (parsed into a web form).",
"endpoint_name": null,
+ "icon": null,
+ "icon_bg_color": null,
"id": "ec795b9d-f9d2-5730-b016-16e5f9b3bd3f",
"is_component": false,
+ "locked": false,
+ "mcp_enabled": false,
"name": "intake_form",
"tags": [],
"webhook": false
diff --git a/backend/flows/mitarbeiter_einstellen.json b/backend/flows/mitarbeiter_einstellen.json
index e383193..9bd4176 100644
--- a/backend/flows/mitarbeiter_einstellen.json
+++ b/backend/flows/mitarbeiter_einstellen.json
@@ -430,7 +430,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "\"\"\"Mitarbeiter-einstellen form component for kirche-einfach flows.\n\nStandalone by design: this module shares NO code with the other form\nmodules (no imports from them). The LFX runtime executes the embedded\nmodule source without our ``kirche`` package, so every form component\nmust be self-contained (stdlib + ``lfx`` only). Do NOT refactor the\nduplicated machinery below into a shared import — it would break flow\nexecution. Copy this file (or ``bauvorhaben_form.py``) as the starting\npoint for future forms.\n\nA flow that starts with :class:`CustomInputMitarbeiter` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n DropdownInput,\n MessageTextInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputMitarbeiter(Component):\n display_name = \"Mitarbeiter Einstellen Form\"\n description = \"Collects hiring form values into a single JSON object.\"\n icon = \"UserPlus\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"rolle\",\n display_name=\"Rolle\",\n info=\"\",\n options=[\"Assistenz\", \"Pfarrer:in\", \"Vikar:in\", \"Messner:in\"],\n value=\"Assistenz\",\n required=True,\n ),\n DropdownInput(\n name=\"einstufung\",\n display_name=\"Einstufung\",\n info=\"Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]\",\n options=[\"Stufe 1\", \"Stufe 2\", \"Stufe 3\"],\n value=\"Stufe 1\",\n ),\n StrInput(\n name=\"mitarbeiter_name\",\n display_name=\"Name\",\n info=\"Vor- und Nachname des Mitarbeiters\",\n value=\"\",\n required=True,\n ),\n StrInput(\n name=\"geburtsdatum\",\n display_name=\"Geburtsdatum\",\n info=\"Datum eingeben (z. B. JJJJ-MM-TT)\",\n value=\"\",\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n"
+ "value": "\"\"\"Mitarbeiter-einstellen form component for kirche-einfach flows.\n\nThin variant: shared runtime lives in ``kirche._form_core`` (imported here\nfor the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``\ninlines the core into the exported ``code`` so the LFX runtime stays\nself-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting\npoint for future forms.\n\nA flow that starts with :class:`CustomInputMitarbeiter` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json # noqa: F401 -- used by inlined _form_core at runtime\nimport re # noqa: F401 -- used by inlined _form_core at runtime\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n DropdownInput,\n MessageTextInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n#: Reserved input carrying the run envelope — never a form field name.\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object, structured=None) -> list:\n \"\"\"[(trigger, value)] from structured key or info markers.\n\n Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``\n dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as\n deprecated fallback.\n \"\"\"\n if structured:\n conditions: list = []\n items = structured.items() if isinstance(structured, dict) else structured\n for item in items or []:\n if isinstance(item, dict) and \"field\" in item:\n conditions.append((item[\"field\"], item.get(\"value\")))\n elif isinstance(item, (list, tuple)) and len(item) == 2:\n conditions.append((item[0], item[1]))\n if conditions:\n return conditions\n conditions = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\ndef collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:\n \"\"\"Shared collect(): effective values, visibility scope, submitted wins.\"\"\"\n effective = dict(field_values)\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n scope = [n for n in (asked if asked is not None else field_names) if n in field_names]\n return effective, scope\n\n\ndef parse_envelope(text_value) -> tuple[dict, list | None]:\n \"\"\"(payload, asked-fields) from the run envelope text.\"\"\"\n try:\n envelope = json.loads(_text(text_value) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n return {}, None\n if not isinstance(envelope, dict):\n return {}, None\n if isinstance(envelope.get(\"payload\"), dict):\n fields = envelope.get(\"fields\")\n asked = (\n [i for i in fields if i]\n if isinstance(fields, list) and all(isinstance(i, str) for i in fields)\n else None\n )\n return envelope[\"payload\"], asked\n return envelope, None\n\n# (LFX executes embedded source without the kirche package) -->\n\n\nclass CustomInputMitarbeiter(Component):\n display_name = \"Mitarbeiter Einstellen Form\"\n description = \"Collects hiring form values into a single JSON object.\"\n icon = \"UserPlus\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"rolle\",\n display_name=\"Rolle\",\n info=\"\",\n options=[\"Assistenz\", \"Pfarrer:in\", \"Vikar:in\", \"Messner:in\"],\n value=\"Assistenz\",\n required=True,\n ),\n DropdownInput(\n name=\"einstufung\",\n display_name=\"Einstufung\",\n info=\"Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]\",\n options=[\"Stufe 1\", \"Stufe 2\", \"Stufe 3\"],\n value=\"Stufe 1\",\n ),\n StrInput(\n name=\"mitarbeiter_name\",\n display_name=\"Name\",\n info=\"Vor- und Nachname des Mitarbeiters\",\n value=\"\",\n required=True,\n ),\n StrInput(\n name=\"geburtsdatum\",\n display_name=\"Geburtsdatum\",\n info=\"Datum eingeben (z. B. JJJJ-MM-TT)\",\n value=\"\",\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n structured = None\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n structured = getattr(inputs[name], \"visible_if\", None)\n for trigger, expected in _field_conditions(info, structured):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n payload, asked = parse_envelope(self.form_values)\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n field_values = {name: _json_safe(self._field_value(name)) for name in declared}\n effective, scope = collect_form_data(declared, field_values, payload, asked)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)"
}
},
"description": "Collects hiring form values into a single JSON object.",
diff --git a/backend/flows/simple_rule.json b/backend/flows/simple_rule.json
index 4d96d4f..aba77ad 100644
--- a/backend/flows/simple_rule.json
+++ b/backend/flows/simple_rule.json
@@ -1,5 +1,1256 @@
{
"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,
@@ -122,1257 +1373,6 @@
"targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-sonderœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}"
}
],
- "nodes": [
- {
- "data": {
- "id": "ChatInput-entry",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Get chat inputs from the Playground.",
- "display_name": "Chat Input",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Chat Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "files": {
- "_input_type": "FileInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Files",
- "dynamic": false,
- "fileTypes": [
- "csv",
- "json",
- "pdf",
- "txt",
- "md",
- "mdx",
- "yaml",
- "yml",
- "xml",
- "html",
- "htm",
- "docx",
- "py",
- "sh",
- "sql",
- "js",
- "ts",
- "tsx",
- "jpg",
- "jpeg",
- "png",
- "bmp",
- "image"
- ],
- "file_path": "",
- "info": "Files to be sent with the message.",
- "list": true,
- "list_add_label": "Add More",
- "name": "files",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "temp_file": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "file",
- "value": ""
- },
- "input_value": {
- "_input_type": "MultilineInput",
- "advanced": false,
- "ai_enabled": false,
- "api_editable": false,
- "copy_field": false,
- "display_name": "Input Text",
- "dynamic": false,
- "info": "Message to be passed as input.",
- "input_types": [],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "multiline": true,
- "name": "input_value",
- "override_skip": false,
- "password": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "User"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "User"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatInput"
- },
- "id": "ChatInput-entry",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 0,
- "y": 200
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "CustomComponent-rule",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.",
- "display_name": "Rule Check",
- "documentation": "",
- "edited": false,
- "field_order": [
- "payload"
- ],
- "frozen": false,
- "legacy": false,
- "metadata": {
- "code_hash": "0b9f8a009fcf",
- "dependencies": {
- "dependencies": [
- {
- "name": "lfx",
- "version": "1.12.0"
- }
- ],
- "total_dependencies": 1
- },
- "module": "custom_components.rule_check"
- },
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Verdict",
- "group_outputs": false,
- "method": "check",
- "name": "verdict",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "payload": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Payload",
- "dynamic": false,
- "info": "",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "payload",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "CustomComponent"
- },
- "id": "CustomComponent-rule",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 400,
- "y": 200
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ConditionalRouter-router",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Routes an input message to a corresponding output based on text comparison.",
- "display_name": "If-Else",
- "documentation": "https://docs.langflow.org/if-else",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "split",
- "legacy": false,
- "metadata": {},
- "minimized": false,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "True",
- "group_outputs": true,
- "method": "true_response",
- "name": "true_result",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- },
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "False",
- "group_outputs": true,
- "method": "false_response",
- "name": "false_result",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "case_sensitive": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Case Sensitive",
- "dynamic": false,
- "info": "If true, the comparison will be case sensitive.",
- "list": false,
- "list_add_label": "Add More",
- "name": "case_sensitive",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "default_route": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Default Route",
- "dynamic": false,
- "external_options": {},
- "info": "The default route to take when max iterations are reached.",
- "name": "default_route",
- "options": [
- "true_result",
- "false_result"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "false_result"
- },
- "false_case_message": {
- "_input_type": "MessageInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Case False",
- "dynamic": false,
- "info": "The message to pass if the condition is False.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "false_case_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "Standardfall: automatisch freigegeben"
- },
- "input_text": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Text Input",
- "dynamic": false,
- "info": "The primary text input for the operation.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "input_text",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "match_text": {
- "_input_type": "MessageTextInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Match Text",
- "dynamic": false,
- "info": "The text input to compare against.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "match_text",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "PRUEFUNG"
- },
- "max_iterations": {
- "_input_type": "IntInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Max Iterations",
- "dynamic": false,
- "info": "The maximum number of iterations for the conditional router.",
- "list": false,
- "list_add_label": "Add More",
- "name": "max_iterations",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "int",
- "value": 10
- },
- "operator": {
- "_input_type": "DropdownInput",
- "advanced": false,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Operator",
- "dynamic": false,
- "external_options": {},
- "info": "The operator to apply for comparing the texts.",
- "name": "operator",
- "options": [
- "equals",
- "not equals",
- "contains",
- "starts with",
- "ends with",
- "regex",
- "less than",
- "less than or equal",
- "greater than",
- "greater than or equal"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "real_time_refresh": true,
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "equals"
- },
- "true_case_message": {
- "_input_type": "MessageInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Case True",
- "dynamic": false,
- "info": "The message to pass if the condition is True.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "true_case_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "Sonderfall: menschliche Pruefung erforderlich"
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ConditionalRouter"
- },
- "id": "ConditionalRouter-router",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 800,
- "y": 200
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ChatOutput-ok",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Display a chat message in the Playground.",
- "display_name": "Chat Output",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Output Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "clean_data": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Basic Clean Data",
- "dynamic": false,
- "info": "Whether to clean data before converting to string.",
- "list": false,
- "list_add_label": "Add More",
- "name": "clean_data",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "data_template": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Data Template",
- "dynamic": false,
- "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "data_template",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "{text}"
- },
- "input_value": {
- "_input_type": "HandleInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Inputs",
- "dynamic": false,
- "info": "Message to be passed as output.",
- "input_types": [
- "Data",
- "JSON",
- "DataFrame",
- "Table",
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "name": "input_value",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "other",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "Machine"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "AI"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatOutput"
- },
- "id": "ChatOutput-ok",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 1200,
- "y": 60
- },
- "type": "genericNode"
- },
- {
- "data": {
- "id": "ChatOutput-sonder",
- "node": {
- "base_classes": [
- "Message"
- ],
- "beta": false,
- "conditional_paths": [],
- "custom_fields": {},
- "description": "Display a chat message in the Playground.",
- "display_name": "Chat Output",
- "documentation": "https://docs.langflow.org/chat-input-and-output",
- "edited": false,
- "field_order": [],
- "frozen": false,
- "icon": "MessagesSquare",
- "legacy": false,
- "metadata": {},
- "minimized": true,
- "output_types": [],
- "outputs": [
- {
- "allows_loop": false,
- "cache": true,
- "display_name": "Output Message",
- "group_outputs": false,
- "method": "message_response",
- "name": "message",
- "selected": "Message",
- "tool_mode": true,
- "types": [
- "Message"
- ],
- "value": "__UNDEFINED__"
- }
- ],
- "pinned": false,
- "template": {
- "_type": "Component",
- "clean_data": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Basic Clean Data",
- "dynamic": false,
- "info": "Whether to clean data before converting to string.",
- "list": false,
- "list_add_label": "Add More",
- "name": "clean_data",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- },
- "code": {
- "advanced": true,
- "api_editable": false,
- "dynamic": true,
- "fileTypes": [],
- "file_path": "",
- "info": "",
- "list": false,
- "load_from_db": false,
- "multiline": true,
- "name": "code",
- "password": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "type": "code",
- "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"
- },
- "context_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Context ID",
- "dynamic": false,
- "info": "The context ID of the chat. Adds an extra layer to the local memory.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "context_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "data_template": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Data Template",
- "dynamic": false,
- "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "data_template",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "{text}"
- },
- "input_value": {
- "_input_type": "HandleInput",
- "advanced": false,
- "api_editable": false,
- "display_name": "Inputs",
- "dynamic": false,
- "info": "Message to be passed as output.",
- "input_types": [
- "Data",
- "JSON",
- "DataFrame",
- "Table",
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "name": "input_value",
- "override_skip": false,
- "placeholder": "",
- "required": true,
- "show": true,
- "title_case": false,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "other",
- "value": ""
- },
- "sender": {
- "_input_type": "DropdownInput",
- "advanced": true,
- "api_editable": false,
- "combobox": false,
- "dialog_inputs": {},
- "display_name": "Sender Type",
- "dynamic": false,
- "external_options": {},
- "info": "Type of sender.",
- "name": "sender",
- "options": [
- "Machine",
- "User"
- ],
- "options_metadata": [],
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "toggle": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "str",
- "value": "Machine"
- },
- "sender_name": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Sender Name",
- "dynamic": false,
- "info": "Name of the sender.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "sender_name",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": "AI"
- },
- "session_id": {
- "_input_type": "MessageTextInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Session ID",
- "dynamic": false,
- "info": "The session ID of the chat. If empty, the current session ID parameter will be used.",
- "input_types": [
- "Message"
- ],
- "list": false,
- "list_add_label": "Add More",
- "load_from_db": false,
- "name": "session_id",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_input": true,
- "trace_as_metadata": true,
- "track_in_telemetry": false,
- "type": "str",
- "value": ""
- },
- "should_store_message": {
- "_input_type": "BoolInput",
- "advanced": true,
- "api_editable": false,
- "display_name": "Store Messages",
- "dynamic": false,
- "info": "Store the message in the history.",
- "list": false,
- "list_add_label": "Add More",
- "name": "should_store_message",
- "override_skip": false,
- "placeholder": "",
- "required": false,
- "show": true,
- "title_case": false,
- "tool_mode": false,
- "trace_as_metadata": true,
- "track_in_telemetry": true,
- "type": "bool",
- "value": true
- }
- },
- "tool_mode": false
- },
- "showNode": true,
- "type": "ChatOutput"
- },
- "id": "ChatOutput-sonder",
- "measured": {
- "height": 200,
- "width": 320
- },
- "position": {
- "x": 1200,
- "y": 340
- },
- "type": "genericNode"
- }
- ],
"viewport": {
"x": 0,
"y": 0,
@@ -1381,8 +1381,12 @@
},
"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/migrations/versions/c4d8f2a1e6b9_drop_user_sessions.py b/backend/migrations/versions/c4d8f2a1e6b9_drop_user_sessions.py
new file mode 100644
index 0000000..4d9d46e
--- /dev/null
+++ b/backend/migrations/versions/c4d8f2a1e6b9_drop_user_sessions.py
@@ -0,0 +1,46 @@
+"""drop user_sessions (stateless JWT sessions)
+
+Revision ID: c4d8f2a1e6b9
+Revises: b7e2d4a1c9f3
+Create Date: 2026-09-28 00:00:00.000000
+
+Sessions are stateless HS256 JWTs in the HttpOnly cookie (see
+app/auth/service.py mint_session_token). All sessions are invalidated
+at deploy — users re-login via magic link.
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+import sqlmodel
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "c4d8f2a1e6b9"
+down_revision: str | Sequence[str] | None = "b7e2d4a1c9f3"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
+ op.drop_table("user_sessions")
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.create_table(
+ "user_sessions",
+ sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
+ sa.Column("user_id", sa.Uuid(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["users.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 5b1a245..079c0de 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -5,10 +5,19 @@ description = "Kirche Einfach - KI-gestuetzte Verwaltungsplattform fuer Kirche"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.14",
+ "atomicwrites>=1.4.1",
"fastapi[standard]>=0.115.0",
+ "fastapi-mail>=1.4.1",
"httpx>=0.28",
+ "jinja2>=3.1",
"pydantic-settings>=2.0",
+ "pyjwt>=2.10",
+ "python-slugify>=8.0",
+ "secure>=1.0",
+ "slowapi>=0.1.9",
"sqlmodel>=0.0.24",
+ "tenacity>=9.0",
+ "typer>=0.15",
"uvicorn[standard]>=0.30.0",
"watchfiles>=0.24",
]
diff --git a/backend/scripts/check_prod.py b/backend/scripts/check_prod.py
index 05182e4..e4901ae 100644
--- a/backend/scripts/check_prod.py
+++ b/backend/scripts/check_prod.py
@@ -11,10 +11,11 @@ from app (stdlib only), prints no secret values.
from __future__ import annotations
-import argparse
import sys
from pathlib import Path
+import typer
+
BACKEND = Path(__file__).resolve().parent.parent
REPO_ROOT = BACKEND.parent
@@ -57,6 +58,7 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
# Shared + service API keys.
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
+ secret("JWT_SECRET", MIN_TOKEN, "session JWT secret")
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
placeholder("LANGFLOW_API_KEY", ("sk-",))
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
@@ -87,11 +89,9 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
return failures, warnings
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
- args = parser.parse_args()
- path = Path(args.env)
+def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> int:
+ """Validate a prod .env file (typer CLI; check/parse_env stay importable)."""
+ path = Path(env)
if not path.is_file():
print(f"no env file at {path}")
return 1
@@ -108,4 +108,4 @@ def main() -> int:
if __name__ == "__main__":
- sys.exit(main())
+ sys.exit(typer.run(main))
diff --git a/backend/scripts/deploy_flow.py b/backend/scripts/deploy_flow.py
index 4f2f279..8df10cb 100644
--- a/backend/scripts/deploy_flow.py
+++ b/backend/scripts/deploy_flow.py
@@ -7,11 +7,11 @@ Usage:
from __future__ import annotations
-import argparse
import json
import sys
import httpx
+import typer
def post(api: str, path: str, body: dict) -> dict:
@@ -26,23 +26,22 @@ def post(api: str, path: str, body: dict) -> dict:
return {"status": 0, "body": str(exc)[: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()
-
+def main(
+ langflow_id: str = typer.Option(..., "--langflow-id"),
+ api: str = typer.Option("http://localhost:8000", "--api"),
+ name: str | None = typer.Option(None, "--name"),
+ author: str = typer.Option("", "--author"),
+ no_deploy: bool = typer.Option(False, "--no-deploy"),
+) -> int:
+ """Import a flow from Langflow via the running API (post() stays importable)."""
result = post(
- args.api,
+ api,
"/api/flows/import",
{
- "langflow_flow_id": args.langflow_id,
- "name": args.name,
- "author": args.author,
- "deploy": not args.no_deploy,
+ "langflow_flow_id": langflow_id,
+ "name": name,
+ "author": author,
+ "deploy": not no_deploy,
},
)
print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -50,4 +49,4 @@ def main() -> int:
if __name__ == "__main__":
- sys.exit(main())
+ sys.exit(typer.run(main))
diff --git a/backend/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py
index 96e7952..026de59 100644
--- a/backend/scripts/make_flow_fixtures.py
+++ b/backend/scripts/make_flow_fixtures.py
@@ -29,6 +29,74 @@ FLOW_DIR = REPO_ROOT / "backend" / "flows"
HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports
+FORM_CORE_PATH = REPO_ROOT / "backend" / "components" / "kirche" / "_form_core.py"
+
+INLINE_MARKER = "# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()"
+
+
+def _inline_core() -> str:
+ """Body of _form_core.py minus docstring/future/std-imports (already present)."""
+ text = FORM_CORE_PATH.read_text(encoding="utf-8")
+ lines = text.splitlines()
+ body: list[str] = []
+ skip_docstring = True
+ in_docstring = False
+ for line in lines:
+ stripped = line.strip()
+ if skip_docstring:
+ if stripped.startswith('"""') and not in_docstring:
+ if stripped.count('"""') >= 2:
+ skip_docstring = False
+ else:
+ in_docstring = True
+ continue
+ if in_docstring:
+ if '"""' in stripped:
+ in_docstring = False
+ skip_docstring = False
+ continue
+ if stripped in (
+ "from __future__ import annotations",
+ "import json",
+ "import re",
+ "",
+ ):
+ continue
+ skip_docstring = False
+ if stripped in ("from __future__ import annotations", "import json", "import re"):
+ continue
+ body.append(line)
+ return "\n".join(body).strip() + "\n"
+
+
+def _compose_component_code(source: str) -> str:
+ """Replace the sibling import + marker with inlined core (LFX self-contained)."""
+ core = _inline_core()
+ out: list[str] = []
+ skipping_import = False
+ for line in source.splitlines():
+ stripped = line.strip()
+ if not skipping_import and stripped.startswith(
+ ("from kirche._form_core import", "from ._form_core import")
+ ):
+ out.append(core.rstrip())
+ # Multi-line parenthesized import: skip continuation lines
+ # up to and including the closing paren.
+ if "(" in stripped and ")" not in stripped:
+ skipping_import = True
+ continue
+ if skipping_import:
+ if ")" in stripped:
+ skipping_import = False
+ continue
+ if "_form_core inlined by" in stripped:
+ continue
+ out.append(line)
+ composed = "\n".join(out)
+ assert "from kirche" not in composed, "sibling import survived inlining"
+ assert "from ._" not in composed, "relative import survived inlining"
+ return composed
+
# --------------------------------------------------------------------------
# node builders
@@ -118,7 +186,7 @@ def kirche_node(
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
inst = component_cls()
if code_override is not None:
- inst._code = code_override
+ inst._code = _compose_component_code(code_override)
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
inst._map_parameters_on_frontend_node(frontend_node)
node = frontend_node.to_dict(keep_name=False)
@@ -126,6 +194,10 @@ def kirche_node(
inst._map_parameters_on_template(node["template"])
if not getattr(inst, "_code", None):
inst.set_class_code()
+ # Always compose: default path may already carry thin source with the
+ # sibling import (Component base sets _code on init); override path is
+ # pre-composed but re-composing is idempotent (no import lines left).
+ inst._code = _compose_component_code(inst._code)
node["template"]["code"] = {
"advanced": True,
"api_editable": False,
diff --git a/backend/scripts/smoke_e2e.py b/backend/scripts/smoke_e2e.py
index f90b81e..c00092b 100644
--- a/backend/scripts/smoke_e2e.py
+++ b/backend/scripts/smoke_e2e.py
@@ -37,6 +37,9 @@ LFX_PORT = _free_port()
API_PORT = _free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
+# Stateless admin session JWT, minted in main() after planting the user.
+_SMOKE_JWT = ""
+
def wait_healthy(url: str, timeout: int = 180) -> None:
deadline = time.time() + timeout
@@ -54,9 +57,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
import httpx
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
- # Management + runs are login-gated; the smoke admin session is
- # planted directly in the throwaway DB (see main()).
- c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
+ # Management + runs are login-gated; the smoke admin JWT is minted
+ # in main() (stateless sessions, see app/auth/service.py).
+ c.cookies.set("kirche_session", _SMOKE_JWT, domain="127.0.0.1")
r = c.request(method, path, json=body)
try:
return r.status_code, r.json()
@@ -90,27 +93,31 @@ def main() -> None:
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head")
- # Plant an admin session: management + runs require login, and there is
- # no SMTP in smoke to deliver a magic link.
- from datetime import UTC, datetime, timedelta
+ # Plant an admin user + mint its session JWT: management + runs
+ # require login, and there is no SMTP in smoke to deliver a magic link.
+ # Sessions are stateless (see app/auth/service.py) — the cookie value
+ # below is the JWT sent as `kirche_session`.
+ global _SMOKE_JWT
+ import os
from sqlmodel import Session, create_engine
- from app.models import User, UserSession
+ from app.auth import service as auth_service
+ from app.models import User
+ from app.settings import settings as app_settings
+ if not app_settings.jwt_secret:
+ app_settings.jwt_secret = os.environ.get(
+ "JWT_SECRET", "smoke-jwt-secret-32-chars-minimum!!"
+ )
engine = create_engine(db_url)
with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin")
s.add(user)
- s.flush()
- s.add(
- UserSession(
- id="smoke-session",
- user_id=user.id,
- expires_at=datetime.now(UTC) + timedelta(hours=1),
- )
- )
s.commit()
+ s.refresh(user)
+ _SMOKE_JWT = auth_service.mint_session_token(user)
+ print("smoke admin JWT minted")
engine.dispose()
env = {
diff --git a/backend/scripts/sync_flows.py b/backend/scripts/sync_flows.py
index 16be65a..5807384 100644
--- a/backend/scripts/sync_flows.py
+++ b/backend/scripts/sync_flows.py
@@ -9,12 +9,12 @@ Exit codes: 0 ok, 1 sync errors, 2 flows dir missing.
from __future__ import annotations
-import argparse
import asyncio
import json
import sys
from pathlib import Path
+import typer
from sqlmodel import Session
from app.db import get_engine, reset_engine
@@ -80,18 +80,17 @@ async def main_async(args) -> int:
return 0
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument(
- "--pull", action="store_true", help="export all Langflow flows to backend/flows first"
- )
- parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
- parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
- args = parser.parse_args()
- if not args.pull and not args.sync:
- args.sync = True
+def main(
+ pull: bool = typer.Option(False, "--pull", help="export all Langflow flows first"),
+ sync: bool = typer.Option(False, "--sync", help="reconcile backend/flows into DB + LFX"),
+ dir: str | None = typer.Option(None, "--dir", help="flows directory"),
+) -> int:
+ """Reconcile flows (typer CLI; run_sync/run_full_pass stay importable)."""
+ if not pull and not sync:
+ sync = True
+ args = type("Args", (), {"pull": pull, "sync": sync, "dir": dir})()
return asyncio.run(main_async(args))
if __name__ == "__main__":
- sys.exit(main())
+ sys.exit(typer.run(main))
diff --git a/backend/tests/_harness.py b/backend/tests/_harness.py
index 77570b0..1774c5e 100644
--- a/backend/tests/_harness.py
+++ b/backend/tests/_harness.py
@@ -66,3 +66,42 @@ def migrate_throwaway(backend_dir: Path) -> None:
cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
alembic_command.upgrade(cfg, "head")
+
+
+# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the
+# repo .env value must be cleared wherever a test key is used.
+TEST_LFX_KEY = "test-lfx-key"
+
+
+def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def]
+ """Real uvicorn server in a daemon thread. Returns (server, thread)."""
+ import threading
+ import time
+
+ import uvicorn
+
+ config = uvicorn.Config(app, host=host, port=port, 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"
+ return server, thread
+
+
+def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object:
+ """Idempotent test user + session JWT (shared with smoke pattern)."""
+ from sqlmodel import select
+
+ from app.auth import service
+ from app.models import User
+
+ user = session.exec(select(User).where(User.email == email)).first()
+ if user is None:
+ user = User(name=name, email=email, role=role)
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+ return user, service.mint_session_token(user)
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 73e1a30..cc90151 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -19,6 +19,8 @@ os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
+# Stateless session JWTs are signed with this (HS256).
+os.environ["JWT_SECRET"] = "test-jwt-secret-32-chars-minimum!!"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
@@ -93,13 +95,11 @@ def login(client, session):
role: str = "admin",
tenant_id: str = "default",
):
- from datetime import UTC, datetime, timedelta
- from uuid import uuid4
-
from sqlmodel import select
+ from app.auth import service
from app.auth.deps import SESSION_COOKIE
- from app.models import User, UserSession
+ from app.models import User
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
@@ -111,15 +111,9 @@ def login(client, session):
user.role = role
user.tenant_id = tenant_id
session.add(user)
- session.flush()
- user_session = UserSession(
- id=f"test-session-{uuid4().hex}",
- user_id=user.id,
- expires_at=datetime.now(UTC) + timedelta(days=1),
- )
- session.add(user_session)
session.commit()
- client.cookies.set(SESSION_COOKIE, user_session.id)
+ session.refresh(user)
+ client.cookies.set(SESSION_COOKIE, service.mint_session_token(user))
return user
return _login
@@ -132,76 +126,33 @@ def lfx_server(tmp_path_factory):
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()
+ from ._harness import TEST_LFX_KEY, free_port, spawn_lfx, wait_healthy
+ port = free_port()
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.
- # get_api_key() prefers LANGFLOW_API_KEY over LFX_API_KEY, and the repo
- # .env may put a real Langflow key in the environment — clear it so the
- # test key below is the one the client sends.
- "LANGFLOW_API_KEY": "",
- "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,
- )
+ proc = spawn_lfx(flow_dir, port)
url = f"http://127.0.0.1:{port}"
- deadline = time.time() + 240
- healthy = False
- while time.time() < deadline:
+ try:
+ wait_healthy(url)
+ except Exception:
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")
+ raise
old_url = settings.lfx_url
old_key = settings.lfx_api_key
settings.lfx_url = url
- settings.lfx_api_key = "test-lfx-key"
+ settings.lfx_api_key = TEST_LFX_KEY
yield url
settings.lfx_url = old_url
settings.lfx_api_key = old_key
@@ -259,21 +210,11 @@ def override_langflow():
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"
+ from ._harness import spawn_uvicorn
+
+ server, thread = spawn_uvicorn(app)
port = server.servers[0].sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
server.should_exit = True
@@ -293,12 +234,20 @@ def load_flow_fixture(name: str) -> dict:
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
- """Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
+ """Full Langflow export envelope (merges _export_doc/_langflow_doc).
+
+ Carries real fixture flow data so import/deploy paths exercise LFX.
+ """
+ fixture = load_flow_fixture("simple_rule.json")
return {
"id": flow_id,
"name": name,
- "description": "",
- "data": {"nodes": [], "edges": []},
+ "description": "aus Langflow",
+ "endpoint_name": None,
+ "data": fixture["data"],
+ "tags": [],
+ "webhook": False,
+ "is_component": False,
}
diff --git a/backend/tests/fixtures/flows/bauvorhaben.json b/backend/tests/fixtures/flows/bauvorhaben.json
index ee0b311..4a093a8 100644
--- a/backend/tests/fixtures/flows/bauvorhaben.json
+++ b/backend/tests/fixtures/flows/bauvorhaben.json
@@ -509,7 +509,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "\"\"\"Bauvorhaben form component for kirche-einfach flows.\n\nStandalone by design: this module shares NO code with ``form_inputs``\n(no imports from it). The LFX runtime executes the embedded module\nsource without our ``kirche`` package, so every form component must be\nself-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated\nmachinery below into a shared import — it would break flow execution.\nCopy this file as the starting point for future forms.\n\nA flow that starts with :class:`CustomInputBauvorhaben` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches, see below),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultiselectInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputBauvorhaben(Component):\n display_name = \"Bauvorhaben Form\"\n description = \"Collects Bauvorhaben form values into a single JSON object.\"\n icon = \"Church\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"gebaeude\",\n display_name=\"Welches Gebäude soll verändert werden?\",\n info=\"\",\n options=[\"Kirche\", \"Anderes Gebäude\"],\n value=\"\",\n required=True,\n ),\n MultiselectInput(\n name=\"bestandteil\",\n display_name=\"Welcher Bestandteil der Kirche?\",\n info=\"Mehrfachauswahl möglich [visible_if gebaeude=Kirche]\",\n options=[\"Glocke\", \"Orgel\", \"Innenraum\", \"Fenster\", \"Fassade\"],\n value=[],\n required=True,\n ),\n DropdownInput(\n name=\"glockenmassnahme\",\n display_name=\"Welche Maßnahme an der Glocke?\",\n info=\"[visible_if bestandteil=Glocke]\",\n options=[\"Reinigung\", \"Reparatur\", \"Neuguss\"],\n value=\"Reinigung\",\n ),\n IntInput(\n name=\"gussjahr\",\n display_name=\"Gussjahr (nur bei Neuguss)?\",\n info=\"[visible_if glockenmassnahme=Neuguss]\",\n value=0,\n ),\n DropdownInput(\n name=\"gebaeudeart\",\n display_name=\"Art des anderen Gebäudes?\",\n info='[visible_if gebaeude=\"Anderes Gebäude\"]',\n options=[\"Wohnhaus\", \"Scheune\", \"Sonstiges\"],\n value=\"Wohnhaus\",\n ),\n BoolInput(\n name=\"denkmalschutz\",\n display_name=\"Steht das Gebäude unter Denkmalschutz?\",\n info=\"\",\n value=False,\n ),\n StrInput(\n name=\"denkmalnummer\",\n display_name=\"Aktenzeichen Denkmalschutz?\",\n info=\"[visible_if denkmalschutz=true]\",\n value=\"\",\n required=True,\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n"
+ "value": "\"\"\"Bauvorhaben form component for kirche-einfach flows.\n\nThin variant: shared runtime lives in ``kirche._form_core`` (imported here\nfor the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``\ninlines the core into the exported ``code`` so the LFX runtime stays\nself-contained. Copy this file as the starting point for future forms.\n\nA flow that starts with :class:`CustomInputBauvorhaben` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches, see below),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json # noqa: F401 -- used by inlined _form_core at runtime\nimport re # noqa: F401 -- used by inlined _form_core at runtime\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultiselectInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n#: Reserved input carrying the run envelope — never a form field name.\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object, structured=None) -> list:\n \"\"\"[(trigger, value)] from structured key or info markers.\n\n Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``\n dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as\n deprecated fallback.\n \"\"\"\n if structured:\n conditions: list = []\n items = structured.items() if isinstance(structured, dict) else structured\n for item in items or []:\n if isinstance(item, dict) and \"field\" in item:\n conditions.append((item[\"field\"], item.get(\"value\")))\n elif isinstance(item, (list, tuple)) and len(item) == 2:\n conditions.append((item[0], item[1]))\n if conditions:\n return conditions\n conditions = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\ndef collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:\n \"\"\"Shared collect(): effective values, visibility scope, submitted wins.\"\"\"\n effective = dict(field_values)\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n scope = [n for n in (asked if asked is not None else field_names) if n in field_names]\n return effective, scope\n\n\ndef parse_envelope(text_value) -> tuple[dict, list | None]:\n \"\"\"(payload, asked-fields) from the run envelope text.\"\"\"\n try:\n envelope = json.loads(_text(text_value) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n return {}, None\n if not isinstance(envelope, dict):\n return {}, None\n if isinstance(envelope.get(\"payload\"), dict):\n fields = envelope.get(\"fields\")\n asked = (\n [i for i in fields if i]\n if isinstance(fields, list) and all(isinstance(i, str) for i in fields)\n else None\n )\n return envelope[\"payload\"], asked\n return envelope, None\n\n# (LFX executes embedded source without the kirche package) -->\n\n\nclass CustomInputBauvorhaben(Component):\n display_name = \"Bauvorhaben Form\"\n description = \"Collects Bauvorhaben form values into a single JSON object.\"\n icon = \"Church\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"gebaeude\",\n display_name=\"Welches Gebäude soll verändert werden?\",\n info=\"\",\n options=[\"Kirche\", \"Anderes Gebäude\"],\n value=\"\",\n required=True,\n ),\n MultiselectInput(\n name=\"bestandteil\",\n display_name=\"Welcher Bestandteil der Kirche?\",\n info=\"Mehrfachauswahl möglich [visible_if gebaeude=Kirche]\",\n options=[\"Glocke\", \"Orgel\", \"Innenraum\", \"Fenster\", \"Fassade\"],\n value=[],\n required=True,\n ),\n DropdownInput(\n name=\"glockenmassnahme\",\n display_name=\"Welche Maßnahme an der Glocke?\",\n info=\"[visible_if bestandteil=Glocke]\",\n options=[\"Reinigung\", \"Reparatur\", \"Neuguss\"],\n value=\"Reinigung\",\n ),\n IntInput(\n name=\"gussjahr\",\n display_name=\"Gussjahr (nur bei Neuguss)?\",\n info=\"[visible_if glockenmassnahme=Neuguss]\",\n value=0,\n ),\n DropdownInput(\n name=\"gebaeudeart\",\n display_name=\"Art des anderen Gebäudes?\",\n info='[visible_if gebaeude=\"Anderes Gebäude\"]',\n options=[\"Wohnhaus\", \"Scheune\", \"Sonstiges\"],\n value=\"Wohnhaus\",\n ),\n BoolInput(\n name=\"denkmalschutz\",\n display_name=\"Steht das Gebäude unter Denkmalschutz?\",\n info=\"\",\n value=False,\n ),\n StrInput(\n name=\"denkmalnummer\",\n display_name=\"Aktenzeichen Denkmalschutz?\",\n info=\"[visible_if denkmalschutz=true]\",\n value=\"\",\n required=True,\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n structured = None\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n structured = getattr(inputs[name], \"visible_if\", None)\n for trigger, expected in _field_conditions(info, structured):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n payload, asked = parse_envelope(self.form_values)\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n field_values = {name: _json_safe(self._field_value(name)) for name in declared}\n effective, scope = collect_form_data(declared, field_values, payload, asked)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)"
}
},
"description": "Collects Bauvorhaben form values into a single JSON object.",
diff --git a/backend/tests/fixtures/flows/intake_form.json b/backend/tests/fixtures/flows/intake_form.json
index 792e3ff..6fa9665 100644
--- a/backend/tests/fixtures/flows/intake_form.json
+++ b/backend/tests/fixtures/flows/intake_form.json
@@ -579,7 +579,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\nones (``form_values``, ``code``), so fields can be renamed, added, or\nremoved in the Langflow code editor without touching this method.\n``name`` is the stable JSON key (freely renamable, must stay unique and\nnon-empty); downstream components read the keys as-is.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\n#: Inputs that are plumbing, not form fields (never part of the output).\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def collect(self) -> Data:\n data = {}\n for name in self._field_names():\n data[name] = _json_safe(self._field_value(name))\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n # Unknown keys are kept: the frontend may already know renamed\n # fields this code revision does not declare.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key, val in override.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n"
+ "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\nones (``form_values``, ``code``), so fields can be renamed, added, or\nremoved in the Langflow code editor without touching this method.\n``name`` is the stable JSON key (freely renamable, must stay unique and\nnon-empty); downstream components read the keys as-is.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked fields — the template may declare fields unknown to this code and\nunasked code defaults stay out of the output. Without the list (raw dict,\nLangflow playground), all declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json # noqa: F401 -- used by inlined _form_core at runtime\nimport re # noqa: F401 -- used by inlined _form_core at runtime\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n#: Reserved input carrying the run envelope — never a form field name.\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object, structured=None) -> list:\n \"\"\"[(trigger, value)] from structured key or info markers.\n\n Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``\n dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as\n deprecated fallback.\n \"\"\"\n if structured:\n conditions: list = []\n items = structured.items() if isinstance(structured, dict) else structured\n for item in items or []:\n if isinstance(item, dict) and \"field\" in item:\n conditions.append((item[\"field\"], item.get(\"value\")))\n elif isinstance(item, (list, tuple)) and len(item) == 2:\n conditions.append((item[0], item[1]))\n if conditions:\n return conditions\n conditions = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\ndef collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:\n \"\"\"Shared collect(): effective values, visibility scope, submitted wins.\"\"\"\n effective = dict(field_values)\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n scope = [n for n in (asked if asked is not None else field_names) if n in field_names]\n return effective, scope\n\n\ndef parse_envelope(text_value) -> tuple[dict, list | None]:\n \"\"\"(payload, asked-fields) from the run envelope text.\"\"\"\n try:\n envelope = json.loads(_text(text_value) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n return {}, None\n if not isinstance(envelope, dict):\n return {}, None\n if isinstance(envelope.get(\"payload\"), dict):\n fields = envelope.get(\"fields\")\n asked = (\n [i for i in fields if i]\n if isinstance(fields, list) and all(isinstance(i, str) for i in fields)\n else None\n )\n return envelope[\"payload\"], asked\n return envelope, None\n\n# (LFX executes embedded source without the kirche package) -->\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n structured = None\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n structured = getattr(inputs[name], \"visible_if\", None)\n for trigger, expected in _field_conditions(info, structured):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n payload, asked = parse_envelope(self.form_values)\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n field_values = {name: _json_safe(self._field_value(name)) for name in declared}\n effective, scope = collect_form_data(declared, field_values, payload, asked)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)"
}
},
"description": "Collects structured form values into a single JSON object.",
diff --git a/backend/tests/fixtures/flows/mitarbeiter_einstellen.json b/backend/tests/fixtures/flows/mitarbeiter_einstellen.json
index e383193..9bd4176 100644
--- a/backend/tests/fixtures/flows/mitarbeiter_einstellen.json
+++ b/backend/tests/fixtures/flows/mitarbeiter_einstellen.json
@@ -430,7 +430,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "\"\"\"Mitarbeiter-einstellen form component for kirche-einfach flows.\n\nStandalone by design: this module shares NO code with the other form\nmodules (no imports from them). The LFX runtime executes the embedded\nmodule source without our ``kirche`` package, so every form component\nmust be self-contained (stdlib + ``lfx`` only). Do NOT refactor the\nduplicated machinery below into a shared import — it would break flow\nexecution. Copy this file (or ``bauvorhaben_form.py``) as the starting\npoint for future forms.\n\nA flow that starts with :class:`CustomInputMitarbeiter` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n DropdownInput,\n MessageTextInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputMitarbeiter(Component):\n display_name = \"Mitarbeiter Einstellen Form\"\n description = \"Collects hiring form values into a single JSON object.\"\n icon = \"UserPlus\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"rolle\",\n display_name=\"Rolle\",\n info=\"\",\n options=[\"Assistenz\", \"Pfarrer:in\", \"Vikar:in\", \"Messner:in\"],\n value=\"Assistenz\",\n required=True,\n ),\n DropdownInput(\n name=\"einstufung\",\n display_name=\"Einstufung\",\n info=\"Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]\",\n options=[\"Stufe 1\", \"Stufe 2\", \"Stufe 3\"],\n value=\"Stufe 1\",\n ),\n StrInput(\n name=\"mitarbeiter_name\",\n display_name=\"Name\",\n info=\"Vor- und Nachname des Mitarbeiters\",\n value=\"\",\n required=True,\n ),\n StrInput(\n name=\"geburtsdatum\",\n display_name=\"Geburtsdatum\",\n info=\"Datum eingeben (z. B. JJJJ-MM-TT)\",\n value=\"\",\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n"
+ "value": "\"\"\"Mitarbeiter-einstellen form component for kirche-einfach flows.\n\nThin variant: shared runtime lives in ``kirche._form_core`` (imported here\nfor the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``\ninlines the core into the exported ``code`` so the LFX runtime stays\nself-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting\npoint for future forms.\n\nA flow that starts with :class:`CustomInputMitarbeiter` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json # noqa: F401 -- used by inlined _form_core at runtime\nimport re # noqa: F401 -- used by inlined _form_core at runtime\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n DropdownInput,\n MessageTextInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n#: Reserved input carrying the run envelope — never a form field name.\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\ndef _text(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\ndef _json_safe(value):\n \"\"\"Plain JSON value: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object, structured=None) -> list:\n \"\"\"[(trigger, value)] from structured key or info markers.\n\n Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}``\n dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as\n deprecated fallback.\n \"\"\"\n if structured:\n conditions: list = []\n items = structured.items() if isinstance(structured, dict) else structured\n for item in items or []:\n if isinstance(item, dict) and \"field\" in item:\n conditions.append((item[\"field\"], item.get(\"value\")))\n elif isinstance(item, (list, tuple)) and len(item) == 2:\n conditions.append((item[0], item[1]))\n if conditions:\n return conditions\n conditions = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\ndef collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict:\n \"\"\"Shared collect(): effective values, visibility scope, submitted wins.\"\"\"\n effective = dict(field_values)\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n scope = [n for n in (asked if asked is not None else field_names) if n in field_names]\n return effective, scope\n\n\ndef parse_envelope(text_value) -> tuple[dict, list | None]:\n \"\"\"(payload, asked-fields) from the run envelope text.\"\"\"\n try:\n envelope = json.loads(_text(text_value) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n return {}, None\n if not isinstance(envelope, dict):\n return {}, None\n if isinstance(envelope.get(\"payload\"), dict):\n fields = envelope.get(\"fields\")\n asked = (\n [i for i in fields if i]\n if isinstance(fields, list) and all(isinstance(i, str) for i in fields)\n else None\n )\n return envelope[\"payload\"], asked\n return envelope, None\n\n# (LFX executes embedded source without the kirche package) -->\n\n\nclass CustomInputMitarbeiter(Component):\n display_name = \"Mitarbeiter Einstellen Form\"\n description = \"Collects hiring form values into a single JSON object.\"\n icon = \"UserPlus\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"rolle\",\n display_name=\"Rolle\",\n info=\"\",\n options=[\"Assistenz\", \"Pfarrer:in\", \"Vikar:in\", \"Messner:in\"],\n value=\"Assistenz\",\n required=True,\n ),\n DropdownInput(\n name=\"einstufung\",\n display_name=\"Einstufung\",\n info=\"Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]\",\n options=[\"Stufe 1\", \"Stufe 2\", \"Stufe 3\"],\n value=\"Stufe 1\",\n ),\n StrInput(\n name=\"mitarbeiter_name\",\n display_name=\"Name\",\n info=\"Vor- und Nachname des Mitarbeiters\",\n value=\"\",\n required=True,\n ),\n StrInput(\n name=\"geburtsdatum\",\n display_name=\"Geburtsdatum\",\n info=\"Datum eingeben (z. B. JJJJ-MM-TT)\",\n value=\"\",\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n structured = None\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n structured = getattr(inputs[name], \"visible_if\", None)\n for trigger, expected in _field_conditions(info, structured):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n payload, asked = parse_envelope(self.form_values)\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n field_values = {name: _json_safe(self._field_value(name)) for name in declared}\n effective, scope = collect_form_data(declared, field_values, payload, asked)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)"
}
},
"description": "Collects hiring form values into a single JSON object.",
diff --git a/backend/tests/test_approvals.py b/backend/tests/test_approvals.py
index 2ee5661..911de66 100644
--- a/backend/tests/test_approvals.py
+++ b/backend/tests/test_approvals.py
@@ -1,16 +1,15 @@
"""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"
+from .conftest import load_flow_fixture
def load_fixture(name: str) -> dict:
- return json.loads((FIXTURES / name).read_text())
+ """Alias of the shared conftest helper (kept for call-site stability)."""
+ return load_flow_fixture(name)
async def _create_flow(client, name):
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index e48de8a..7790b57 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -93,6 +93,9 @@ async def test_request_approve_consume_me_logout(client, session):
assert me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200
+ # Stateless sessions: logout clears the cookie client-side
+ # (browsers honor Set-Cookie delete); drop it like they do.
+ member.cookies.clear()
assert (await member.get("/api/auth/me")).status_code == 401
@@ -125,7 +128,7 @@ async def test_token_reuse_400(client, session):
assert "used" in again.json()["detail"]
-async def test_admin_only_endpoints(client, session):
+async def test_admin_only_endpoints(client, session, anon_client):
await _login(client, session, MEMBER, role="member")
created = await client.post(
"/api/access/requests",
@@ -137,12 +140,7 @@ async def test_admin_only_endpoints(client, session):
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403
- from httpx import ASGITransport, AsyncClient
-
- from app.main import app
-
- transport = ASGITransport(app=app)
- async with AsyncClient(transport=transport, base_url="http://test") as anon:
+ async for anon in anon_client():
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
@@ -183,11 +181,11 @@ async def test_session_cookie_flags(client, session):
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
- raw = " ".join(response.headers.get_list("set-cookie")).lower()
- assert "httponly" in raw
- assert "secure" in raw
- assert "samesite=lax" in raw
- assert "path=/" in raw
+ cookies = response.headers.get_list("set-cookie")
+ assert any("httponly" in c.lower() for c in cookies), cookies
+ assert any("secure" in c.lower() for c in cookies), cookies
+ assert any("samesite=lax" in c.lower() for c in cookies), cookies
+ assert any("path=/" in c.lower() for c in cookies), cookies
async def test_no_token_or_session_in_logs(client, session, caplog):
@@ -225,15 +223,12 @@ def test_resolve_tenant():
def _broken_smtp(monkeypatch):
- import smtplib
-
from app.auth import mail
- class ExplodingSMTP:
- def __init__(self, *args, **kwargs):
- raise smtplib.SMTPException("relay down")
+ def _explode(message):
+ raise OSError("relay down")
- monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
+ monkeypatch.setattr(mail, "_send", _explode)
def test_magic_link_delivery_failure_returns_false(monkeypatch):
@@ -315,9 +310,8 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
- assert 'Jetzt anmelden" in html
- assert mail.BLUE in html # kirchenblau brand bar
+ assert "http://localhost:3000/login?token=GEHEIM" in html
+ assert "Jetzt anmelden" in html
assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]
diff --git a/backend/tests/test_check_prod.py b/backend/tests/test_check_prod.py
index e695f08..5d6819e 100644
--- a/backend/tests/test_check_prod.py
+++ b/backend/tests/test_check_prod.py
@@ -3,6 +3,8 @@
import sys
from pathlib import Path
+import pytest
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
@@ -12,6 +14,7 @@ from check_prod import check
def _good() -> dict:
return {
"CALLBACK_TOKEN": "t" * 40,
+ "JWT_SECRET": "j" * 40,
"LANGFLOW_API_KEY": "k" * 24,
"LFX_API_KEY": "x" * 24,
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
@@ -31,33 +34,28 @@ def test_good_env_passes_without_warnings():
assert warnings == []
-def test_empty_and_short_secrets_fail():
+@pytest.mark.parametrize(
+ ("key", "bad_value"),
+ [
+ ("CALLBACK_TOKEN", ""),
+ ("JWT_SECRET", ""),
+ ("CALLBACK_TOKEN", "t" * 10),
+ ("JWT_SECRET", "j" * 10),
+ ("LANGFLOW_SUPERUSER_PASSWORD", "short"),
+ ("LANGFLOW_SECRET_KEY", "s" * 10),
+ ("LANGFLOW_API_KEY", "sk-"),
+ ("LFX_API_KEY", "lfx-abc"),
+ ("FRONTEND_MODE", "dev"),
+ ("APP_ENV", "dev"),
+ ("LANGFLOW_AUTO_LOGIN", "true"),
+ ],
+)
+def test_bad_values_fail(key, bad_value):
values = _good()
- values["CALLBACK_TOKEN"] = ""
- values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
+ values[key] = bad_value
failures, _ = check(values)
- assert any("CALLBACK_TOKEN" in f for f in failures)
- assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
-
-
-def test_placeholders_fail():
- values = _good()
- values["LANGFLOW_API_KEY"] = "sk-"
- values["LFX_API_KEY"] = "lfx-abc"
- failures, _ = check(values)
- assert any("LANGFLOW_API_KEY" in f for f in failures)
- assert any("LFX_API_KEY" in f for f in failures)
-
-
-def test_insecure_modes_fail():
- values = _good()
- values["FRONTEND_MODE"] = "dev"
- values["APP_ENV"] = "dev"
- values["LANGFLOW_AUTO_LOGIN"] = "true"
- failures, _ = check(values)
- assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
- assert len([f for f in failures if "APP_ENV" in f]) == 1
- assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
+ wanted = "AUTO_LOGIN" if key == "LANGFLOW_AUTO_LOGIN" else key
+ assert any(wanted in f for f in failures), failures
def test_soft_misconfigurations_warn_only():
diff --git a/backend/tests/test_deploy.py b/backend/tests/test_deploy.py
index a8430a4..9728978 100644
--- a/backend/tests/test_deploy.py
+++ b/backend/tests/test_deploy.py
@@ -1,27 +1,20 @@
"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real)."""
import json
-from pathlib import Path
from uuid import UUID, uuid4
+import pytest
+
from app.flows.sync import lfx_id_for
-FIXTURES = Path(__file__).parent / "fixtures" / "flows"
+from .conftest import langflow_export_doc
+
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,
- }
+ """Alias of the shared conftest helper (kept for call-site stability)."""
+ return langflow_export_doc(name, LANGFLOW_ID)
async def test_import_creates_definition_version_and_deploys(
@@ -100,6 +93,14 @@ async def test_import_with_explicit_name(client, login, lfx_server, override_lan
assert response.json()["definition"]["name"] == "wunschname"
+async def test_import_with_implicit_name_uses_langflow_name(client, login, override_langflow):
+ override_langflow(_export_doc(name="langflow-name"))
+ await login()
+ response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
+ assert response.status_code == 201, response.text
+ assert response.json()["definition"]["name"] == "langflow-name"
+
+
async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc())
await login()
@@ -113,18 +114,19 @@ async def test_import_without_deploy(client, login, override_langflow):
assert body["version"]["deployed_at"] is None
-async def test_import_langflow_not_found(client, login, override_langflow):
- override_langflow(status=404)
+@pytest.mark.parametrize(
+ ("fake_status", "expected_status"),
+ [(404, 404), (0, 503)],
+)
+async def test_import_langflow_failures(
+ client, login, override_langflow, fake_status, expected_status
+):
+ """Langflow 404/unreachable maps to 404/503 (parametrized)."""
+ override_langflow(status=fake_status)
await login()
- 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, login, override_langflow):
- override_langflow(status=0)
- await login()
- response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
- assert response.status_code == 503
+ target = LANGFLOW_ID if fake_status == 404 else str(uuid4())
+ response = await client.post("/api/flows/import", json={"langflow_flow_id": target})
+ assert response.status_code == expected_status
async def test_list_langflow_flows(client, login, override_langflow):
diff --git a/backend/tests/test_flows_api.py b/backend/tests/test_flows_api.py
index cc29aff..852e5f2 100644
--- a/backend/tests/test_flows_api.py
+++ b/backend/tests/test_flows_api.py
@@ -1,17 +1,20 @@
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
import json
-from pathlib import Path
from uuid import UUID, uuid4
+import pytest
+
from app.flows.sync import lfx_id_for
-FIXTURES = Path(__file__).parent / "fixtures" / "flows"
+from .conftest import load_flow_fixture
+
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
- return json.loads((FIXTURES / name).read_text())
+ """Alias of the shared conftest helper (kept for call-site stability)."""
+ return load_flow_fixture(name)
async def _create_flow(client, name="regeltest"):
@@ -55,59 +58,57 @@ async def test_flow_crud(client, login):
assert response.status_code == 404
-async def test_management_endpoints_require_admin(client, login):
- """Anonymous -> 401, member -> 403, admin -> through (A)."""
+MANAGEMENT_PROBES = [
+ ("POST", "/api/flows", {"name": "x"}),
+ ("POST", "/api/flows/sync", None),
+ ("POST", "/api/flows/{flow_id}/versions", {"langflow_json": {}}),
+ ("GET", "/api/flows/{flow_id}/versions", None),
+ ("POST", "/api/flows/{flow_id}/versions/1/deploy", None),
+ ("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
+ ("GET", "/api/flows/langflow/available", None),
+]
+
+
+@pytest.mark.parametrize(("method", "path", "body"), MANAGEMENT_PROBES)
+async def test_management_endpoints_require_admin(client, login, method, path, body):
+ """Anonymous -> 401, member -> 403 (parametrized per endpoint)."""
flow_id = uuid4()
- probes = [
- ("POST", "/api/flows", {"name": "x"}),
- ("POST", "/api/flows/sync", None),
- ("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
- ("GET", f"/api/flows/{flow_id}/versions", None),
- ("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
- ("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
- ("GET", "/api/flows/langflow/available", None),
- ]
- for method, path, body in probes:
- if method == "POST":
- response = await client.post(path, json=body or {})
- else:
- response = await client.get(path)
- assert response.status_code == 401, (method, path, response.text)
+ target = path.format(flow_id=flow_id)
+ if method == "POST":
+ response = await client.post(target, json=body or {})
+ else:
+ response = await client.get(target)
+ assert response.status_code == 401, (method, target, response.text)
await login(email="member@example.com", role="member")
- for method, path, body in probes:
- if method == "POST":
- response = await client.post(path, json=body or {})
- else:
- response = await client.get(path)
- assert response.status_code == 403, (method, path, response.text)
+ if method == "POST":
+ response = await client.post(target, json=body or {})
+ else:
+ response = await client.get(target)
+ assert response.status_code == 403, (method, target, response.text)
-async def test_runs_and_reads_require_login(client, login):
- """Run, run reads and approval reads are login-gated (A)."""
+LOGIN_GATED_PATHS = [
+ ("POST", "/api/flows/{flow_id}/run", {"input": {}}),
+ ("GET", "/api/flows/{flow_id}/runs", None),
+ ("GET", "/api/flows/runs/{run_id}", None),
+ ("GET", "/api/approvals", None),
+ ("POST", "/api/approvals/{task_id}/decision", {"decision": "approve"}),
+]
+
+
+@pytest.mark.parametrize(("method", "path", "body"), LOGIN_GATED_PATHS)
+async def test_runs_and_reads_require_login(client, login, anon_client, method, path, body):
+ """Run, run reads and approval reads are login-gated (parametrized)."""
await login()
flow = await _create_flow(client, "gated")
- run_id = uuid4()
- authed_paths = [
- ("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
- ("GET", f"/api/flows/{flow['id']}/runs", None),
- ("GET", f"/api/flows/runs/{run_id}", None),
- ("GET", "/api/approvals", None),
- ("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
- ]
- # Fresh client without a session cookie.
- from httpx import ASGITransport, AsyncClient
-
- from app.main import app
-
- transport = ASGITransport(app=app)
- async with AsyncClient(transport=transport, base_url="http://test") as anon:
- for method, path, body in authed_paths:
- if method == "POST":
- response = await anon.post(path, json=body or {})
- else:
- response = await anon.get(path)
- assert response.status_code == 401, (method, path, response.text)
+ target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
+ async for anon in anon_client():
+ if method == "POST":
+ response = await anon.post(target, json=body or {})
+ else:
+ response = await anon.get(target)
+ assert response.status_code == 401, (method, target, response.text)
async def test_version_validation_and_numbering(client, login):
@@ -211,12 +212,13 @@ async def test_run_stream_events(client, lfx_server, login):
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"
+ by_type = {}
+ for event, data in events:
+ by_type.setdefault(event, []).append(json.loads(data))
+ assert "run_id" in by_type["run"][0]
+ finished = [p for p in by_type.get("run_finished", []) if "status" in p]
+ assert finished and finished[0]["status"] == "completed"
+ first_payload = by_type["run"][0]
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
assert response.status_code == 200
diff --git a/backend/tests/test_form_components.py b/backend/tests/test_form_components.py
index d1f9516..cb59ce4 100644
--- a/backend/tests/test_form_components.py
+++ b/backend/tests/test_form_components.py
@@ -293,11 +293,54 @@ def test_embedded_code_matches_template_fields():
field["name"] for field in template.values() if isinstance(field, dict)
} - {"code"}
code = template["code"]["value"]
+ # Codegen-inlined core: self-contained for LFX, single truth in repo.
+ assert "from kirche" not in code, filename
+ assert "from ._" not in code, filename
+ assert "collect_form_data" in code, filename
+ assert "parse_envelope" in code, filename
cls = validate_mod.create_class(code, class_name)
code_names = {inp.name for inp in cls.inputs}
assert code_names == template_names, filename
+def test_form_core_envelope_and_conditions():
+ """Unit tests for the shared core (single truth for all 3 forms)."""
+ import sys
+
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "components"))
+ from kirche._form_core import (
+ _field_conditions,
+ collect_form_data,
+ parse_envelope,
+ )
+
+ payload, asked = parse_envelope('{"payload": {"a": 1}, "fields": ["a", ""]}')
+ assert payload == {"a": 1} and asked == ["a"]
+ payload, asked = parse_envelope('{"a": 2}')
+ assert payload == {"a": 2} and asked is None
+ payload, asked = parse_envelope("not json")
+ assert payload == {} and asked is None
+ assert _field_conditions("x [visible_if a=B]", None) == [("a", "B")]
+ assert _field_conditions("x", [{"field": "a", "value": 1}]) == [("a", 1)]
+ assert _field_conditions("x [visible_if a=B]", {"a": 2}) == [("a", 2)]
+ effective, scope = collect_form_data(["a", "b"], {"a": 1, "b": 2}, {"b": 3}, ["a"])
+ assert scope == ["a"] and effective["b"] == 3
+
+
+def test_kirche_node_inlines_core():
+ """Generator composes thin source + core into self-contained code."""
+ import sys
+
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+ from make_flow_fixtures import _compose_component_code, _inline_core
+
+ core = _inline_core()
+ assert "def parse_envelope" in core and "def collect_form_data" in core
+ thin = "from kirche._form_core import parse_envelope\n# <-- _form_core inlined -->\nX = 1\n"
+ composed = _compose_component_code(thin)
+ assert "from kirche" not in composed and "def parse_envelope" in composed
+
+
def test_bauvorhaben_fixture_shape():
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
assert doc["name"] == "bauvorhaben"
@@ -332,8 +375,8 @@ def test_bauvorhaben_schema_branches():
schema = out["json_schema"]
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
assert schema["required"] == ["gebaeude"]
- assert len(schema["allOf"]) == 5
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
+ assert len(schema["allOf"]) == len(by_field) == 5
assert set(by_field) == {
"bestandteil",
"glockenmassnahme",
@@ -355,7 +398,9 @@ def test_bauvorhaben_schema_branches():
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
"bestandteil"
].get("description", "")
- assert out["ui_schema"]["ui:order"] == [
+ order = out["ui_schema"]["ui:order"]
+ assert order[-1] == "*"
+ assert set(order[:-1]) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
@@ -363,8 +408,7 @@ def test_bauvorhaben_schema_branches():
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
- "*",
- ]
+ }
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py
index 1eea92e..20f142b 100644
--- a/backend/tests/test_input_schema.py
+++ b/backend/tests/test_input_schema.py
@@ -4,12 +4,14 @@ import json
from pathlib import Path
from uuid import uuid4
+import pytest
+
from app.flows import input_schema as parser
from app.flows import sync
from app.flows.lfx_client import LfxClient
from app.settings import settings
-FIXTURES = Path(__file__).parent / "fixtures" / "flows"
+from .conftest import FIXTURES_DIR as FIXTURES
def _field(name, itype, **kw):
@@ -48,14 +50,55 @@ def _branch_schema(doc):
return schema
-def test_type_mapping_table():
+@pytest.mark.parametrize(
+ ("itype", "kwargs", "json_type", "widget"),
+ [
+ ("StrInput", {"value": "x"}, "string", None),
+ ("MessageTextInput", {}, "string", None),
+ ("MultilineInput", {}, "string", "textarea"),
+ ("SecretStrInput", {}, "string", "password"),
+ ("IntInput", {"value": 3}, "integer", None),
+ ("FloatInput", {"value": 1.5}, "number", None),
+ ("BoolInput", {"value": True}, "boolean", None),
+ (
+ "DropdownInput",
+ {"options": ["Music", "Youth"], "value": "Youth"},
+ "string",
+ None,
+ ),
+ (
+ "MultiselectInput",
+ {"options": ["Worship", "Courses"], "value": ["Worship"]},
+ "array",
+ None,
+ ),
+ (
+ "SliderInput",
+ {"value": 3, "range_spec": {"min": 1, "max": 5, "step": 1}},
+ "number",
+ "range",
+ ),
+ ("FileInput", {}, "string", None),
+ ("FutureFancyInput", {}, "string", None),
+ ],
+)
+def test_type_mapping_table(itype, kwargs, json_type, widget):
+ doc = _doc(_field("a", itype, **kwargs))
+ schema = parser.extract_form_schema(doc)
+ assert "fallback" not in schema
+ prop = schema["json_schema"]["properties"]["a"]
+ assert prop["type"] == json_type
+ ui = schema["ui_schema"].get("a", {})
+ if widget is None:
+ assert "ui:widget" not in ui
+ else:
+ assert ui["ui:widget"] == widget
+
+
+def test_type_mapping_details():
doc = _doc(
_field("a", "StrInput", value="x"),
- _field("b", "MessageTextInput"),
- _field("c", "MultilineInput"),
- _field("d", "SecretStrInput"),
_field("e", "IntInput", value=3),
- _field("f", "FloatInput", value=1.5),
_field("g", "BoolInput", value=True),
_field(
"h",
@@ -75,16 +118,11 @@ def test_type_mapping_table():
value=3,
range_spec={"min": 1, "max": 5, "step": 1},
),
- _field("k", "FileInput"),
- _field("l", "FutureFancyInput"),
)
schema = parser.extract_form_schema(doc)
- assert "fallback" not in schema
props = schema["json_schema"]["properties"]
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
- assert props["b"]["type"] == "string"
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
- assert props["f"]["type"] == "number"
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
assert props["h"] == {
"title": "H",
@@ -98,12 +136,6 @@ def test_type_mapping_table():
assert props["j"]["minimum"] == 1
assert props["j"]["maximum"] == 5
assert props["j"]["multipleOf"] == 1
- assert props["k"]["type"] == "string"
- assert props["l"]["type"] == "string"
- ui = schema["ui_schema"]
- assert ui["c"] == {"ui:widget": "textarea"}
- assert ui["d"] == {"ui:widget": "password"}
- assert ui["j"] == {"ui:widget": "range"}
def test_dropdown_default_outside_enum_omitted():
@@ -122,7 +154,9 @@ def test_required_advanced_hidden_code():
schema = parser.extract_form_schema(doc)
assert schema["json_schema"]["required"] == ["a"]
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
- assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
+ order = schema["ui_schema"]["ui:order"]
+ assert order[-1] == "*"
+ assert set(order[:-1]) == {"a", "b"}
def test_display_name_match_and_fallback():
@@ -260,7 +294,9 @@ def test_visible_if_single_branch():
assert rule["then"]["required"] == ["bestandteil"]
# Marker stripped from the shown description.
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
- assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
+ order = out["ui_schema"]["ui:order"]
+ assert order[-1] == "*"
+ assert set(order[:-1]) == {"gebaeude", "bestandteil"}
def test_visible_if_nested_three_levels():
diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py
index bc68616..e671edc 100644
--- a/backend/tests/test_middleware.py
+++ b/backend/tests/test_middleware.py
@@ -38,7 +38,8 @@ async def test_oversize_body_rejected(client):
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
from app.settings import settings
- monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
+ limit = 2
+ monkeypatch.setattr(settings, "run_throttle_per_minute", limit)
await login()
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
@@ -65,7 +66,7 @@ async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
},
)
# Undeployed: 422s still pass the throttle (only 429 matters here).
- for _ in range(2):
+ for _ in range(limit):
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 422
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py
index 0f3c650..f86ad00 100644
--- a/backend/tests/test_models.py
+++ b/backend/tests/test_models.py
@@ -8,6 +8,9 @@ from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
def test_flow_definition_defaults(session):
+ from datetime import UTC, datetime
+
+ before = datetime.now(UTC).replace(tzinfo=None)
d = FlowDefinition(name="bauvorhaben")
session.add(d)
session.commit()
@@ -18,7 +21,8 @@ def test_flow_definition_defaults(session):
assert d.created_at is not None
# Timestamps are written as UTC. SQLite does not round-trip tzinfo, so
# after a refresh the value is calendar-time UTC without tzinfo.
- assert d.created_at.year >= 2026
+ assert d.created_at.tzinfo is None
+ assert abs((d.created_at - before).total_seconds()) < 60
def test_flow_definition_name_unique(session):
diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py
index 2eb35b8..b83d37d 100644
--- a/backend/tests/test_sync.py
+++ b/backend/tests/test_sync.py
@@ -14,22 +14,24 @@ from app.flows.schemas import SyncReport
from app.flows.sync import lfx_id_for
from app.settings import settings
-FIXTURES = Path(__file__).parent / "fixtures" / "flows"
-
-
-def load_fixture(name: str) -> dict:
- return json.loads((FIXTURES / name).read_text())
-
-
-def _lfx() -> LfxClient:
- return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
+from .conftest import FIXTURES_DIR, langflow_export_doc, load_flow_fixture
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
- shutil.copy(FIXTURES / name, tmp_path / name)
+ shutil.copy(FIXTURES_DIR / name, tmp_path / name)
return tmp_path
+def load_fixture(name: str) -> dict:
+ """Alias of the shared conftest helper (kept for call-site stability)."""
+ return load_flow_fixture(name)
+
+
+def _lfx() -> LfxClient:
+ """Alias of the shared lfx_client fixture logic (call-site stability)."""
+ return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
+
+
async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
_seed_dir(tmp_path)
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
@@ -145,17 +147,8 @@ async def test_sync_endpoint_returns_report(
def _langflow_doc(name: str, langflow_id: str) -> dict:
- doc = load_fixture("simple_rule.json")
- return {
- "id": langflow_id,
- "name": name,
- "description": "aus Langflow",
- "endpoint_name": None,
- "data": doc["data"],
- "tags": [],
- "webhook": False,
- "is_component": False,
- }
+ """Alias of the shared conftest helper (kept for call-site stability)."""
+ return langflow_export_doc(name, langflow_id)
async def test_sync_endpoint_pulls_from_langflow_then_syncs(
@@ -227,24 +220,26 @@ async def test_sync_endpoint_pull_error_continues(
assert "kaputt-id" in body["errors"]
-async def test_sync_endpoint_langflow_unreachable(
- client, lfx_server, tmp_path, monkeypatch, login, override_langflow
+@pytest.mark.parametrize(
+ ("list_status", "expected_status"),
+ [(0, 503), (500, 502)],
+)
+async def test_sync_endpoint_langflow_failures(
+ client,
+ lfx_server,
+ tmp_path,
+ monkeypatch,
+ login,
+ override_langflow,
+ list_status,
+ expected_status,
):
+ """Listing failures map to 503/502 (parametrized)."""
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
- override_langflow(flows=[], list_status=0)
+ override_langflow(flows=[], list_status=list_status)
response = await client.post("/api/flows/sync")
- assert response.status_code == 503
-
-
-async def test_sync_endpoint_langflow_error(
- client, lfx_server, tmp_path, monkeypatch, login, override_langflow
-):
- await login()
- monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
- override_langflow(flows=[], list_status=500)
- response = await client.post("/api/flows/sync")
- assert response.status_code == 502
+ assert response.status_code == expected_status
async def test_watch_forever_triggers_on_file_change(tmp_path):
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0221c78..95972d6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,20 +9,28 @@
"version": "0.1.0",
"dependencies": {
"@mantine/core": "^9.6.1",
+ "@mantine/dates": "^9.6.1",
+ "@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1",
+ "@mantine/notifications": "^9.6.1",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
+ "@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
+ "@tanstack/react-query": "^5.62.0",
+ "dayjs": "^1.11.13",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-router-dom": "^7.18.3"
+ "react-router-dom": "^7.18.3",
+ "zod": "^3.24.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
+ "sharp": "^0.34.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
@@ -261,6 +269,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -309,6 +326,17 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -804,6 +832,496 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -873,22 +1391,35 @@
}
},
"node_modules/@mantine/dates": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.1.tgz",
- "integrity": "sha512-3NxfqrcwMuYB9wvxIhGhEhhazK3wcA2wG4AuNVFGJDd97ujWWAy+jGHqBF9jOusp/Mk4bn8u+3SuDICtLPDf5w==",
+ "version": "9.6.3",
+ "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.3.tgz",
+ "integrity": "sha512-sPDlPBKl0R8VR9mmTVaNvuvsczD79qMbZOpe+wjkQkrvE102G+iwk2cxW8UIA+o8jqc6jAP4Cf2aoy9EqJ/hiQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
- "@mantine/core": "9.6.1",
- "@mantine/hooks": "9.6.1",
+ "@mantine/core": "9.6.3",
+ "@mantine/hooks": "9.6.3",
"dayjs": ">=1.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
+ "node_modules/@mantine/form": {
+ "version": "9.6.3",
+ "resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.6.3.tgz",
+ "integrity": "sha512-u62dq7iwIPc5hs7hZ00sgAjlhsCgGs88J2NeCfUF7Ap4+65FoR1xi572xiIfLfzugFbDvORKFQir9WGfFJ00ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "fast-deep-equal": "^3.1.3",
+ "klona": "^2.0.6"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
"node_modules/@mantine/hooks": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
@@ -898,6 +1429,31 @@
"react": "^19.2.0"
}
},
+ "node_modules/@mantine/notifications": {
+ "version": "9.6.3",
+ "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.6.3.tgz",
+ "integrity": "sha512-1imtNfBwZ0JBDrg3ohVuZLnJvwRVhb7eWFbcfz5QFSy7hbM/MereURExWUGbZRBiJhQz850Aj6fVSEAviK6vdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@mantine/store": "9.6.3",
+ "react-transition-group": "4.4.5"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.6.3",
+ "@mantine/hooks": "9.6.3",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ }
+ },
+ "node_modules/@mantine/store": {
+ "version": "9.6.3",
+ "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.6.3.tgz",
+ "integrity": "sha512-EdD0QAn5HpsAKbvZwz7cqzq4E+4UpXCWK3hK5SR/O/WFtSAm0tUIyKBcrQ9baPRoQknfhOBmN7A17K+PdOzGqg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -952,13 +1508,12 @@
}
},
"node_modules/@rjsf/utils": {
- "version": "6.10.0",
- "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
- "integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
+ "integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
- "@x0k/json-schema-merge": "^1.0.3",
+ "@x0k/json-schema-merge": "^1.0.6",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1",
@@ -1344,6 +1899,12 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@tabler/icons": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
@@ -1370,6 +1931,32 @@
"react": ">= 16"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.104.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz",
+ "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.104.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.0.tgz",
+ "integrity": "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.104.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1426,14 +2013,13 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1471,11 +2057,10 @@
}
},
"node_modules/@x0k/json-schema-merge": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz",
- "integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==",
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.6.tgz",
+ "integrity": "sha512-SSdWAhirRj9UGTSjmgMnchC3Nytpbde46qPEXVQLm/S3opteW6E/VX4UzLlfmA9L2zdkmG3c69sfLfHbT0jl0w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
}
@@ -1630,7 +2215,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
@@ -1657,12 +2241,32 @@
}
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
@@ -1729,19 +2333,18 @@
"license": "MIT"
},
"node_modules/fast-equals": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz",
- "integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==",
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.4.tgz",
+ "integrity": "sha512-JiBeR0k76FIahi9zAtQR7X1jgZDKLE+fs5S9ryjOGYZWZCOcm8QiwgeahkFvaboS3i6PkGBopRznHKuKhm9zpg==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-uri": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
- "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
+ "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
"funding": [
{
"type": "github",
@@ -1752,8 +2355,7 @@
"url": "https://opencollective.com/fastify"
}
],
- "license": "BSD-3-Clause",
- "peer": true
+ "license": "BSD-3-Clause"
},
"node_modules/fdir": {
"version": "6.5.0",
@@ -1811,7 +2413,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -1851,11 +2452,31 @@
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/klona": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
+ "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1930,6 +2551,15 @@
"node": ">=18"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2008,6 +2638,23 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -2033,8 +2680,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-number-format": {
"version": "5.4.5",
@@ -2163,6 +2809,22 @@
}
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2240,6 +2902,64 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2475,6 +3195,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index a28983a..50da3fe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,20 +12,28 @@
},
"dependencies": {
"@mantine/core": "^9.6.1",
+ "@mantine/dates": "^9.6.1",
+ "@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1",
+ "@mantine/notifications": "^9.6.1",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
+ "@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
+ "@tanstack/react-query": "^5.62.0",
+ "dayjs": "^1.11.13",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-router-dom": "^7.18.3"
+ "react-router-dom": "^7.18.3",
+ "zod": "^3.24.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
+ "sharp": "^0.34.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
diff --git a/frontend/public/photos/SOURCES.md b/frontend/public/photos/SOURCES.md
index d0e9112..e0d9135 100644
--- a/frontend/public/photos/SOURCES.md
+++ b/frontend/public/photos/SOURCES.md
@@ -16,5 +16,4 @@ Dutch-language documents.
| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ |
| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ |
| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ |
-| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ |
| pilot-meeting.webp | Consultation meeting, notes | https://www.pexels.com/photo/two-people-shaking-hands-5668858/ |
diff --git a/frontend/public/photos/team-laptop.webp b/frontend/public/photos/team-laptop.webp
deleted file mode 100644
index 8811110..0000000
Binary files a/frontend/public/photos/team-laptop.webp and /dev/null differ
diff --git a/frontend/scripts/capture.mjs b/frontend/scripts/capture.mjs
index 5f8d856..78b52a8 100644
--- a/frontend/scripts/capture.mjs
+++ b/frontend/scripts/capture.mjs
@@ -50,12 +50,12 @@ try {
const page = await context.newPage();
for (const shot of shots) {
await page.goto(base + shot.path, { waitUntil: "networkidle" });
- await page.waitForTimeout(1500);
+ await page.waitForSelector("main, #root", { timeout: 15000 });
if (shot.selectFlow) {
// Show the generated RJSF form instead of the default first flow.
await page.locator('[role="combobox"]').first().click();
await page.getByRole("option", { name: shot.selectFlow }).click();
- await page.waitForTimeout(2000);
+ await page.waitForSelector("form", { timeout: 15000 });
}
const file = join(outDir, `${shot.name}-${scheme}.png`);
await page.screenshot({ path: file, fullPage: shot.fullPage });
diff --git a/frontend/scripts/fetch-photos.py b/frontend/scripts/fetch-photos.py
index b36dddb..b358b59 100644
--- a/frontend/scripts/fetch-photos.py
+++ b/frontend/scripts/fetch-photos.py
@@ -46,10 +46,6 @@ PHOTOS = {
pexels(18687845),
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
),
- "team-laptop.webp": (
- pexels(7793702),
- "https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/",
- ),
"pilot-meeting.webp": (
pexels(5668858),
"https://www.pexels.com/photo/two-people-shaking-hands-5668858/",
diff --git a/frontend/scripts/icons.mjs b/frontend/scripts/icons.mjs
index 7377455..f281221 100644
--- a/frontend/scripts/icons.mjs
+++ b/frontend/scripts/icons.mjs
@@ -1,22 +1,21 @@
/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable).
*
- * Usage (frontend container, Chromium already installed for screenshots):
+ * Usage (frontend container):
* npm run icons
*
- * Renders the SVG at each target size (crisp native rasterization) into
- * public/. apple-touch-icon keeps the solid kirchenblau background (iOS
- * fills transparency black). The 512 icon doubles as maskable: the glyph
- * stays inside the inner 80% safe zone.
+ * Renders the SVG at each target size with sharp (no browser needed).
+ * apple-touch-icon keeps the solid kirchenblau background (iOS fills
+ * transparency black). The 512 icon doubles as maskable: the glyph stays
+ * inside the inner 80% safe zone.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
-import { chromium } from "playwright";
+import sharp from "sharp";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
-const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8");
-const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`;
+const svg = readFileSync(join(root, "public", "favicon.svg"));
const targets = [
["favicon-32x32.png", 32],
@@ -25,20 +24,11 @@ const targets = [
["icon-512.png", 512],
];
-const browser = await chromium.launch();
-try {
- const page = await browser.newPage();
- for (const [file, size] of targets) {
- await page.setViewportSize({ width: size, height: size });
- await page.setContent(
- ``,
- );
- await page.locator("img").screenshot({
- path: join(root, "public", file),
- omitBackground: true,
- });
- console.log(`wrote public/${file}`);
- }
-} finally {
- await browser.close();
+for (const [file, size] of targets) {
+ await sharp(svg, { density: 300 })
+ .resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
+ .flatten({ background: file === "apple-touch-icon.png" ? "#1d4a79" : "#ffffff" })
+ .png()
+ .toFile(join(root, "public", file));
+ console.log(`wrote public/${file}`);
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b32fcf4..dee3421 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
+import { Notifications } from "@mantine/notifications";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { AuthProvider, useAuth } from "./auth";
import SchemeToggle from "./components/SchemeToggle";
@@ -67,15 +69,17 @@ function Nav() {
);
}
+function Loading() {
+ return (
+
+
+
+ );
+}
+
function RequireAuth({ children }: { children: ReactNode }) {
const { me, loading } = useAuth();
- if (loading) {
- return (
-
-
-
- );
- }
+ if (loading) return ;
// Runs, run history and approvals are login-gated server-side;
// anonymous visitors go to the magic-link login.
if (!me) return ;
@@ -111,9 +115,7 @@ function Shell() {
KI-gestützte Verwaltungsplattform für Kirche
{loading ? (
-