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 8811110..0000000 Binary files a/frontend/public/photos/team-laptop.webp and /dev/null differ diff --git a/frontend/scripts/capture.mjs b/frontend/scripts/capture.mjs index 5f8d856..78b52a8 100644 --- a/frontend/scripts/capture.mjs +++ b/frontend/scripts/capture.mjs @@ -50,12 +50,12 @@ try { const page = await context.newPage(); for (const shot of shots) { await page.goto(base + shot.path, { waitUntil: "networkidle" }); - await page.waitForTimeout(1500); + await page.waitForSelector("main, #root", { timeout: 15000 }); if (shot.selectFlow) { // Show the generated RJSF form instead of the default first flow. await page.locator('[role="combobox"]').first().click(); await page.getByRole("option", { name: shot.selectFlow }).click(); - await page.waitForTimeout(2000); + await page.waitForSelector("form", { timeout: 15000 }); } const file = join(outDir, `${shot.name}-${scheme}.png`); await page.screenshot({ path: file, fullPage: shot.fullPage }); diff --git a/frontend/scripts/fetch-photos.py b/frontend/scripts/fetch-photos.py index b36dddb..b358b59 100644 --- a/frontend/scripts/fetch-photos.py +++ b/frontend/scripts/fetch-photos.py @@ -46,10 +46,6 @@ PHOTOS = { pexels(18687845), "https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/", ), - "team-laptop.webp": ( - pexels(7793702), - "https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/", - ), "pilot-meeting.webp": ( pexels(5668858), "https://www.pexels.com/photo/two-people-shaking-hands-5668858/", diff --git a/frontend/scripts/icons.mjs b/frontend/scripts/icons.mjs index 7377455..f281221 100644 --- a/frontend/scripts/icons.mjs +++ b/frontend/scripts/icons.mjs @@ -1,22 +1,21 @@ /* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable). * - * Usage (frontend container, Chromium already installed for screenshots): + * Usage (frontend container): * npm run icons * - * Renders the SVG at each target size (crisp native rasterization) into - * public/. apple-touch-icon keeps the solid kirchenblau background (iOS - * fills transparency black). The 512 icon doubles as maskable: the glyph - * stays inside the inner 80% safe zone. + * Renders the SVG at each target size with sharp (no browser needed). + * apple-touch-icon keeps the solid kirchenblau background (iOS fills + * transparency black). The 512 icon doubles as maskable: the glyph stays + * inside the inner 80% safe zone. */ import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { chromium } from "playwright"; +import sharp from "sharp"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8"); -const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`; +const svg = readFileSync(join(root, "public", "favicon.svg")); const targets = [ ["favicon-32x32.png", 32], @@ -25,20 +24,11 @@ const targets = [ ["icon-512.png", 512], ]; -const browser = await chromium.launch(); -try { - const page = await browser.newPage(); - for (const [file, size] of targets) { - await page.setViewportSize({ width: size, height: size }); - await page.setContent( - ``, - ); - await page.locator("img").screenshot({ - path: join(root, "public", file), - omitBackground: true, - }); - console.log(`wrote public/${file}`); - } -} finally { - await browser.close(); +for (const [file, size] of targets) { + await sharp(svg, { density: 300 }) + .resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .flatten({ background: file === "apple-touch-icon.png" ? "#1d4a79" : "#ffffff" }) + .png() + .toFile(join(root, "public", file)); + console.log(`wrote public/${file}`); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b32fcf4..dee3421 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ import { Text, Title, } from "@mantine/core"; +import { Notifications } from "@mantine/notifications"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { AuthProvider, useAuth } from "./auth"; import SchemeToggle from "./components/SchemeToggle"; @@ -67,15 +69,17 @@ function Nav() { ); } +function Loading() { + return ( +
+ +
+ ); +} + function RequireAuth({ children }: { children: ReactNode }) { const { me, loading } = useAuth(); - if (loading) { - return ( -
- -
- ); - } + if (loading) return ; // Runs, run history and approvals are login-gated server-side; // anonymous visitors go to the magic-link login. if (!me) return ; @@ -111,9 +115,7 @@ function Shell() { KI-gestützte Verwaltungsplattform für Kirche {loading ? ( -
- -
+ ) : ( } /> @@ -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")} />