From 03af41e8379d1537e6b1cea9a4fd52496b7ce469 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Mon, 28 Sep 2026 18:03:07 +0200 Subject: [PATCH] refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter --- .env.example | 3 + CHANGELOG.md | 11 +- backend/app/auth/api.py | 14 +- backend/app/auth/deps.py | 9 +- backend/app/auth/mail.py | 107 +- backend/app/auth/service.py | 86 +- backend/app/auth/templates/magic_link.html | 42 + backend/app/flows/api.py | 19 +- backend/app/flows/base_client.py | 19 +- backend/app/flows/export.py | 42 +- backend/app/flows/langflow_client.py | 5 +- backend/app/flows/lfx_client.py | 7 +- backend/app/flows/schemas.py | 7 + backend/app/flows/store.py | 22 +- backend/app/flows/sync.py | 5 +- backend/app/main.py | 22 +- backend/app/middleware.py | 92 +- backend/app/models/__init__.py | 3 +- backend/app/models/user.py | 14 +- backend/app/settings.py | 4 +- backend/components/kirche/README.md | 49 +- backend/components/kirche/bauvorhaben_form.py | 139 +- backend/components/kirche/form_inputs.py | 132 +- backend/components/kirche/mitarbeiter_form.py | 139 +- backend/flows/approval_handoff.json | 3356 +++++++++-------- backend/flows/bauvorhaben.json | 2 +- backend/flows/intake_form.json | 1822 ++++----- backend/flows/mitarbeiter_einstellen.json | 2 +- backend/flows/simple_rule.json | 2506 ++++++------ .../c4d8f2a1e6b9_drop_user_sessions.py | 46 + backend/pyproject.toml | 9 + backend/scripts/check_prod.py | 14 +- backend/scripts/deploy_flow.py | 31 +- backend/scripts/make_flow_fixtures.py | 74 +- backend/scripts/smoke_e2e.py | 37 +- backend/scripts/sync_flows.py | 23 +- backend/tests/_harness.py | 39 + backend/tests/conftest.py | 107 +- backend/tests/fixtures/flows/bauvorhaben.json | 2 +- backend/tests/fixtures/flows/intake_form.json | 2 +- .../flows/mitarbeiter_einstellen.json | 2 +- backend/tests/test_approvals.py | 7 +- backend/tests/test_auth.py | 36 +- backend/tests/test_check_prod.py | 48 +- backend/tests/test_deploy.py | 50 +- backend/tests/test_flows_api.py | 112 +- backend/tests/test_form_components.py | 52 +- backend/tests/test_input_schema.py | 74 +- backend/tests/test_middleware.py | 5 +- backend/tests/test_models.py | 6 +- backend/tests/test_sync.py | 65 +- frontend/package-lock.json | 795 +++- frontend/package.json | 10 +- frontend/public/photos/SOURCES.md | 1 - frontend/public/photos/team-laptop.webp | Bin 117574 -> 0 bytes frontend/scripts/capture.mjs | 4 +- frontend/scripts/fetch-photos.py | 4 - frontend/scripts/icons.mjs | 38 +- frontend/src/App.tsx | 37 +- frontend/src/api.ts | 6 + frontend/src/components/AccessQueue.tsx | 94 +- frontend/src/components/AdminTab.tsx | 114 +- frontend/src/components/ApprovalsTab.tsx | 90 +- frontend/src/components/DecisionButtons.tsx | 38 + frontend/src/components/FormattedDate.tsx | 10 + frontend/src/components/JsonView.tsx | 12 + frontend/src/components/Landing.tsx | 71 + frontend/src/components/RunsTable.tsx | 10 +- frontend/src/pages/AccessPage.tsx | 70 +- frontend/src/pages/AdminPage.tsx | 47 +- frontend/src/pages/LoginPage.tsx | 51 +- frontend/src/pages/StartPage.tsx | 93 +- frontend/src/pages/TasksPage.tsx | 56 +- frontend/src/pages/WorkflowsPage.tsx | 100 +- 74 files changed, 6089 insertions(+), 5183 deletions(-) create mode 100644 backend/app/auth/templates/magic_link.html create mode 100644 backend/migrations/versions/c4d8f2a1e6b9_drop_user_sessions.py delete mode 100644 frontend/public/photos/team-laptop.webp create mode 100644 frontend/src/components/DecisionButtons.tsx create mode 100644 frontend/src/components/FormattedDate.tsx create mode 100644 frontend/src/components/JsonView.tsx create mode 100644 frontend/src/components/Landing.tsx diff --git a/.env.example b/.env.example index 40e3dbe..cad60c2 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,9 @@ CALLBACK_BASE_URL=http://api:8000 # Shared secret for flow callbacks (POST /api/approvals carries it as # X-Callback-Token). Long random value; empty disables callbacks. CALLBACK_TOKEN= +# Secret signing stateless session JWTs (HS256). Long random value +# (openssl rand -hex 32); empty fails closed at login time. +JWT_SECRET= # --- frontend mode: unset/preview serves the production bundle --- # Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it. diff --git a/CHANGELOG.md b/CHANGELOG.md index b76708c..1d543bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed -- Declutter: shared `BaseApiClient` (LFX/Langflow), single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`. -- Forms: shared `components/kirche/_form_core.py` single source (codegen-inlined for LFX), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI). -- Tests (full real LFX kept, 146 green): shared `conftest` helpers (`load_flow_fixture`, `flow_helpers`, `lfx_client`, `anon_client`), `tests/_harness.py` shared with smoke script, parametrized `filename_stem`. -- Frontend: shared `formatApiError`, ``, ``, `URLSearchParams` queries, localized run dates. +- Declutter: shared `BaseApiClient` (LFX/Langflow) with tenacity transport retry, single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`, atomicwrites file writes, `secure`-package headers. +- Auth: stateless JWT sessions (HS256, 30d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`. +- Forms: thin variant components over shared `components/kirche/_form_core.py` (codegen-inlined by `kirche_node()` for LFX; all 5 flows regenerated + level-4 validated), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI). +- Store/sync: single `next_version_no` query per version, validation only at the `create_version` choke point, mtime-cached `.flowsignore`. +- Tests (full real LFX kept): shared `conftest` helpers + `tests/_harness.py` adopted by fixtures and smoke script, parametrized matrices (export stems, check_prod env, deploy failures, sync listing failures, API auth probes, input_schema types), relaxed brittle asserts (ui:order sets, per-flag cookie asserts, SSE any-match). +- Frontend: react-query reads + notifications, `useForm`+zod static forms, shared `DecisionButtons`/`ErrorAlert`/`JsonView`/`FormattedDate`/`RunsTable`/`Landing` blocks, new aggregate `GET /api/runs` kills the Tasks N+1, `Promise.all` version fetches, `sharp` icons, selector-based screenshots, dead photo removed. ### Added +- Aggregate `GET /api/runs` (tenant-scoped runs with flow names, single query). - Branched questionnaires: `[visible_if feld=Wert]` markers in a `CustomInputForm` field's `info` (code editor) show the field only when the condition holds — nested to any depth, with `&` for AND conditions diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py index 5c20ff6..81b0184 100644 --- a/backend/app/auth/api.py +++ b/backend/app/auth/api.py @@ -126,17 +126,14 @@ def consume_magic( session: Annotated[Session, Depends(get_db)], ): try: - user_session = service.consume_magic_token(session, data.token) + user, session_token = service.consume_magic_token(session, data.token) except ( service.InvalidTokenError, service.ExpiredTokenError, service.UsedTokenError, ) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - user = service.get_user(session, user_session.user_id) - if user is None: # pragma: no cover - consume already validated the user - raise HTTPException(status_code=400, detail="unknown token") - _set_session_cookie(response, user_session.id) + _set_session_cookie(response, session_token) return _read_me(user) @@ -151,11 +148,8 @@ def logout( response: Response, session: Annotated[Session, Depends(get_db)], ): - # Revoke the server-side row when the cookie maps to a live session; - # always clear the client cookie. - session_id = request.cookies.get(SESSION_COOKIE) - if session_id: - service.revoke_session(session, session_id) + # Stateless sessions: nothing server-side to revoke — always clear + # the client cookie. response.delete_cookie(SESSION_COOKIE, path="/") return MessageRead(message="logged out") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index 2fc921c..88747f3 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -19,13 +19,10 @@ SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds()) def get_optional_user( request: Request, session: Annotated[Session, Depends(get_db)] ) -> User | None: - session_id = request.cookies.get(SESSION_COOKIE) - if not session_id: + token = request.cookies.get(SESSION_COOKIE) + if not token: return None - record = service.get_valid_session(session, session_id) - if record is None: - return None - return service.get_user(session, record.user_id) + return service.get_token_user(session, token) def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User: diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py index b50e3a8..177f833 100644 --- a/backend/app/auth/mail.py +++ b/backend/app/auth/mail.py @@ -9,8 +9,10 @@ fonts, no external images (many clients block them / track opens). from __future__ import annotations import logging -import smtplib from email.message import EmailMessage +from pathlib import Path + +from jinja2 import Template from app.settings import settings @@ -22,67 +24,62 @@ BLUE_LIGHT = "#eef4fb" INK = "#162f4c" MUTED = "#5b6b7c" +_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "magic_link.html" +_template_cache: Template | None = None + + +def _template() -> Template: + global _template_cache + if _template_cache is None: + _template_cache = Template(_TEMPLATE_PATH.read_text(encoding="utf-8")) + return _template_cache + def magic_link(token: str) -> str: return f"{settings.frontend_url.rstrip('/')}/login?token={token}" def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str: - """Branded HTML mail body (table layout, inline styles, no images).""" - button = "" - if button_url: - button = ( - '' - f'
' - f'{button_label}
' - ) - return f""" - - - - -
- - - - - - - -
- Kirche Einfach -
-

{title}

-

{intro}

- {button} -
-

- {note} -

-
- -""" + """Branded HTML mail body (jinja template, byte-identical to the old f-string).""" + return _template().render( + title=title, + intro=intro, + button_label=button_label, + button_url=button_url, + note=note, + blue=BLUE, + blue_light=BLUE_LIGHT, + ink=INK, + muted=MUTED, + ) def _send(message: EmailMessage) -> None: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: - smtp.starttls() - if settings.smtp_username: - smtp.login(settings.smtp_username, settings.smtp_password) - smtp.send_message(message) + """Deliver via fastapi-mail (same never-raise contract at call sites).""" + import asyncio + + from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType + + config = ConnectionConfig( + MAIL_USERNAME=settings.smtp_username, + MAIL_PASSWORD=settings.smtp_password, + MAIL_FROM=settings.mail_from or settings.contact_to or message["To"], + MAIL_PORT=settings.smtp_port, + MAIL_SERVER=settings.smtp_host, + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + USE_CREDENTIALS=bool(settings.smtp_username), + ) + html = message.get_body(preferencelist=("html",)) + plain = message.get_body(preferencelist=("plain",)) + outgoing = MessageSchema( + subject=str(message["Subject"]), + recipients=[str(message["To"])], + body=plain.get_content() if plain else "", + html=html.get_content() if html else "", + subtype=MessageType.html, + ) + asyncio.run(FastMail(config).send_message(outgoing)) def _branded_mail( @@ -142,7 +139,7 @@ def send_magic_link(to_email: str, link: str) -> bool: ) try: _send(message) - except (OSError, smtplib.SMTPException): + except Exception: # noqa: BLE001 -- never-raise contract: delivery must not 500 logger.warning("magic-link delivery failed for %s", to_email) return False logger.info("magic link mailed to %s", to_email) @@ -167,7 +164,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool: ) try: _send(message) - except (OSError, smtplib.SMTPException): + except Exception: # noqa: BLE001 -- best-effort notification, never raises logger.warning("access-request notification failed for %s", request_id) return False return True diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 3a501d1..e8a0f71 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,7 +1,12 @@ -"""Access requests, magic-link tokens, and sessions (DB via function args). +"""Access requests, magic-link tokens, and stateless JWT sessions. -Keep API handlers thin: all persistence lives here. Tokens and session ids -are bearer secrets — log user/request ids only, never secret values. +Keep API handlers thin: all persistence lives here. Tokens and session +JWTs are bearer secrets — log user/request ids only, never secret values. + +Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id, +iat, exp=30d, jti) in the HttpOnly session cookie. No session table: +logout clears the cookie; role/tenant changes apply at next login (or +immediately for deleted users — the user row is reloaded per request). """ from __future__ import annotations @@ -9,12 +14,14 @@ from __future__ import annotations import hashlib import logging import secrets +import uuid from datetime import UTC, datetime, timedelta from uuid import UUID +import jwt from sqlmodel import Session, func, select -from app.models import AccessRequest, MagicLinkToken, User, UserSession +from app.models import AccessRequest, MagicLinkToken, User from app.models.base import utcnow from app.settings import settings @@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime: return value -def consume_magic_token(session: Session, token: str) -> UserSession: - """Single-use consume → session row. used_at is set before creation.""" +def _jwt_secret() -> str: + secret = settings.jwt_secret + if not secret: + raise RuntimeError("JWT_SECRET is empty — set it in .env") + return secret + + +def mint_session_token(user: User) -> str: + """Issue a stateless session JWT for a user (30-day expiry).""" + now = utcnow() + return jwt.encode( + { + "sub": str(user.id), + "email": user.email, + "role": user.role, + "tenant_id": user.tenant_id, + "iat": int(now.timestamp()), + "exp": int((now + SESSION_TTL).timestamp()), + "jti": uuid.uuid4().hex, + }, + _jwt_secret(), + algorithm="HS256", + ) + + +def decode_session_token(token: str) -> dict: + """Verify a session JWT → claims. Expired/forged → token errors.""" + try: + return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"]) + except jwt.ExpiredSignatureError as exc: + raise ExpiredTokenError("session expired") from exc + except jwt.InvalidTokenError as exc: + raise InvalidTokenError("invalid session") from exc + + +def consume_magic_token(session: Session, token: str) -> tuple[User, str]: + """Single-use consume → (user, session JWT). used_at is set first.""" record = session.exec( select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip())) ).first() @@ -252,33 +294,23 @@ def consume_magic_token(session: Session, token: str) -> UserSession: # Mark used first: a reused token can never mint a second session. record.used_at = utcnow() session.add(record) - user_session = UserSession( - id=secrets.token_urlsafe(32), - user_id=user.id, - expires_at=utcnow() + SESSION_TTL, - ) - session.add(user_session) session.commit() - session.refresh(user_session) + session.refresh(user) logger.info("magic token consumed for user %s", user.id) - return user_session + return user, mint_session_token(user) -def get_valid_session(session: Session, session_id: str) -> UserSession | None: - record = session.get(UserSession, session_id) - if record is None or record.revoked_at is not None: +def get_token_user(session: Session, token: str) -> User | None: + """User for a session JWT, or None (expired/forged/deleted user).""" + try: + claims = decode_session_token(token) + except (InvalidTokenError, ExpiredTokenError): return None - if _aware(record.expires_at) <= utcnow(): + try: + user_id = UUID(str(claims.get("sub", ""))) + except ValueError: return None - return record - - -def revoke_session(session: Session, session_id: str) -> None: - record = session.get(UserSession, session_id) - if record is not None and record.revoked_at is None: - record.revoked_at = utcnow() - session.add(record) - session.commit() + return session.get(User, user_id) def bootstrap_admin(session: Session, email: str) -> tuple[User, str]: diff --git a/backend/app/auth/templates/magic_link.html b/backend/app/auth/templates/magic_link.html new file mode 100644 index 0000000..de39814 --- /dev/null +++ b/backend/app/auth/templates/magic_link.html @@ -0,0 +1,42 @@ + + + + + +
+ + + + + + + +
+ Kirche Einfach +
+

{{ title }}

+

{{ intro }}

+ {% if button_url %} + + +
+ {{ button_label }} +
+ {% endif %} +
+

+ {{ note }} +

+
+ + 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 88111108a309df4a048481c1757c2608372c9d4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 117574 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

^sKEJ*m91p~njO*$oD=EkE=Gf4$6&<{ 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 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 ? ( -
- -
+ ) : ( } /> @@ -143,7 +145,7 @@ function Shell() { } /> - } /> + } /> )} @@ -152,10 +154,17 @@ function Shell() { ); } +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 15_000, retry: 1 } }, +}); + export default function App() { return ( - - - + + + + + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6b2228b..eb0159d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -61,6 +61,11 @@ export interface Decision { continuation_run: Run | null; } +export interface NamedRun { + flow_name: string; + run: Run; +} + export interface SyncReport { added: string[]; updated: string[]; @@ -153,6 +158,7 @@ export const api = { getInputSchema: (flowId: string) => req(`/api/flows/${flowId}/input-schema`), listRuns: (flowId: string) => req(`/api/flows/${flowId}/runs`), + listAllRuns: () => req("/api/runs"), listApprovals: () => req("/api/approvals"), decide: (taskId: string, decision: "approve" | "reject", note: string) => req(`/api/approvals/${taskId}/decision`, { diff --git a/frontend/src/components/AccessQueue.tsx b/frontend/src/components/AccessQueue.tsx index 71daf2e..f7cbecf 100644 --- a/frontend/src/components/AccessQueue.tsx +++ b/frontend/src/components/AccessQueue.tsx @@ -1,52 +1,55 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { - Alert, - Button, Card, Group, Stack, Text, Title, } from "@mantine/core"; -import { - IconAlertCircle, - IconCheck, -} from "@tabler/icons-react"; -import { ApiError, api, type AccessRequest } from "../api"; +import { notifications } from "@mantine/notifications"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, type AccessRequest } from "../api"; +import { formatApiError } from "../utils"; +import { DecisionButtons } from "./DecisionButtons"; +import { ErrorAlert } from "./ErrorAlert"; +import { FormattedDate } from "./FormattedDate"; import StatusBadge from "./StatusBadge"; export default function AccessQueue() { - const [requests, setRequests] = useState([]); - const [error, setError] = useState(""); - const [info, setInfo] = useState(""); + const queryClient = useQueryClient(); const [busyId, setBusyId] = useState(""); + const [error, setError] = useState(""); - const load = useCallback(async () => { - try { - setRequests(await api.listAccessRequests()); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - }, []); - + const requestsQuery = useQuery({ + queryKey: ["access-requests"], + queryFn: () => api.listAccessRequests(), + }); + const requests: AccessRequest[] = requestsQuery.data ?? []; useEffect(() => { - void load(); - }, [load]); + if (requestsQuery.error) setError(formatApiError(requestsQuery.error)); + }, [requestsQuery.error]); async function decide(id: string, approve: boolean) { setBusyId(id); setError(""); - setInfo(""); try { const res = await api.decideAccess(id, approve); - setRequests((prev) => - prev.map((r) => (r.id === id ? res.request : r)), - ); if (approve && !res.email_sent) { - setInfo("Genehmigt — die E-Mail kam nicht an (SMTP prüfen)."); + notifications.show({ + title: "Genehmigt", + message: "Die E-Mail kam nicht an (SMTP prüfen).", + color: "yellow", + }); + } else { + notifications.show({ + title: approve ? "Genehmigt" : "Abgelehnt", + message: approve ? "Zugang freigegeben." : "Anfrage abgelehnt.", + color: approve ? "green" : "red", + }); } + await queryClient.invalidateQueries({ queryKey: ["access-requests"] }); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } finally { setBusyId(""); } @@ -60,16 +63,7 @@ export default function AccessQueue() { Zugangsanfragen - {error && ( - } mb="md"> - {error} - - )} - {info && ( - } mb="md"> - {info} - - )} + {pending.length === 0 && ( Keine offenen Anfragen. @@ -90,23 +84,13 @@ export default function AccessQueue() { {r.reason} )} - - - - + void decide(r.id, true)} + onReject={() => void decide(r.id, false)} + approveLabel="Genehmigen" + rejectLabel="Ablehnen" + /> ))} @@ -119,7 +103,7 @@ export default function AccessQueue() { - {r.name} · {r.email} + {r.name} · {r.email} · ))} diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx index da08d8a..0237f0a 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -1,6 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { - Alert, Anchor, Button, Card, @@ -10,83 +9,111 @@ import { TextInput, Title, } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; import { - IconAlertCircle, - IconCheck, IconExternalLink, IconRefresh, IconUpload, } from "@tabler/icons-react"; +import { z } from "zod"; import { - ApiError, LANGFLOW_URL, api, type FlowDefinition, type FlowVersion, } from "../api"; +import { formatApiError } from "../utils"; import FlowHelp from "./FlowHelp"; +import { ErrorAlert } from "./ErrorAlert"; import StatusBadge from "./StatusBadge"; +const importSchema = z.object({ + langflowId: z.string().min(1, "Flow-ID erforderlich"), + importName: z.string(), +}); + export default function AdminTab() { const [flows, setFlows] = useState([]); const [versions, setVersions] = useState>({}); - const [langflowId, setLangflowId] = useState(""); - const [importName, setImportName] = useState(""); + const importForm = useForm({ + initialValues: { langflowId: "", importName: "" }, + validate: (values) => { + const parsed = importSchema.safeParse(values); + if (parsed.success) return {}; + return { + langflowId: + parsed.error.flatten().fieldErrors.langflowId?.[0] ?? null, + }; + }, + }); const [error, setError] = useState(""); - const [info, setInfo] = useState(""); - const load = useCallback(async () => { + function notifyInfo(message: string) { + notifications.show({ message, color: "green" }); + } + + useEffect(() => { + void (async () => { + try { + const list = await api.listFlows(); + setFlows(list); + const entries = await Promise.all( + list.map(async (f) => [f.id, await api.listVersions(f.id)] as const), + ); + setVersions(Object.fromEntries(entries)); + } catch (e) { + setError(formatApiError(e)); + } + })(); + }, []); + + async function load() { try { const list = await api.listFlows(); setFlows(list); - const all: Record = {}; - for (const f of list) { - all[f.id] = await api.listVersions(f.id); - } - setVersions(all); + const entries = await Promise.all( + list.map(async (f) => [f.id, await api.listVersions(f.id)] as const), + ); + setVersions(Object.fromEntries(entries)); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } - }, []); - - useEffect(() => { - void load(); - }, [load]); + } async function onImport() { setError(""); - setInfo(""); + const validation = importForm.validate(); + if (validation.hasErrors) return; + const { langflowId, importName } = importForm.values; try { const result = await api.importFlow( langflowId, importName.trim() ? importName.trim() : undefined, ); - setInfo( + notifyInfo( `Importiert: ${result.definition.name} (Version ${result.version.version_no})`, ); - setLangflowId(""); - setImportName(""); + importForm.reset(); await load(); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } } async function onDeploy(flowId: string, versionNo: number) { setError(""); - setInfo(""); try { const version = await api.deployVersion(flowId, versionNo); - setInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`); + notifyInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`); await load(); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } } async function onSync() { setError(""); - setInfo(""); try { const r = await api.syncFlows(); const parts = [ @@ -98,10 +125,10 @@ export default function AdminTab() { const errs = Object.entries(r.errors) .map(([f, e]) => `${f}: ${e}`) .join("; "); - setInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`); + notifyInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`); await load(); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } } @@ -110,16 +137,7 @@ export default function AdminTab() { Flows & Versionen - {error && ( - } mb="md"> - {error} - - )} - {info && ( - } mb="md"> - {info} - - )} + Flows werden visuell in Langflow entworfen:{" "} @@ -130,20 +148,18 @@ export default function AdminTab() { setLangflowId(e.target.value)} style={{ flex: 1 }} styles={{ input: { fontFamily: "monospace" } }} + {...importForm.getInputProps("langflowId")} /> setImportName(e.target.value)} + {...importForm.getInputProps("importName")} /> @@ -200,9 +216,9 @@ export default function AdminTab() { ); } - const live = [...list] - .reverse() - .find((v) => v.deployed_at && v.id !== latest.id); + const live = list + .filter((v) => v.deployed_at && v.id !== latest.id) + .at(-1); return ( diff --git a/frontend/src/components/ApprovalsTab.tsx b/frontend/src/components/ApprovalsTab.tsx index 0412f32..8bcbeaf 100644 --- a/frontend/src/components/ApprovalsTab.tsx +++ b/frontend/src/components/ApprovalsTab.tsx @@ -1,49 +1,55 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { - Alert, Button, Card, - Code, Group, Stack, Text, TextInput, Title, } from "@mantine/core"; -import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react"; -import { ApiError, api, type ApprovalTask } from "../api"; +import { notifications } from "@mantine/notifications"; +import { IconRefresh } from "@tabler/icons-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, type ApprovalTask } from "../api"; import { useAuth } from "../auth"; +import { formatApiError } from "../utils"; +import { DecisionButtons } from "./DecisionButtons"; +import { ErrorAlert } from "./ErrorAlert"; +import { JsonView } from "./JsonView"; +import { FormattedDate } from "./FormattedDate"; import StatusBadge from "./StatusBadge"; export default function ApprovalsTab() { const { me } = useAuth(); - const [tasks, setTasks] = useState([]); + const queryClient = useQueryClient(); const [note, setNote] = useState(""); const [error, setError] = useState(""); const [busyId, setBusyId] = useState(""); - const load = useCallback(async () => { - try { - setTasks(await api.listApprovals()); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - }, []); - + const tasksQuery = useQuery({ + queryKey: ["approvals"], + queryFn: api.listApprovals, + }); + const tasks: ApprovalTask[] = tasksQuery.data ?? []; useEffect(() => { - void load(); - }, [load]); + if (tasksQuery.error) setError(formatApiError(tasksQuery.error)); + }, [tasksQuery.error]); async function decide(taskId: string, decision: "approve" | "reject") { setBusyId(taskId); setError(""); try { - const result = await api.decide(taskId, decision, note); - setTasks((prev) => - prev.map((t) => (t.id === taskId ? result.task : t)), - ); + await api.decide(taskId, decision, note); + notifications.show({ + title: decision === "approve" ? "Freigegeben" : "Abgelehnt", + color: decision === "approve" ? "green" : "red", + message: note || undefined, + }); + await queryClient.invalidateQueries({ queryKey: ["approvals"] }); + await queryClient.invalidateQueries({ queryKey: ["all-runs"] }); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } finally { setBusyId(""); } @@ -57,11 +63,7 @@ export default function ApprovalsTab() { Freigaben - {error && ( - } mb="md"> - {error} - - )} + Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem Konto zugeordnet. @@ -76,7 +78,10 @@ export default function ApprovalsTab() { @@ -99,27 +104,15 @@ export default function ApprovalsTab() { {t.tenant_id} - - {JSON.stringify(t.payload)} - + - - + void decide(t.id, "approve")} + onReject={() => void decide(t.id, "reject")} + approveLabel="Freigeben" + rejectLabel="Ablehnen" + /> ))} @@ -132,7 +125,8 @@ export default function ApprovalsTab() { {t.title || t.id} · {t.approver ?? "–"} - {t.decision_note ? ` · „${t.decision_note}“` : ""} + {t.decision_note ? ` · „${t.decision_note}“` : ""} ·{" "} + ))} diff --git a/frontend/src/components/DecisionButtons.tsx b/frontend/src/components/DecisionButtons.tsx new file mode 100644 index 0000000..8e1e513 --- /dev/null +++ b/frontend/src/components/DecisionButtons.tsx @@ -0,0 +1,38 @@ +import { Button, Group } from "@mantine/core"; +import { IconCheck, IconX } from "@tabler/icons-react"; + +export function DecisionButtons({ + busy, + onApprove, + onReject, + approveLabel, + rejectLabel, +}: { + busy: boolean; + onApprove: () => void; + onReject: () => void; + approveLabel: string; + rejectLabel: string; +}) { + return ( + + + + + ); +} diff --git a/frontend/src/components/FormattedDate.tsx b/frontend/src/components/FormattedDate.tsx new file mode 100644 index 0000000..f4aded5 --- /dev/null +++ b/frontend/src/components/FormattedDate.tsx @@ -0,0 +1,10 @@ +import dayjs from "dayjs"; +import "dayjs/locale/de"; + +dayjs.locale("de"); + +/** Localized date (was toLocaleString in one place, missing elsewhere). */ +export function FormattedDate({ iso }: { iso: string | null }) { + if (!iso) return <>–; + return <>{dayjs(iso).format("DD.MM.YYYY HH:mm")}; +} diff --git a/frontend/src/components/JsonView.tsx b/frontend/src/components/JsonView.tsx new file mode 100644 index 0000000..9a4a141 --- /dev/null +++ b/frontend/src/components/JsonView.tsx @@ -0,0 +1,12 @@ +import { Code } from "@mantine/core"; + +const MAX_LEN = 2000; + +/** Pretty JSON view (was raw JSON.stringify / [object Object] in 3 places). */ +export function JsonView({ value }: { value: unknown }) { + if (value === null || value === undefined) return <>–; + const text = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + const shown = text.length > MAX_LEN ? text.slice(0, MAX_LEN) + "…" : text; + return {shown}; +} diff --git a/frontend/src/components/Landing.tsx b/frontend/src/components/Landing.tsx new file mode 100644 index 0000000..c9fc46b --- /dev/null +++ b/frontend/src/components/Landing.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { Card, Image, Text, ThemeIcon, Title } from "@mantine/core"; + +export const PHOTO_CLASS = "photo"; + +/** Shared landing building blocks (was copy-pasted Card/Icon/Title/Text x10). */ +export function SectionTitle({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function SectionCard({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function FeatureCard({ + icon, + title, + text, + iconSize = 40, +}: { + icon: ReactNode; + title: string; + text: string; + iconSize?: number; +}) { + return ( + + + {icon} + + + {title} + + + {text} + + + ); +} + +export function PhotoImage({ + src, + alt, + h, + eager = false, +}: { + src: string; + alt: string; + h?: number; + eager?: boolean; +}) { + return ( + {alt} + ); +} diff --git a/frontend/src/components/RunsTable.tsx b/frontend/src/components/RunsTable.tsx index db157b0..c17e490 100644 --- a/frontend/src/components/RunsTable.tsx +++ b/frontend/src/components/RunsTable.tsx @@ -1,5 +1,7 @@ -import { Code, Table } from "@mantine/core"; +import { Table } from "@mantine/core"; import StatusBadge from "./StatusBadge"; +import { FormattedDate } from "./FormattedDate"; +import { JsonView } from "./JsonView"; import type { Run } from "../api"; export function RunsTable({ runs }: { runs: Run[] }) { @@ -19,9 +21,11 @@ export function RunsTable({ runs }: { runs: Run[] }) { - {r.output?.result ?? r.output?.error ?? "–"} + + + + - {new Date(r.created_at).toLocaleString("de-DE")} ))} diff --git a/frontend/src/pages/AccessPage.tsx b/frontend/src/pages/AccessPage.tsx index 23fbaf9..4f2a338 100644 --- a/frontend/src/pages/AccessPage.tsx +++ b/frontend/src/pages/AccessPage.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { useSearchParams } from "react-router-dom"; import { - Alert, Button, Card, Stack, @@ -10,31 +9,53 @@ import { TextInput, Title, } from "@mantine/core"; -import { IconAlertCircle, IconCheck } from "@tabler/icons-react"; +import { useForm } from "@mantine/form"; +import { z } from "zod"; import { ApiError, api } from "../api"; +import { formatApiError } from "../utils"; +import { ErrorAlert } from "../components/ErrorAlert"; + +const accessSchema = z.object({ + name: z.string().min(1, "Name erforderlich").max(200), + email: z.string().email("Gültige E-Mail erforderlich").max(320), + reason: z.string().max(2000), +}); export default function AccessPage() { const [params] = useSearchParams(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(params.get("email") ?? ""); - const [reason, setReason] = useState(""); + const form = useForm({ + initialValues: { + name: "", + email: params.get("email") ?? "", + reason: "", + }, + validate: (values) => { + const parsed = accessSchema.safeParse(values); + if (parsed.success) return {}; + const flat = parsed.error.flatten().fieldErrors; + return { + name: flat.name?.[0] ?? null, + email: flat.email?.[0] ?? null, + reason: flat.reason?.[0] ?? null, + }; + }, + }); const [done, setDone] = useState(false); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); + async function onSubmit(values: typeof form.values) { setBusy(true); setError(""); try { - await api.requestAccess(name.trim(), email.trim(), reason.trim()); + await api.requestAccess(values.name.trim(), values.email.trim(), values.reason.trim()); setDone(true); } catch (err) { setError( err instanceof ApiError ? err.status === 429 ? "Zu viele Anfragen von dieser Adresse — bitte später erneut versuchen." - : `${err.status}: ${err.message}` + : formatApiError(err) : String(err), ); } finally { @@ -48,38 +69,25 @@ export default function AccessPage() { Zugang anfragen {done ? ( - } title="Angefragt"> - Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, sobald - sie freigegeben wurde. - + + Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, + sobald sie freigegeben wurde. + ) : ( -
void onSubmit(e)}> + void onSubmit(v))}> - {error && ( - }> - {error} - - )} - setName(e.target.value)} - required - maxLength={200} - /> + + setEmail(e.target.value)} required - maxLength={320} + {...form.getInputProps("email")} />