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 2ace98a..33b5049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- 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, 7d, 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`. +- Fix batch: typer CLIs exit non-zero via `typer.Exit` (`check_prod` gate works again); JWT expiry 7d with startup warning and new expiry/deleted-user tests; deleted dead `resolve_tenant`, test alias helpers now delegate to `conftest` (flows/approvals/sync/deploy suites migrated to shared fixtures); `AdminTab` reads via react-query; Mantine aligned to 9.6.3 (consistent lockfile, `npm ci` green). +- Removed `backend/flows/test_flow.json` (legacy Message-shape demo, superseded by `intake_form.json`; Langflow copy deleted first, sync cascades runs by design); `FlowVersion.input_schema` is now NOT NULL (backfill migration); `smoke_e2e.py` reuses `tests/_harness.py` and runs green; `FlowHelp` docs moved to `docs/flow-authoring.md`; shared `StatusBadge` status union, single `RunsTable`, `photos.css` class via one constant. +- 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 + and quotes for values with spaces. The parser emits `allOf`/`if`/`then` + branches (branch-scoped `required`, template-order placement); unknown + triggers, cycles, and unsupported trigger types fail open (always + visible). New demo flow `bauvorhaben.json` (4 levels + sibling branch + + bool trigger). Values of hidden fields are kept and submitted. +- Run envelope carries the asked field names (`fields`, from the + version's form schema): `collect()` emits defaults only for asked + AND currently visible fields (hidden branch fields stay out, like the + frontend never submits them), so templates may declare fields unknown + to the component code and unasked code defaults stay out of the run + output. Without the list (raw dicts, Langflow playground) all visible + declared defaults apply. +- Standalone `CustomInputBauvorhaben` component ("Bauvorhaben Form" in + the kirche palette, zero shared code with `CustomInputForm` — the LFX + runtime executes embedded module source without our package); the + bauvorhaben flow uses it, and Langflow no longer normalizes the node + back to the default form on import. +- Form parser also recognizes user-built `CustomComponent` nodes whose + display name ends with `"Form"` (e.g. Mitarbeiter Einstellen Form) — + same template contract, no kirche base needed. +- Standalone `CustomInputMitarbeiter` component ("Mitarbeiter Einstellen + Form") with hiring flow incl. Gehaltsrechner, rebuilt generator-owned. +- Admin flow table shows one row per flow (latest version, with live + version note when an older version is deployed) instead of one row + per version. +- Step-by-step guide for new workflow modules: `docs/new_workflow_module.md` + (linked from README). +### Changed +- `CustomInputForm.collect()` is now dynamic and returns `Data` (JSON) + instead of `Message`: fields can be renamed, added, or removed in the + Langflow code editor with no `collect()` change, and values travel as a + real JSON object. The form parser keeps `name` keys verbatim (empty + names skipped, collisions suffixed) and the run envelope merges unknown + keys. Note: a terminal `ChatOutput` renders `Data` as a fenced ```json + block, and downstream components should read `.data`, not `.text`. ### Added - Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync` (Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle diff --git a/README.md b/README.md index 1c09b1c..17203a3 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,12 @@ needs judgment. Every step is documented and traceable. can automatically start a follow-up ("continuation") flow. - **Access management** — public landing page (German) with an access-request form (request + approval). Approved users get a passwordless magic-link - invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants - come from the session. First admin via `POST /api/auth/admin-bootstrap` - (only on an empty user table, email must be in `ADMIN_EMAILS`). + invite by email (SMTP via `.env`); sessions are stateless JWTs (HS256, + 7-day expiry) in HttpOnly cookies — no server-side revocation, logout + clears the cookie, deleted users fail closed. Tenants come from the + session. `JWT_SECRET` (long random value) signs the cookies. First admin + via `POST /api/auth/admin-bootstrap` (only on an empty user table, email + must be in `ADMIN_EMAILS`). - **Dynamic forms** — flows declare form fields as typed inputs on a `CustomInput*` custom component (reusable library in `backend/components/`, mounted into Langflow via @@ -125,6 +128,8 @@ Frontend (node lives in the `frontend` container): running via compose; this is for a second instance or flags). - `docker compose exec frontend npm run build` — typecheck + build. - Without a docker socket: VS Code "Attach to Running Container" → frontend. +- Local `npm install`: use `--legacy-peer-deps` (Mantine 9 + React 19 + peer range); `npm ci` inside the image works off the committed lockfile. Flow workflow: design in Langflow → press "Aus Langflow synchronisieren" in Verwaltung (manual full pass: Langflow → `backend/flows/.json` → @@ -133,6 +138,14 @@ DB + LFX deploy) → commit. File-only alternatives: `scripts/sync_flows.py watcher deploys file edits automatically. Deleting a file removes the flow including its runs (git keeps the JSON). +New workflow module? Step-by-step guide: `docs/new_workflow_module.md`. + +Pull safety: sync matches Langflow flows to files **by name** and +overwrites silently — renaming or repurposing a flow in Langflow (or a +duplicate with the same name) lands in the matched file. Commit before +syncing and review the `pulled` report with `git diff`; uncommitted +work in `backend/flows/` can be overwritten. + ## Production (self-hosted — TLS/reverse proxy required, not included) 1. Check out the repo on the host, create `.env` from `.env.example` with @@ -152,7 +165,9 @@ proxy — nothing here substitutes for it): critical unauthenticated RCEs — treat its UI as privileged tooling, never share flows publicly ("Shareable Playground" = RCE risk). - Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate - after any incident or staff change; re-run `check_prod.py`. + after any incident or staff change; re-run `check_prod.py`. `JWT_SECRET` + signs session cookies — rotating it logs everyone out (same on every + deploy that drops session state: users re-login via magic link). - Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION` (`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then `lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py index 7880082..81b0184 100644 --- a/backend/app/auth/api.py +++ b/backend/app/auth/api.py @@ -6,16 +6,11 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from sqlmodel import Session +from app.db import get_db from app.models import User from . import mail, service -from .deps import ( - SESSION_COOKIE, - SESSION_MAX_AGE, - get_current_user, - get_db, - require_admin, -) +from .deps import SESSION_COOKIE, SESSION_MAX_AGE, get_current_user, require_admin from .schemas import ( AccessDecisionRead, AccessRequestCreate, @@ -33,26 +28,11 @@ auth_router = APIRouter(prefix="/api/auth", tags=["auth"]) def _read_request(r) -> AccessRequestRead: - return AccessRequestRead( - id=r.id, - name=r.name, - email=r.email, - reason=r.reason, - status=r.status, - created_at=r.created_at, - decided_at=r.decided_at, - decided_by=r.decided_by, - ) + return AccessRequestRead.model_validate(r, from_attributes=True) def _read_me(user: User) -> MeRead: - return MeRead( - id=user.id, - email=user.email, - name=user.name, - role=user.role, - tenant_id=user.tenant_id, - ) + return MeRead.model_validate(user, from_attributes=True) def _set_session_cookie(response: Response, session_id: str) -> None: @@ -146,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) @@ -171,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 b090eb9..37dc5cb 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -1,14 +1,13 @@ -"""Session-cookie dependencies. Handlers stay thin; DB via get_session.""" +"""Session-cookie dependencies. Handlers stay thin; DB via app.db.get_db.""" from __future__ import annotations -from collections.abc import Iterator from typing import Annotated from fastapi import Depends, HTTPException, Request from sqlmodel import Session -from app.db import get_session +from app.db import get_db from app.models import User from . import service @@ -17,20 +16,13 @@ SESSION_COOKIE = "kirche_session" SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds()) -def get_db() -> Iterator[Session]: - yield from get_session() - - 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: @@ -44,11 +36,3 @@ def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User: if user.role != "admin": raise HTTPException(status_code=403, detail="admin only") return user - - -def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str: - """Tenant comes from the session; the body/query param is a deprecated - fallback for unauthenticated callers.""" - if user is not None: - return user.tenant_id - return explicit or default diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py index a8cf957..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,10 +24,15 @@ BLUE_LIGHT = "#eef4fb" INK = "#162f4c" MUTED = "#5b6b7c" -BLUE = "#1d4a79" -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: @@ -33,61 +40,46 @@ def magic_link(token: str) -> str: 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( @@ -147,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) @@ -172,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 8681284..a998c11 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,7 +1,13 @@ -"""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=7d, jti) in the HttpOnly session cookie. No session table and no +revocation list: logout clears the cookie client-side, and a stolen token +is valid until its short expiry. Role/tenant changes apply at next login +(deleted users fail closed — the user row is reloaded per request). """ from __future__ import annotations @@ -9,18 +15,24 @@ 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 logger = logging.getLogger(__name__) MAGIC_LINK_TTL = timedelta(minutes=15) -SESSION_TTL = timedelta(days=30) +# Session window is deliberately short: sessions are stateless JWTs with +# no server-side revocation (logout only clears the cookie), so a stolen +# token stays valid until expiry. 7 days bounds that window. +SESSION_TTL = timedelta(days=7) # Public request endpoint: at most this many requests per email per hour. REQUESTS_PER_EMAIL_PER_HOUR = 5 @@ -60,10 +72,6 @@ class RateLimitedError(AuthError): pass -def utcnow() -> datetime: - return datetime.now(UTC) - - def normalize_email(email: str) -> str: return email.strip().lower() @@ -92,18 +100,22 @@ def user_count(session: Session) -> int: return session.exec(select(func.count()).select_from(User)).one() -def recent_request_count(session: Session, email: str, hours: int = 1) -> int: +def _recent_count(session: Session, model, *filters, hours: int = 1) -> int: since = utcnow() - timedelta(hours=hours) return session.exec( - select(func.count()) - .select_from(AccessRequest) - .where( - AccessRequest.email == normalize_email(email), - AccessRequest.created_at >= since, - ) + select(func.count()).select_from(model).where(*filters, model.created_at >= since) ).one() +def recent_request_count(session: Session, email: str, hours: int = 1) -> int: + return _recent_count( + session, + AccessRequest, + AccessRequest.email == normalize_email(email), + hours=hours, + ) + + def create_access_request( session: Session, name: str, email: str, reason: str = "" ) -> AccessRequest: @@ -165,15 +177,7 @@ def issue_magic_token(session: Session, user: User) -> str: def recent_token_count(session: Session, user: User, hours: int = 1) -> int: - since = utcnow() - timedelta(hours=hours) - return session.exec( - select(func.count()) - .select_from(MagicLinkToken) - .where( - MagicLinkToken.user_id == user.id, - MagicLinkToken.created_at >= since, - ) - ).one() + return _recent_count(session, MagicLinkToken, MagicLinkToken.user_id == user.id, hours=hours) def request_magic_link(session: Session, email: str) -> tuple[User, str]: @@ -242,8 +246,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 (7-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() @@ -259,33 +298,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/db.py b/backend/app/db.py index 768c2ea..c3aa192 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -44,6 +44,11 @@ def get_session() -> Iterator[Session]: yield session +def get_db() -> Iterator[Session]: + """Single FastAPI DB dependency (all routers import this).""" + yield from get_session() + + def create_all(url: str | None = None) -> None: """Create tables directly (dev convenience; migrations own prod schema).""" SQLModel.metadata.create_all(get_engine(url)) diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index eaca507..5f05bd0 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -12,30 +12,24 @@ from __future__ import annotations import json import logging import secrets -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator from pathlib import Path from typing import Annotated from uuid import UUID -import httpx -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_engine, get_session +from app.db import get_db +from app.middleware import limiter, run_limit from app.models import User from app.settings import settings -from . import input_schema as form_schema from . import runner, store -from .deploy import import_from_langflow, pull_all_from_langflow -from .langflow_client import ( - LangflowClient, - LangflowError, - LangflowNotFound, - LangflowUnavailable, -) +from .deploy import import_from_langflow +from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable from .lfx_client import ( LfxClient, LfxConflict, @@ -56,22 +50,20 @@ from .schemas import ( FlowVersionCreate, FlowVersionRead, InputSchemaRead, + NamedRunRead, RunRead, RunRequest, SyncReport, ) -from .sync import lfx_id_for, sync_from_directory +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__) -def get_db() -> Iterator[Session]: - yield from get_session() - - def get_lfx() -> LfxClient: return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) @@ -81,60 +73,40 @@ def get_langflow() -> LangflowClient: def _read_definition(d) -> FlowDefinitionRead: - return FlowDefinitionRead( - id=d.id, - name=d.name, - description=d.description, - status=d.status, - source_path=d.source_path, - continuation_flow_id=d.continuation_flow_id, - created_at=d.created_at, - ) + return FlowDefinitionRead.model_validate(d, from_attributes=True) def _read_version(v) -> FlowVersionRead: - return FlowVersionRead( - id=v.id, - flow_id=v.flow_id, - version_no=v.version_no, - langflow_json=v.langflow_json, - lfx_flow_id=v.lfx_flow_id, - deployed_at=v.deployed_at, - author=v.author, - created_at=v.created_at, - input_schema=v.input_schema, - ) + return FlowVersionRead.model_validate(v, from_attributes=True) def _read_run(r) -> RunRead: - return RunRead( - id=r.id, - version_id=r.version_id, - tenant_id=r.tenant_id, - status=r.status, - input=r.input, - output=r.output, - session_id=r.session_id, - created_at=r.created_at, - finished_at=r.finished_at, - ) + return RunRead.model_validate(r, from_attributes=True) def _read_task(t) -> ApprovalRead: - return ApprovalRead( - id=t.id, - run_id=t.run_id, - version_id=t.version_id, - tenant_id=t.tenant_id, - title=t.title, - payload=t.payload, - continuation_flow_id=t.continuation_flow_id, - approver=t.approver, - status=t.status, - decision_note=t.decision_note, - created_at=t.created_at, - decided_at=t.decided_at, - ) + return ApprovalRead.model_validate(t, from_attributes=True) + + +def _lfx_http(exc: LfxError) -> HTTPException: + """Map LFX domain errors to HTTP (single place, was 10x duplicated).""" + if isinstance(exc, LfxInvalidFlow): + return HTTPException(status_code=422, detail=str(exc)) + if isinstance(exc, LfxConflict): + return HTTPException(status_code=409, detail=str(exc)) + if isinstance(exc, LfxUnavailable): + logger.warning("lfx unavailable: %s", exc) + return HTTPException(status_code=503, detail="flow runtime unavailable") + logger.warning("lfx error: %s", exc) + return HTTPException(status_code=502, detail="flow runtime error") + + +def _langflow_http(exc: LangflowError) -> HTTPException: + if isinstance(exc, LangflowUnavailable): + logger.warning("langflow unavailable: %s", exc) + return HTTPException(status_code=503, detail="authoring server unavailable") + logger.warning("langflow error: %s", exc) + return HTTPException(status_code=502, detail="authoring server error") @flows_router.post("", response_model=FlowDefinitionRead, status_code=201) @@ -164,21 +136,14 @@ async def sync_flows( _admin: Annotated[User, Depends(require_admin)], ): """Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX.""" + from .sync import sync_full_pass + try: - pulled_result = await pull_all_from_langflow(langflow, Path(settings.flows_dir)) - except LangflowUnavailable as exc: - logger.warning("langflow unavailable: %s", exc) - raise HTTPException(status_code=503, detail="authoring server unavailable") from exc + return await sync_full_pass(session, lfx, langflow, Path(settings.flows_dir)) except LangflowError as exc: - logger.warning("langflow error: %s", exc) - raise HTTPException(status_code=502, detail="authoring server error") from exc - try: - report = await sync_from_directory(session, lfx, Path(settings.flows_dir)) + raise _langflow_http(exc) from exc except FileNotFoundError as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc - report.pulled = pulled_result["pulled"] - report.errors = {**pulled_result["errors"], **report.errors} - return report @flows_router.get("/{flow_id}", response_model=FlowDefinitionRead) @@ -236,16 +201,8 @@ async def deploy_version( flow_id=lfx_id_for(flow_id, version_no), replace=True, ) - except LfxInvalidFlow as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - except LfxConflict as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except LfxUnavailable as exc: - logger.warning("lfx unavailable: %s", exc) - raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc except LfxError as exc: - logger.warning("lfx error: %s", exc) - raise HTTPException(status_code=502, detail="flow runtime error") from exc + raise _lfx_http(exc) from exc return _read_version(store.mark_deployed(session, version, uploaded.id)) @@ -256,7 +213,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)], @@ -290,71 +249,52 @@ async def run_flow( ) except LfxNotFound as exc: raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc - except LfxUnavailable as exc: - logger.warning("lfx unavailable: %s", exc) - raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc except LfxError as exc: - logger.warning("lfx error: %s", exc) - raise HTTPException(status_code=502, detail="flow runtime error") from exc + raise _lfx_http(exc) from exc return _read_run(run) async def _run_stream( session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition ): - if not version.lfx_flow_id: - raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX") - run = store.create_run( - session, - version, - input=data.input, - tenant_id=user.tenant_id, - session_id=data.session_id, - ) - envelope = runner.build_envelope( - payload=data.input, - run_id=run.id, - callback_base_url=settings.callback_base_url, - continuation_flow_id=definition.continuation_flow_id, - callback_token=settings.callback_token, - ) + try: + run, envelope, lfx_flow_id, sid = await runner.stream_run( + session, + lfx, + version, + payload=data.input, + tenant_id=user.tenant_id, + session_id=data.session_id, + callback_base_url=settings.callback_base_url, + continuation_flow_id=definition.continuation_flow_id, + callback_token=settings.callback_token, + ) + except store.InvalidError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc run_id = run.id - lfx_flow_id = version.lfx_flow_id - session_id = data.session_id or str(run.id) async def event_gen() -> AsyncIterator[str]: yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n" - try: - async with ( - httpx.AsyncClient( - base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers() - ) as client, - client.stream( - "POST", - f"/flows/{lfx_flow_id}/stream", - json={"input_value": envelope, "session_id": session_id}, - ) as response, - ): - if response.status_code == 404: - raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") - response.raise_for_status() - async for chunk in response.aiter_text(): - yield chunk - except (LfxNotFound, httpx.HTTPError) as exc: - yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" - # Finalize from a fresh session: the request session may be closed. - with Session(get_engine()) as fresh: - try: - fresh_run = store.get_run(fresh, run_id) - status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed" - store.set_run_result(fresh, fresh_run, status, {"streamed": True}) - except store.NotFoundError: - status = "failed" + async for chunk in runner.stream_lfx_chunks(lfx, lfx_flow_id, envelope, sid): + yield chunk + status = runner.finalize_streamed_run(run_id) yield f"event: run_finished\ndata: {json.dumps({'run_id': str(run_id), 'status': status})}\n\n" return StreamingResponse(event_gen(), media_type="text/event-stream") +@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, @@ -376,12 +316,8 @@ async def list_langflow_flows( """Browse flows on the Langflow authoring server (admin import source).""" try: return await langflow.list_flows() - except LangflowUnavailable as exc: - logger.warning("langflow unavailable: %s", exc) - raise HTTPException(status_code=503, detail="authoring server unavailable") from exc except LangflowError as exc: - logger.warning("langflow error: %s", exc) - raise HTTPException(status_code=502, detail="authoring server error") from exc + raise _langflow_http(exc) from exc @flows_router.post("/import", response_model=FlowImportRead, status_code=201) @@ -405,26 +341,14 @@ async def import_flow( ) except LangflowNotFound as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc - except LangflowUnavailable as exc: - logger.warning("langflow unavailable: %s", exc) - raise HTTPException(status_code=503, detail="authoring server unavailable") from exc except LangflowError as exc: - logger.warning("langflow error: %s", exc) - raise HTTPException(status_code=502, detail="authoring server error") from exc + raise _langflow_http(exc) from exc except store.ConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except store.InvalidError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - except LfxInvalidFlow as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - except LfxConflict as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except LfxUnavailable as exc: - logger.warning("lfx unavailable: %s", exc) - raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc except LfxError as exc: - logger.warning("lfx error: %s", exc) - raise HTTPException(status_code=502, detail="flow runtime error") from exc + raise _lfx_http(exc) from exc return FlowImportRead(definition=_read_definition(definition), version=_read_version(version)) @@ -438,7 +362,7 @@ def get_input_schema( version = store.get_latest_deployed_version(session, flow_id) except (store.NotFoundError, store.InvalidError) as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc - cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json) + cached = version.input_schema return InputSchemaRead( flow_id=flow_id, version_no=version.version_no, @@ -561,10 +485,8 @@ async def decide_approval( ) except LfxNotFound as exc: raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc - except LfxUnavailable as exc: - raise HTTPException(status_code=503, detail=str(exc)) from exc except LfxError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc + raise _lfx_http(exc) from exc return DecisionRead( task=_read_task(task), continuation_run=_read_run(continuation_run) if continuation_run else None, diff --git a/backend/app/flows/base_client.py b/backend/app/flows/base_client.py new file mode 100644 index 0000000..10b7286 --- /dev/null +++ b/backend/app/flows/base_client.py @@ -0,0 +1,44 @@ +"""Shared base for LFX + Langflow async HTTP clients. + +Consolidates base_url stripping, header building, per-request client +construction, and HTTPError → domain-error mapping. Subclasses only +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: + """Thin wrapper around httpx.AsyncClient construction.""" + + def __init__(self, base_url: str, timeout: float = 60.0, api_key: str | None = None) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.api_key = api_key or "" + + def _headers(self) -> dict[str, str]: + return {"x-api-key": self.api_key} if self.api_key else {} + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self.base_url, timeout=self.timeout, headers=self._headers() + ) + + +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/deploy.py b/backend/app/flows/deploy.py index 6f017cb..6358da8 100644 --- a/backend/app/flows/deploy.py +++ b/backend/app/flows/deploy.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from pathlib import Path from sqlmodel import Session @@ -13,6 +14,8 @@ from . import export, sync from .langflow_client import LangflowClient, LangflowError from .lfx_client import LfxClient +logger = logging.getLogger(__name__) + def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict: """Reduce a Langflow FlowRead to the canonical fields we persist.""" @@ -40,6 +43,7 @@ async def pull_all_from_langflow( """ flows_dir = Path(flows_dir) headers = await langflow.list_flows() + ignored = export.ignored_stems(flows_dir) pulled: list[str] = [] errors: dict[str, str] = {} for header in headers: @@ -49,6 +53,9 @@ async def pull_all_from_langflow( try: exported = await langflow.export_flow(flow_id) doc = normalize_exported_flow(exported, flow_id) + if export.filename_stem(doc["name"]) in ignored: + logger.warning("pull skips ignored flow %r", doc["name"]) + continue reuse, identical = export.find_reuse_path(flows_dir, doc) if identical and reuse is not None: pulled.append(reuse.stem) diff --git a/backend/app/flows/export.py b/backend/app/flows/export.py index 0681b2f..1235604 100644 --- a/backend/app/flows/export.py +++ b/backend/app/flows/export.py @@ -10,11 +10,42 @@ import hashlib import json import os import re -import tempfile from pathlib import Path FLOWS_GLOB = "*.json" +#: Opt-out list in the flows dir (one stem per line, `#` comments): +#: listed flows stay in Langflow only — never synced, deployed, +#: 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() + 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: """Map a flow name to a filename stem, keeping the original name. @@ -79,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 @@ -113,6 +135,7 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None return write_flow_bytes(flow_path(flows_dir, stem), doc) -def iter_flow_files(flows_dir: str | Path) -> list[Path]: - """Sorted list of candidate flow files (top level only).""" - return sorted(Path(flows_dir).glob(FLOWS_GLOB)) +def iter_flow_files(flows_dir: str | Path, *, ignored: set[str] | None = None) -> list[Path]: + """Sorted list of candidate flow files (top level only, minus ignored).""" + skip = ignored if ignored is not None else ignored_stems(flows_dir) + return sorted(p for p in Path(flows_dir).glob(FLOWS_GLOB) if p.stem not in skip) diff --git a/backend/app/flows/input_schema.py b/backend/app/flows/input_schema.py index b453cb4..32a4b4f 100644 --- a/backend/app/flows/input_schema.py +++ b/backend/app/flows/input_schema.py @@ -1,11 +1,16 @@ """Derive a web-form schema from a Langflow export. -Looks for ``CustomInput*`` nodes (``data.type`` or the inner -``display_name`` starting with ``CustomInput`` — see -``backend/components/kirche/form_inputs.py``) and maps their ``template`` +Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner +``display_name`` starting with ``CustomInput``, see +``backend/components/kirche/form_inputs.py``) plus user-built +``CustomComponent`` nodes whose display name ends with ``"Form"`` +(e.g. ``Mitarbeiter Einstellen Form``) — and maps their ``template`` (minus ``code``) to ``{json_schema, ui_schema}`` for RJSF: -* ``name`` → property key, ``display_name`` → ``title``, +* ``name`` → property key (freely renamable in the Langflow code editor; + kept verbatim so frontend payload keys match the runtime output — + only empty names are skipped and collisions get a ``_2``/``_3`` suffix), + ``display_name`` → ``title``, ``info`` → ``description``, ``value`` → ``default``, ``options`` → ``enum``, ``required`` → required array * ``show: false`` fields are plumbing (e.g. the run envelope input), not @@ -13,6 +18,13 @@ Looks for ``CustomInput*`` nodes (``data.type`` or the inner * ``advanced`` fields render last via ``ui:order`` * ``_input_type`` → JSON type + widget (unknown types fall back to string so the parser never crashes on new fields) +* ``[visible_if ...]`` markers in ``info`` → conditional branches + (``allOf`` with ``if``/``then`` per dependent field, arbitrarily nested; + see ``_extract_visibility``). The marker is stripped from the shown + description. Preferred: structured ``visible_if`` key (list of + ``{field, value}`` or ``{field: value}``) — no quoting mini-language, + rename-refactorable, validated at sync time with warnings instead of + silent fail-open. Markers stay as deprecated fallback. A flow without such a node gets ``{fallback: "textarea"}``: the frontend renders one free-text box instead of a generated form. @@ -20,9 +32,19 @@ renders one free-text box instead of a generated form. from __future__ import annotations +import re + +#: ``[visible_if gebaeude=Kirche & bestandteil="Glocke, Orgel"]`` in a +#: field's ``info``. Authored in the Langflow code editor; the parser strips +#: the marker from the shown description and turns the conditions into +#: ``allOf``/``if``/``then`` branches (draft-07 — the ``dependencies``/ +#: ``if`` keywords this stack's RJSF + ajv build honors; ``dependentSchemas`` +#: is ignored there, so it is never emitted). +_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]") + def _form_templates(langflow_json: dict) -> list[dict]: - """Inner node dicts (``data.node``) of all CustomInput* nodes.""" + """Inner node dicts (``data.node``) of all form nodes.""" if not isinstance(langflow_json, dict): return [] data = langflow_json.get("data") or {} @@ -37,6 +59,10 @@ def _form_templates(langflow_json: dict) -> list[dict]: display = str(inner.get("display_name") or "") if node_type.startswith("CustomInput") or display.startswith("CustomInput"): found.append(inner) + elif node_type == "CustomComponent" and display.endswith("Form"): + # User-built form components (e.g. "Mitarbeiter Einstellen + # Form"): same template contract, no kirche base needed. + found.append(inner) return found @@ -112,6 +138,125 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]: return prop, ui +def _split_conditions(text: str) -> list[str]: + """Split ``a=X & b="Y Z"`` on ``&`` outside double quotes.""" + parts: list[str] = [] + buf: list[str] = [] + 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) -> tuple[str, object] | None: + """``feld=Wert`` → ``(feld, wert)`` (bool/number coerced, quotes stripped).""" + 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 _extract_visibility( + info: str, field: dict | None = None +) -> tuple[str, list[tuple[str, object]]]: + """(marker-free description, conditions) for a field's ``info`` text. + + Structured ``visible_if`` on the field wins (list of {field, value} or + dict); legacy ``[visible_if ...]`` markers in ``info`` stay as fallback. + """ + if field is not None: + structured = field.get("visible_if") + if structured: + conditions: list[tuple[str, object]] = [] + items = structured.items() if isinstance(structured, dict) else structured + for item in items or []: + if isinstance(item, dict) and "field" in item: + conditions.append((item["field"], item.get("value"))) + elif isinstance(item, (list, tuple)) and len(item) == 2: + conditions.append((item[0], item[1])) + if conditions: + clean = re.sub(r"\s+", " ", (info or "")).strip() + return clean, conditions + conditions = [] + + def _collect(match: re.Match) -> str: + for token in _split_conditions(match.group(1)): + parsed = _parse_condition(token) + if parsed is not None: + conditions.append(parsed) + return "" + + clean = _VISIBLE_RE.sub(_collect, info or "") + clean = re.sub(r"\s+", " ", clean).strip() + return clean, conditions + + +def _match_schema(trigger_field: dict, value: object) -> dict | None: + """JSON-Schema fragment matching one condition, or None (unsupported).""" + input_type = str(trigger_field.get("_input_type") or "") + if input_type == "MultiselectInput": + return {"contains": {"const": value}} + if input_type == "BoolInput": + return {"const": value} if isinstance(value, bool) else None + if input_type in ( + "StrInput", + "MessageTextInput", + "MultilineInput", + "SecretStrInput", + "DropdownInput", + ): + return {"const": value} if isinstance(value, str) else None + if input_type in ("IntInput", "FloatInput"): + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return {"const": value} + return None + + +def _cyclic_fields(edges: dict[str, set[str]]) -> set[str]: + """Fields on a trigger→dependent cycle (fail-open candidates).""" + cyclic: set[str] = set() + visiting: list[str] = [] + + def _visit(node: str) -> None: + visiting.append(node) + for target in edges.get(node, ()): + if target in visiting: + cyclic.update(visiting[visiting.index(target) :]) + elif target not in cyclic: + _visit(target) + visiting.pop() + + for node in edges: + if node not in cyclic: + _visit(node) + return cyclic + + def extract_form_schema(langflow_json: dict) -> dict: """``{json_schema, ui_schema}``, or with ``fallback: "textarea"``.""" templates = _form_templates(langflow_json) @@ -122,31 +267,126 @@ def extract_form_schema(langflow_json: dict) -> dict: "fallback": "textarea", } template = templates[0].get("template") or {} - properties: dict = {} - required: list[str] = [] - ui_schema: dict = {} - normal: list[str] = [] - advanced: list[str] = [] + # First pass: sanitize names (existing rules) and parse markers. + records: list[dict] = [] + taken: set[str] = set() for key, field in template.items(): if key == "code" or not isinstance(field, dict): continue if field.get("show") is False: continue - name = field.get("name") or key - prop, ui = _field_schema(name, field) - properties[name] = prop - if field.get("required"): - required.append(name) + raw_name = field.get("name") or key + name = str(raw_name).strip() + if not name: + continue + if name in taken: + suffix = 2 + while f"{name}_{suffix}" in taken: + suffix += 1 + name = f"{name}_{suffix}" + taken.add(name) + clean_info, conditions = _extract_visibility(str(field.get("info") or ""), field) + records.append( + { + "name": name, + "field": {**field, "info": clean_info}, + "required": bool(field.get("required")), + "advanced": bool(field.get("advanced")), + "conditions": conditions, + } + ) + by_name = {record["name"]: record for record in records} + # Validate conditions: known trigger + supported match. Anything else + # fails open (field stays always visible) so a stale marker can never + # hide data or break validation. + matches: dict[str, list[tuple[str, dict]]] = {} + for record in records: + resolved: list[tuple[str, dict]] = [] + for trigger, value in record["conditions"]: + trigger_record = by_name.get(trigger) + match = _match_schema(trigger_record["field"], value) if trigger_record else None + if trigger_record is None or match is None or trigger == record["name"]: + resolved = [] + break + resolved.append((trigger, match)) + if resolved: + matches[record["name"]] = resolved + for name in _cyclic_fields( + {name: {trigger for trigger, _ in conds} for name, conds in matches.items()} + ): + del matches[name] + # Emit: conditional fields live only in their if/then branch (that is + # what hides them in RJSF); everything else stays at root. + properties: dict = {} + required: list[str] = [] + ui_schema: dict = {} + normal: list[str] = [] + advanced: list[str] = [] + for record in records: + name = record["name"] + prop, ui = _field_schema(name, record["field"]) + if name not in matches: + properties[name] = prop + if record["required"]: + required.append(name) + else: + record["branch_prop"] = prop if ui: ui_schema[name] = ui - (advanced if field.get("advanced") else normal).append(name) - if advanced: + (advanced if record["advanced"] else normal).append(name) + json_schema: dict = {"type": "object", "properties": properties, "required": required} + if matches: + rules = [] + for name, conds in matches.items(): + record = by_name[name] + rules.append( + { + "if": { + "properties": {trigger: match for trigger, match in conds}, + "required": [trigger for trigger, _ in conds], + }, + "then": { + "properties": {name: record["branch_prop"]}, + **({"required": [name]} if record["required"] else {}), + }, + } + ) + json_schema["allOf"] = rules ui_schema["ui:order"] = normal + advanced + ["*"] - return { - "json_schema": { - "type": "object", - "properties": properties, - "required": required, - }, - "ui_schema": ui_schema, - } + elif advanced: + ui_schema["ui:order"] = normal + advanced + ["*"] + return {"json_schema": json_schema, "ui_schema": ui_schema} + + +def schema_field_names(cached: dict) -> list[str]: + """All asked field names of a cached form schema (root + branches). + + Used for the run envelope's ``fields`` list so the runtime emits + defaults only for asked fields. Order: root properties first, then + branch discoveries. + """ + if not isinstance(cached, dict): + return [] + json_schema = cached.get("json_schema") or {} + names: list[str] = [] + + def _walk(node: object) -> None: + if not isinstance(node, dict): + return + for subschema in (node.get("then"), node.get("else")): + if isinstance(subschema, dict): + for key in subschema.get("properties") or {}: + if key not in names: + names.append(key) + _walk(subschema) + for key in ("allOf", "oneOf", "anyOf"): + items = node.get(key) or [] + if isinstance(items, list): + for item in items: + _walk(item) + + for key in json_schema.get("properties") or {}: + if key not in names: + names.append(key) + _walk(json_schema) + return names diff --git a/backend/app/flows/langflow_client.py b/backend/app/flows/langflow_client.py index 7414eb8..02d4d31 100644 --- a/backend/app/flows/langflow_client.py +++ b/backend/app/flows/langflow_client.py @@ -8,6 +8,8 @@ from __future__ import annotations import httpx +from .base_client import BaseApiClient, transport_retry + class LangflowError(Exception): pass @@ -21,23 +23,17 @@ class LangflowUnavailable(LangflowError): pass -class LangflowClient: +class LangflowClient(BaseApiClient): """Thin async wrapper around the Langflow `/api/v1/flows` endpoints.""" def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None: - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.timeout = timeout - - def _headers(self) -> dict[str, str]: - return {"x-api-key": self.api_key} if self.api_key else {} + 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: - async with httpx.AsyncClient( - base_url=self.base_url, timeout=self.timeout, headers=self._headers() - ) as client: + async with self._client() as client: response = await client.get("/api/v1/flows/", params={"header_flows": "true"}) if response.status_code == 404: raise LangflowNotFound("flow list not found") @@ -49,12 +45,11 @@ class LangflowClient: 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: - async with httpx.AsyncClient( - base_url=self.base_url, timeout=self.timeout, headers=self._headers() - ) as client: + async with self._client() as client: response = await client.get(f"/api/v1/flows/{langflow_flow_id}") if response.status_code == 404: raise LangflowNotFound(f"flow {langflow_flow_id} not found") @@ -64,3 +59,25 @@ class LangflowClient: raise LangflowError(f"Langflow error {exc.response.status_code}") from exc 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). + + Used after flow/component tightening so the visual editor shows the + latest canonical JSON without a container restart. + """ + try: + async with self._client() as client: + existing = await self.list_flows() + match = next((f for f in existing if f.get("name") == doc.get("name")), None) + if match and match.get("id"): + response = await client.patch(f"/api/v1/flows/{match['id']}", json=doc) + else: + response = await client.post("/api/v1/flows/", json=doc) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise LangflowError(f"Langflow error {exc.response.status_code}") from exc + except httpx.HTTPError as exc: + raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc diff --git a/backend/app/flows/lfx_client.py b/backend/app/flows/lfx_client.py index 1b1061b..66a2fc8 100644 --- a/backend/app/flows/lfx_client.py +++ b/backend/app/flows/lfx_client.py @@ -6,6 +6,8 @@ from dataclasses import dataclass import httpx +from .base_client import BaseApiClient, transport_retry + class LfxError(Exception): """Base error for LFX communication problems.""" @@ -48,22 +50,13 @@ class LfxRunResult: component: str -class LfxClient: +class LfxClient(BaseApiClient): """Thin async wrapper around the LFX serve HTTP API.""" def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None: - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self.api_key = api_key - - def _headers(self) -> dict[str, str]: - return {"x-api-key": self.api_key} if self.api_key else {} - - def _client(self) -> httpx.AsyncClient: - return httpx.AsyncClient( - base_url=self.base_url, timeout=self.timeout, headers=self._headers() - ) + super().__init__(base_url, timeout=timeout, api_key=api_key) + @transport_retry async def health(self) -> dict: try: async with self._client() as client: @@ -73,6 +66,7 @@ class LfxClient: 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: @@ -84,6 +78,7 @@ class LfxClient: 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: @@ -148,6 +143,7 @@ class LfxClient: 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: @@ -159,6 +155,7 @@ class LfxClient: 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/runner.py b/backend/app/flows/runner.py index 4c894b9..24cb910 100644 --- a/backend/app/flows/runner.py +++ b/backend/app/flows/runner.py @@ -3,14 +3,18 @@ from __future__ import annotations import json +from collections.abc import AsyncIterator from uuid import UUID -from sqlmodel import Session, select +import httpx +from sqlmodel import Session -from app.models import ApprovalTask, FlowRun, FlowVersion +from app.db import get_engine +from app.models import FlowRun, FlowVersion +from . import input_schema as form_schema from . import store -from .lfx_client import LfxClient, LfxRunFailed +from .lfx_client import LfxClient, LfxNotFound, LfxRunFailed from .store import InvalidError @@ -21,25 +25,25 @@ def build_envelope( callback_base_url: str, continuation_flow_id: UUID | None, callback_token: str, + fields: list[str] | None = None, ) -> str: - return json.dumps( - { - "payload": payload, - "run_id": str(run_id), - "callback_url": callback_base_url.rstrip("/"), - "continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None), - "callback_token": callback_token, - } - ) + """Run envelope for LFX. ``fields`` (asked form-field names, from the + version's cached input schema) lets form components emit defaults only + for asked fields; omitted (None) preserves the legacy shape.""" + body: dict = { + "payload": payload, + "run_id": str(run_id), + "callback_url": callback_base_url.rstrip("/"), + "continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None), + "callback_token": callback_token, + } + if fields is not None: + body["fields"] = list(fields) + return json.dumps(body) def _task_exists(session: Session, run_id: UUID) -> bool: - return ( - session.exec( - select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open") - ).first() - is not None - ) + return store.has_open_task(session, run_id) async def execute_run( @@ -66,12 +70,14 @@ async def execute_run( run = store.create_run( session, version, input=payload, tenant_id=tenant_id, session_id=session_id ) + cached = version.input_schema envelope = build_envelope( payload=payload, run_id=run.id, callback_base_url=callback_base_url, continuation_flow_id=continuation_flow_id, callback_token=callback_token, + fields=form_schema.schema_field_names(cached), ) try: result = await lfx.run_flow( @@ -89,3 +95,73 @@ async def execute_run( if _task_exists(session, run.id): return store.set_run_result(session, run, "pending_approval", output) return store.set_run_result(session, run, "completed", output) + + +async def stream_run( + session: Session, + lfx: LfxClient, + version: FlowVersion, + *, + payload: dict, + tenant_id: str, + session_id: str | None, + callback_base_url: str, + continuation_flow_id: UUID | None, + callback_token: str, +) -> tuple[FlowRun, str, str, str]: + """Create a run + envelope for SSE streaming. Returns (run, envelope, lfx_id, sid). + + The actual SSE bytes are produced by the API layer; finalization uses a + fresh session (the request session may be closed when the generator ends). + """ + if not version.lfx_flow_id: + raise InvalidError(f"version {version.id} is not deployed to LFX") + run = store.create_run( + session, version, input=payload, tenant_id=tenant_id, session_id=session_id + ) + cached = version.input_schema + envelope = build_envelope( + payload=payload, + run_id=run.id, + callback_base_url=callback_base_url, + continuation_flow_id=continuation_flow_id, + callback_token=callback_token, + fields=form_schema.schema_field_names(cached), + ) + return run, envelope, version.lfx_flow_id, session_id or str(run.id) + + +async def stream_lfx_chunks( + lfx: LfxClient, lfx_flow_id: str, envelope: str, sid: str +) -> AsyncIterator[str]: + """Yield raw SSE chunks from LFX, mapping transport errors to error events.""" + try: + async with ( + httpx.AsyncClient( + base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers() + ) as client, + client.stream( + "POST", + f"/flows/{lfx_flow_id}/stream", + json={"input_value": envelope, "session_id": sid}, + ) as response, + ): + if response.status_code == 404: + raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX") + response.raise_for_status() + async for chunk in response.aiter_text(): + yield chunk + except (LfxNotFound, httpx.HTTPError) as exc: + yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" + + +def finalize_streamed_run(run_id: UUID) -> str: + """Mark a streamed run completed/pending_approval from a fresh session.""" + with Session(get_engine()) as fresh: + try: + fresh_run = store.get_run(fresh, run_id) + status = "pending_approval" if store.has_open_task(fresh, run_id) else "completed" + store.set_run_result(fresh, fresh_run, status, {"streamed": True}) + except store.NotFoundError: + status = "failed" + return status diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py index f2616a4..b37f433 100644 --- a/backend/app/flows/schemas.py +++ b/backend/app/flows/schemas.py @@ -37,7 +37,7 @@ class FlowVersionRead(BaseModel): deployed_at: datetime | None author: str created_at: datetime - input_schema: dict | None = None + input_schema: dict class InputSchemaRead(BaseModel): @@ -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 5a5340c..385a52d 100644 --- a/backend/app/flows/store.py +++ b/backend/app/flows/store.py @@ -129,15 +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) - max_no = session.exec( - select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id) - ).one() version = FlowVersion( flow_id=flow_id, - version_no=(max_no or 0) + 1, + 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 @@ -229,6 +232,16 @@ def set_run_result(session: Session, run: FlowRun, status: str, output: dict | N return run +def has_open_task(session: Session, run_id: UUID) -> bool: + """True when a run has an open approval task (terminal-state mapping).""" + return ( + session.exec( + select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open") + ).first() + is not None + ) + + def get_run(session: Session, run_id: UUID) -> FlowRun: run = session.get(FlowRun, run_id) if run is None: @@ -249,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 3ec3cd5..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: @@ -186,6 +185,14 @@ async def sync_from_directory( return report +def format_report(report: SyncReport) -> str: + """One-line summary for startup/watcher logs (keeps main.py thin).""" + return ( + f"+{len(report.added)} ~{len(report.updated)} -{len(report.deleted)} " + f"skipped={len(report.skipped)} errors={len(report.errors)}" + ) + + def default_session_factory() -> Callable[[], Session]: return lambda: Session(get_engine()) @@ -194,6 +201,27 @@ def default_lfx_factory() -> Callable[[], LfxClient]: return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) +async def sync_full_pass( + session: Session, + lfx: LfxClient, + langflow, + flows_dir: str | Path, +) -> SyncReport: + """Single orchestration: Langflow pull -> files -> DB + LFX. + + Shared by POST /api/flows/sync, CLI, and watcher-adjacent paths so the + full-pass logic lives once. Pull errors merge with sync errors. + """ + from .deploy import pull_all_from_langflow + + flows_dir = Path(flows_dir) + pulled_result = await pull_all_from_langflow(langflow, flows_dir) + report = await sync_from_directory(session, lfx, flows_dir) + report.pulled = pulled_result["pulled"] # type: ignore[typeddict-item] + report.errors = {**pulled_result["errors"], **report.errors} # type: ignore[typeddict-item] + return report + + def run_sync_blocking( flows_dir: str | Path, *, @@ -211,7 +239,11 @@ def run_sync_blocking( class _JsonFilter(DefaultFilter): def __call__(self, change, path: str) -> bool: # type: ignore[override] - return super().__call__(change, path) and path.endswith(".json") + if not (super().__call__(change, path) and path.endswith(".json")): + return False + # The ignore file itself is tiny; re-read per event so no restart + # is needed after editing it. + return Path(path).stem not in export.ignored_stems(Path(path).parent) async def watch_forever( @@ -246,11 +278,4 @@ async def watch_forever( except Exception: logger.exception("flow sync failed") continue - logger.info( - "flow sync: +%d ~%d -%d skipped=%d errors=%d", - len(report.added), - len(report.updated), - len(report.deleted), - len(report.skipped), - len(report.errors), - ) + logger.info("flow sync: %s", format_report(report)) diff --git a/backend/app/main.py b/backend/app/main.py index 83a8d58..bdf4680 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.sync import run_sync_blocking, watch_forever -from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware +from .flows.api import approvals_router, flows_router, runs_router +from .flows.sync import format_report, run_sync_blocking, watch_forever +from .middleware import SecurityHeadersMiddleware, limiter from .settings import settings logger = logging.getLogger(__name__) @@ -20,14 +23,7 @@ def _blocking_startup_sync() -> None: """One-shot reconciliation at startup (fresh session, never fatal).""" try: report = run_sync_blocking(Path(settings.flows_dir)) - logger.info( - "startup flow sync: +%d ~%d -%d skipped=%d errors=%d", - len(report.added), - len(report.updated), - len(report.deleted), - len(report.skipped), - len(report.errors), - ) + logger.info("startup flow sync: %s", format_report(report)) for filename, reason in report.errors.items(): logger.warning("startup flow sync: %s: %s", filename, reason) except FileNotFoundError as exc: @@ -40,6 +36,10 @@ def _blocking_startup_sync() -> None: async def lifespan(app: FastAPI) -> AsyncIterator[None]: task: asyncio.Task | None = None stop: asyncio.Event | None = None + if not settings.jwt_secret: + # Fail-closed sessions need a signing secret; without one every + # login 500s. Warn loudly instead of crashing the whole API. + logger.error("JWT_SECRET is empty — logins will fail until it is set") if settings.flows_watch_enabled: await asyncio.to_thread(_blocking_startup_sync) stop = asyncio.Event() @@ -71,12 +71,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/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..c484969 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,18 @@ +"""Shared SQLModel base: UTC timestamps, no copy-paste across models.""" + +from datetime import UTC, datetime + +from sqlalchemy import Column, DateTime +from sqlmodel import Field + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +def ts_column() -> Column: + return Column(DateTime(timezone=True)) + + +def ts_field(*, default_factory=utcnow) -> datetime: # type: ignore[no-untyped-def] + return Field(default_factory=default_factory, sa_column=ts_column()) diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py index 79e5085..a29e259 100644 --- a/backend/app/models/flow.py +++ b/backend/app/models/flow.py @@ -1,18 +1,12 @@ """Flow engine domain models (SQLModel tables).""" -from datetime import UTC, datetime +from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import JSON, Column, DateTime, UniqueConstraint +from sqlalchemy import JSON, Column, UniqueConstraint from sqlmodel import Field, SQLModel - -def utcnow() -> datetime: - return datetime.now(UTC) - - -def _ts_column() -> Column: - return Column(DateTime(timezone=True)) +from .base import ts_column, utcnow def _json_column() -> Column: @@ -34,7 +28,7 @@ class FlowDefinition(SQLModel, table=True): source_path: str | None = Field(default=None, max_length=255) # Flow to run after a human approval on this flow's runs (nullable). continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id") - created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) class FlowVersion(SQLModel, table=True): @@ -49,12 +43,12 @@ class FlowVersion(SQLModel, table=True): langflow_json: dict = Field(default_factory=dict, sa_column=_json_column()) # Flow id under which LFX serves this version (set on deploy). lfx_flow_id: str | None = Field(default=None, max_length=200) - deployed_at: datetime | None = Field(default=None, sa_column=_ts_column()) + deployed_at: datetime | None = Field(default=None, sa_column=ts_column()) author: str = Field(default="", max_length=200) - created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) # Cached form schema for this exact JSON (see app/flows/input_schema.py). - # Computed at version creation; nullable for pre-Phase-3 rows. - input_schema: dict | None = Field(default=None, sa_column=_json_column()) + # Computed at version creation; backfilled NOT NULL (e8f3a1c2b4d6). + input_schema: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) class FlowRun(SQLModel, table=True): @@ -70,8 +64,8 @@ class FlowRun(SQLModel, table=True): input: dict | None = Field(default=None, sa_column=_json_column()) output: dict | None = Field(default=None, sa_column=_json_column()) session_id: str | None = Field(default=None, max_length=200) - created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) - finished_at: datetime | None = Field(default=None, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) + finished_at: datetime | None = Field(default=None, sa_column=ts_column()) class ApprovalTask(SQLModel, table=True): @@ -90,5 +84,5 @@ class ApprovalTask(SQLModel, table=True): # open | approved | rejected status: str = Field(default="open", index=True, max_length=20) decision_note: str = Field(default="", max_length=2000) - created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) - decided_at: datetime | None = Field(default=None, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) + decided_at: datetime | None = Field(default=None, sa_column=ts_column()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d9d7a09..5e719aa 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,18 +1,11 @@ """Access-management domain models (SQLModel tables).""" -from datetime import UTC, datetime +from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import Column, DateTime from sqlmodel import Field, SQLModel - -def utcnow() -> datetime: - return datetime.now(UTC) - - -def _ts_column() -> Column: - return Column(DateTime(timezone=True)) +from .base import ts_column, utcnow class User(SQLModel, table=True): @@ -26,7 +19,7 @@ class User(SQLModel, table=True): # member | admin role: str = Field(default="member", max_length=20) tenant_id: str = Field(default="default", max_length=200) - created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) class AccessRequest(SQLModel, table=True): @@ -40,8 +33,8 @@ class AccessRequest(SQLModel, table=True): reason: str = Field(default="", max_length=2000) # pending | approved | rejected status: str = Field(default="pending", index=True, max_length=20) - created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) - decided_at: datetime | None = Field(default=None, sa_column=_ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) + decided_at: datetime | None = Field(default=None, sa_column=ts_column()) decided_by: str | None = Field(default=None, max_length=320) @@ -53,20 +46,8 @@ class MagicLinkToken(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=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 + expires_at: datetime = Field(sa_column=ts_column()) + # 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()) + used_at: datetime | None = Field(default=None, sa_column=ts_column()) + created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column()) diff --git a/backend/app/settings.py b/backend/app/settings.py index fe94253..2d9ea80 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,5 +1,6 @@ from pathlib import Path +from pydantic import computed_field from pydantic_settings import BaseSettings, SettingsConfigDict # Repo-root .env (shared with the compose stack). Real environment variables @@ -41,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 = "" @@ -53,6 +56,7 @@ class Settings(BaseSettings): smtp_username: str = "" smtp_password: str = "" + @computed_field # type: ignore[misc] @property def admin_email_list(self) -> list[str]: return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()] diff --git a/backend/components/kirche/README.md b/backend/components/kirche/README.md index ba7a06f..6f75ca5 100644 --- a/backend/components/kirche/README.md +++ b/backend/components/kirche/README.md @@ -8,44 +8,209 @@ derives a JSON Schema + uiSchema from the node's template ## Adding a new form field 1. Open the Langflow authoring UI (http://localhost:7860) and load the - flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded). -2. Edit the `CustomInputForm` node: every entry in its `inputs` list is - one web-form field. Add an `lfx.io` input, e.g. - ```python - from lfx.io import DropdownInput + flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded). +2. Edit the `CustomInputForm` node **in its code editor**: every entry in + its `inputs` list is one web-form field. Add an `lfx.io` input, e.g. + ```python + from lfx.io import DropdownInput - DropdownInput( - name="room", # JSON-Schema property key - display_name="Room", # form label (title) - info="Where should it take place", # help text (description) - options=["Chapel", "Hall"], # dropdown choices (enum) - value="Chapel", # prefill (default) - required=True, # marks the field required - ) - ``` - Supported input types → widgets: `StrInput`/`MessageTextInput` → text, - `MultilineInput` → textarea, `SecretStrInput` → password, - `IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox, - `DropdownInput` → select, `MultiselectInput` → multi-select, - `SliderInput` → slider (needs `range_spec`), `FileInput` → text stub - (real upload later). -3. Add the new field to `collect()` so it reaches the JSON output. + DropdownInput( + name="room", # JSON-Schema property key (freely renamable) + display_name="Room", # form label (title) + info="Where should it take place", # help text (description) + options=["Chapel", "Hall"], # dropdown choices (enum) + value="Chapel", # prefill (default) + required=True, # marks the field required + ) + ``` + Supported input types → widgets: `StrInput`/`MessageTextInput` → text, + `MultilineInput` → textarea, `SecretStrInput` → password, + `IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox, + `DropdownInput` → select, `MultiselectInput` → multi-select, + `SliderInput` → slider (needs `range_spec`), `FileInput` → text stub + (real upload later). +3. Nothing else to change in code: `collect()` is dynamic — it emits + every input except the plumbing ones, so renames, additions, and + removals just work. (The inspector panel can tweak `display_name`, + `info`, `value`, and `options` of existing fields, but `name` and new + fields need the code editor.) 4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden - input, `show=False`). Submitted web-form values arrive there as - `{"payload": {...}}` and are merged over the defaults. Never make form - fields required *with* an incoming edge — that's the envelope's job. + input, `show=False`). Submitted web-form values arrive there as + `{"payload": {...}}` and are merged over the defaults — including keys + the node code does not declare (forward-compatible with renames). + Never make form fields required *with* an incoming edge — that's the + envelope's job. 5. Export the flow (JSON) into `backend/flows/.json` and commit. - The API watcher syncs + deploys it automatically; the form schema is - regenerated per version. + The API watcher syncs + deploys it automatically; the form schema is + regenerated per version. + +## Structured `visible_if` (preferred) + markers (fallback) + +A field can appear only when other fields have certain values — nested +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 +( + DropdownInput( + name="gebaeude", + display_name="Welches Gebäude soll verändert werden?", + options=["Kirche", "Anderes Gebäude"], + value="Kirche", + required=True, + ), +) +( + MultiselectInput( + name="bestandteil", + display_name="Welcher Bestandteil der Kirche?", + options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"], + value=[], + info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]", + required=True, + ), +) +( + DropdownInput( + name="glockenmassnahme", + display_name="Welche Maßnahme an der Glocke?", + options=["Reinigung", "Reparatur", "Neuguss"], + value="Reinigung", + info="[visible_if bestandteil=Glocke]", + ), +) +``` + +Syntax: `[visible_if =]`, `&` for AND +(`[visible_if a=X & b=Y]`), double quotes for values with spaces +(`[visible_if gebaeude="Anderes Gebäude"]`). Trigger matching by input +type: Dropdown/Text `const`, Bool `const` (`true`/`false`), Multiselect +`contains`, Int/Float exact number. Nesting is automatic: a field whose +trigger is itself conditional renders one level deeper, without limit. + +What the parser does with markers: + +- The marker is stripped from the shown help text; the field leaves the + top-level schema and lives only in its `allOf`/`if`/`then` branch, so + RJSF hides it until the condition holds. `ui:order` keeps template + order, so new questions appear directly below their trigger. +- `required` on a branched field applies only while visible. +- Values are always kept: hidden fields still validate and are submitted + (stale values can keep deeper branches satisfied until changed). +- Safety is fail-open: unknown triggers (renamed?), unsupported trigger + types, self-references, and trigger cycles keep the field always + visible instead of hiding data. Renaming a trigger means updating its + markers. + +See `backend/flows/bauvorhaben.json` for a full 4-level example +(Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and +bool trigger). + +## User-built form components (no kirche base needed) + +A plain `CustomComponent` whose display name ends with `"Form"` is also +parsed into a web form — same template contract, no base class. To make +submitted values reach it at runtime, add an envelope input and merge it +in the output method (pattern): + +```python +from lfx.io import MessageTextInput # add to imports +import json + +# add to inputs: +MessageTextInput(name="form_values", display_name="Form values", value="", show=False) + +# at the top of the output method: +try: + raw = self.form_values.text if hasattr(self.form_values, "text") else self.form_values + submitted = json.loads(raw or "{}") +except Exception: + submitted = {} +if isinstance(submitted.get("payload"), dict): + submitted = submitted["payload"] + + +def val(name, default): + return submitted.get(name, default) +``` + +Then use `val("rolle", self.rolle)` etc., and wire a `ChatInput` node +into `form_values` on the canvas. Branch markers (`[visible_if ...]` +in `info`) work here too — the parser reads the template only. + +## Asked fields only (run envelope `fields`) + +The API sends the asked field names (from the version's form schema) +in the run envelope's `fields` list. `collect()` then emits defaults +only for asked AND currently visible fields (branch conditions are +evaluated against submitted values over code defaults, so hidden +fields stay out exactly as the frontend never submits them): the +template may declare fields unknown to this code (they arrive via +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, shared core) + +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, 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. + +## Output type: Data (JSON), not Message + +`collect()` returns `Data(data={...})` — a real JSON object. Consequences: + +- A terminal `ChatOutput` renders it as a fenced ```json block; the run + result text is that block, not raw JSON (unwrap before `json.loads`). +- Components consuming `form_data` should read `.data` (or `get_text()`), + not `.text` like with `Message`. ## Conventions the pipeline relies on - `required=True` on a template field: `lfx validate --level 4` demands a value or an edge, so the canonical-flow generator (`backend/scripts/make_flow_fixtures.py`) writes a single-space - sentinel value for such fields. The form parser treats whitespace-only + sentinel value for such fields (`[" "]` for Multiselect, which rejects + a plain string at runtime). The form parser treats whitespace-only values as "no default" — don't ship real whitespace defaults. -- Field key = `name` (stable); label = `display_name`; help = `info`. +- Field key = `name` (stable, freely renamable but must stay unique and + non-empty; collisions get a `_2`/`_3` suffix in the form schema); + label = `display_name`; help = `info`. Renames propagate to downstream + components, which read the JSON keys as-is. - Unknown future input types degrade to a plain text field in the web form (parser fallback), so adding exotic lfx inputs never breaks runs. @@ -56,3 +221,9 @@ canonical + test-fixture exports: docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py docker compose exec -T api uv run pytest tests/test_lfx_validate.py + +Notes: the generator writes UTF-8/LF explicitly (Windows-safe), and +`backend/flows/*.json` may carry Langflow round-trip state (key order) +the generator does not reproduce — if a regen rewrites canonical files +beyond the component's own node, revert them and splice only the changed +node fields (code, description, output types, edge handles) instead. diff --git a/backend/components/kirche/__init__.py b/backend/components/kirche/__init__.py index 252d226..0f83888 100644 --- a/backend/components/kirche/__init__.py +++ b/backend/components/kirche/__init__.py @@ -7,6 +7,8 @@ template into a web form (``app/flows/input_schema.py``); the frontend renders that schema with RJSF. """ +from .bauvorhaben_form import CustomInputBauvorhaben from .form_inputs import CustomInputForm +from .mitarbeiter_form import CustomInputMitarbeiter -__all__ = ["CustomInputForm"] +__all__ = ["CustomInputBauvorhaben", "CustomInputForm", "CustomInputMitarbeiter"] diff --git a/backend/components/kirche/_form_core.py b/backend/components/kirche/_form_core.py new file mode 100644 index 0000000..07d029a --- /dev/null +++ b/backend/components/kirche/_form_core.py @@ -0,0 +1,147 @@ +"""Shared form runtime core (single source, codegen-inlined for LFX). + +The LFX runtime executes embedded module source without our ``kirche`` +package, so flow JSON must stay self-contained. Author components against +this module (``from kirche._form_core import ...`` or ``from ._form_core`` +); ``scripts/make_flow_fixtures.py:kirche_node()`` inlines this file's text +into the exported ``code`` so the runtime keeps working with one truth in +the repo. + +Supports both the legacy ``[visible_if feld=Wert]`` marker in ``info`` +and the structured ``visible_if`` key (list of {field, value} or dict): +structured wins when present, markers stay as deprecated fallback. +""" + +from __future__ import annotations + +import json +import re + +_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]") + +#: Reserved input carrying the run envelope — never a form field name. +PLUMBING_INPUTS = frozenset({"form_values", "code"}) + + +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: 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, structured=None) -> list: + """[(trigger, value)] from structured key or info markers. + + Structured ``visible_if`` (list of ``{field, value}`` or ``{field: value}`` + dict) wins; legacy ``[visible_if ...]`` markers in ``info`` stay as + deprecated fallback. + """ + if structured: + conditions: list = [] + items = structured.items() if isinstance(structured, dict) else structured + for item in items or []: + if isinstance(item, dict) and "field" in item: + conditions.append((item["field"], item.get("value"))) + elif isinstance(item, (list, tuple)) and len(item) == 2: + conditions.append((item[0], item[1])) + if conditions: + return conditions + conditions = [] + + 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 + + +def collect_form_data(field_names: list, field_values: dict, payload: dict, asked=None) -> dict: + """Shared collect(): effective values, visibility scope, submitted wins.""" + effective = dict(field_values) + for key, val in payload.items(): + if isinstance(key, str) and key: + effective[key] = _json_safe(val) + scope = [n for n in (asked if asked is not None else field_names) if n in field_names] + return effective, scope + + +def parse_envelope(text_value) -> tuple[dict, list | None]: + """(payload, asked-fields) from the run envelope text.""" + try: + envelope = json.loads(_text(text_value) or "{}") + except (json.JSONDecodeError, ValueError, AttributeError): + return {}, None + if not isinstance(envelope, dict): + return {}, None + if isinstance(envelope.get("payload"), dict): + fields = envelope.get("fields") + asked = ( + [i for i in fields if i] + if isinstance(fields, list) and all(isinstance(i, str) for i in fields) + else None + ) + return envelope["payload"], asked + return envelope, None diff --git a/backend/components/kirche/bauvorhaben_form.py b/backend/components/kirche/bauvorhaben_form.py new file mode 100644 index 0000000..026277a --- /dev/null +++ b/backend/components/kirche/bauvorhaben_form.py @@ -0,0 +1,202 @@ +"""Bauvorhaben form component for kirche-einfach flows. + +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 +(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` +from the ``lfx.io`` input definitions below — one JSON-Schema property +per field, keyed by ``name``: + +* ``display_name`` → ``title``, ``info`` → ``description`` + (``[visible_if ...]`` markers declare conditional branches, see below), + ``value`` → ``default``, ``options`` → ``enum`` +* ``required`` → required array (branch-required applies only while visible) + +``collect()`` is fully dynamic: it emits every input except the plumbing +one (``form_values``), so fields can be renamed, added, or removed in +the Langflow code editor without touching this method. + +When the run envelope names the asked fields (``fields`` list, sent by +the API from the version's form schema), defaults are emitted only for +asked AND currently visible fields. Without the list (raw dict, +Langflow playground), all visible declared defaults apply. + +``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``. +""" + +from __future__ import annotations + +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 +from lfx.io import ( + BoolInput, + DropdownInput, + IntInput, + MessageTextInput, + MultiselectInput, + Output, + StrInput, +) +from lfx.schema.data import Data + +from kirche._form_core import ( + PLUMBING_INPUTS, + _field_conditions, + _json_safe, + collect_form_data, + parse_envelope, +) + +# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node() +# (LFX executes embedded source without the kirche package) --> + + +class CustomInputBauvorhaben(Component): + display_name = "Bauvorhaben Form" + description = "Collects Bauvorhaben form values into a single JSON object." + icon = "Church" + + inputs: ClassVar[list] = [ + # Plumbing, not a form field: the API posts submitted web-form + # values as the run envelope (same {"payload": {...}} shape the + # other kirche components use); they arrive here through a ChatInput + # entry node and override the defaults below. Hidden in the GUI and + # skipped by the form parser (show=False). + MessageTextInput( + name="form_values", + display_name="Form values (JSON envelope)", + info="Submitted values as JSON; merged over the defaults", + value="", + show=False, + ), + DropdownInput( + name="gebaeude", + display_name="Welches Gebäude soll verändert werden?", + info="", + options=["Kirche", "Anderes Gebäude"], + value="", + required=True, + ), + MultiselectInput( + name="bestandteil", + display_name="Welcher Bestandteil der Kirche?", + info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]", + options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"], + value=[], + required=True, + ), + DropdownInput( + name="glockenmassnahme", + display_name="Welche Maßnahme an der Glocke?", + info="[visible_if bestandteil=Glocke]", + options=["Reinigung", "Reparatur", "Neuguss"], + value="Reinigung", + ), + IntInput( + name="gussjahr", + display_name="Gussjahr (nur bei Neuguss)?", + info="[visible_if glockenmassnahme=Neuguss]", + value=0, + ), + DropdownInput( + name="gebaeudeart", + display_name="Art des anderen Gebäudes?", + info='[visible_if gebaeude="Anderes Gebäude"]', + options=["Wohnhaus", "Scheune", "Sonstiges"], + value="Wohnhaus", + ), + BoolInput( + name="denkmalschutz", + display_name="Steht das Gebäude unter Denkmalschutz?", + info="", + value=False, + ), + StrInput( + name="denkmalnummer", + display_name="Aktenzeichen Denkmalschutz?", + info="[visible_if denkmalschutz=true]", + value="", + required=True, + ), + ] + outputs: ClassVar[list] = [ + Output(display_name="Form Data", name="form_data", method="collect"), + ] + + def _field_names(self) -> list: + """Names of all form fields (every input except the plumbing ones).""" + inputs = getattr(self, "_inputs", None) + if isinstance(inputs, dict) and inputs: + return [name for name in inputs if name not in PLUMBING_INPUTS] + seen = [] + declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or [] + for inp in declared: + name = getattr(inp, "name", None) + if name and name not in PLUMBING_INPUTS and name not in seen: + seen.append(name) + return seen + + def _field_value(self, name: str): + """Runtime value of one input. + + Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would + otherwise resolve to the component attribute instead of its value. + """ + inputs = getattr(self, "_inputs", None) + if isinstance(inputs, dict) and name in inputs: + return getattr(inputs[name], "value", None) + try: + return getattr(self, name, None) + except (AttributeError, ValueError): + return None + + def _is_visible(self, name: str, effective: dict) -> bool: + """Whether a field's branch conditions hold. + + Single pass over effective values (submitted first, code defaults + after): no recursion, so trigger cycles cannot hang — a stale or + cyclic marker simply matches or not. Unknown triggers never match. + """ + inputs = getattr(self, "_inputs", None) + info = "" + structured = None + if isinstance(inputs, dict) and name in inputs: + info = getattr(inputs[name], "info", "") or "" + 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. + continue + actual = effective[trigger] + if isinstance(actual, list): + if expected not in actual: + return False + elif actual != expected: + return False + return True + + def collect(self) -> Data: + payload, asked = parse_envelope(self.form_values) + # Effective trigger values: submitted first, code defaults after. + declared = self._field_names() + 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). + 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 + # revision does not declare. + for key, val in payload.items(): + if isinstance(key, str) and key: + data[key] = _json_safe(val) + return Data(data=data) diff --git a/backend/components/kirche/form_inputs.py b/backend/components/kirche/form_inputs.py index 320de7b..4b228ac 100644 --- a/backend/components/kirche/form_inputs.py +++ b/backend/components/kirche/form_inputs.py @@ -11,13 +11,31 @@ keyed by ``name``. Field metadata maps as follows: * ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint * ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``) +``collect()`` is fully dynamic: it emits every input except the plumbing +ones (``form_values``, ``code``), so fields can be renamed, added, or +removed in the Langflow code editor without touching this method. +``name`` is the stable JSON key (freely renamable, must stay unique and +non-empty); downstream components read the keys as-is. + +When the run envelope names the asked fields (``fields`` list, sent by +the API from the version's form schema), defaults are emitted only for +asked fields — the template may declare fields unknown to this code and +unasked code defaults stay out of the output. Without the list (raw dict, +Langflow playground), all declared defaults apply. + +``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the +values travel as a real JSON object. A terminal ``ChatOutput`` renders it +as a fenced ```json block; components consuming it should read ``.data`` +(or ``get_text()``), not ``.text``. + ``FileInput`` is a stub for now: it carries a string reference only, real upload comes later. """ from __future__ import annotations -import json +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 @@ -36,20 +54,23 @@ from lfx.io import ( SliderInput, StrInput, ) -from lfx.schema.message import Message +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) +# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node() +# (LFX executes embedded source without the kirche package) --> class CustomInputForm(Component): display_name = "CustomInput Form" - description = "Collects structured form values into a single JSON message." + description = "Collects structured form values into a single JSON object." icon = "ClipboardList" inputs: ClassVar[list] = [ @@ -134,29 +155,73 @@ class CustomInputForm(Component): Output(display_name="Form Data", name="form_data", method="collect"), ] - def collect(self) -> Message: - data = { - "full_name": _text(self.full_name), - "notes": _text(self.notes), - "password": _text(self.password), - "age": self.age, - "amount": self.amount, - "newsletter": self.newsletter, - "department": _text(self.department), - "topics": self.topics, - "satisfaction": self.satisfaction, - "attachment": _text(self.attachment), - } - # Submitted values (via form_values) win over the field defaults. - # Accepts the raw dict or the run envelope {"payload": {...}}. + def _field_names(self) -> list: + """Names of all form fields (every input except the plumbing ones).""" + inputs = getattr(self, "_inputs", None) + if isinstance(inputs, dict) and inputs: + return [name for name in inputs if name not in PLUMBING_INPUTS] + seen = [] + declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or [] + for inp in declared: + name = getattr(inp, "name", None) + if name and name not in PLUMBING_INPUTS and name not in seen: + seen.append(name) + return seen + + def _field_value(self, name: str): + """Runtime value of one input. + + Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would + otherwise resolve to the component attribute instead of its value. + """ + inputs = getattr(self, "_inputs", None) + if isinstance(inputs, dict) and name in inputs: + return getattr(inputs[name], "value", None) try: - override = json.loads(_text(self.form_values) or "{}") - except json.JSONDecodeError: - override = {} - if isinstance(override, dict): - if isinstance(override.get("payload"), dict): - override = override["payload"] - for key in data: - if key in override: - data[key] = override[key] - return Message(text=json.dumps(data, ensure_ascii=False)) + return getattr(self, name, None) + except (AttributeError, ValueError): + return None + + def _is_visible(self, name: str, effective: dict) -> bool: + """Whether a field's branch conditions hold. + + Single pass over effective values (submitted first, code defaults + after): no recursion, so trigger cycles cannot hang — a stale or + cyclic marker simply matches or not. Unknown triggers never match. + """ + inputs = getattr(self, "_inputs", None) + info = "" + structured = None + if isinstance(inputs, dict) and name in inputs: + info = getattr(inputs[name], "info", "") or "" + 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. + continue + actual = effective[trigger] + if isinstance(actual, list): + if expected not in actual: + return False + elif actual != expected: + return False + return True + + def collect(self) -> Data: + payload, asked = parse_envelope(self.form_values) + # Effective trigger values: submitted first, code defaults after. + declared = self._field_names() + 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). + 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 + # revision does not declare. + for key, val in payload.items(): + if isinstance(key, str) and key: + data[key] = _json_safe(val) + return Data(data=data) diff --git a/backend/components/kirche/mitarbeiter_form.py b/backend/components/kirche/mitarbeiter_form.py new file mode 100644 index 0000000..5d215f5 --- /dev/null +++ b/backend/components/kirche/mitarbeiter_form.py @@ -0,0 +1,179 @@ +"""Mitarbeiter-einstellen form component for kirche-einfach flows. + +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 +fields of its web form. The API reads the exported node template +(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` +from the ``lfx.io`` input definitions below — one JSON-Schema property +per field, keyed by ``name``: + +* ``display_name`` → ``title``, ``info`` → ``description`` + (``[visible_if ...]`` markers declare conditional branches), + ``value`` → ``default``, ``options`` → ``enum`` +* ``required`` → required array (branch-required applies only while visible) + +``collect()`` is fully dynamic: it emits every input except the plumbing +one (``form_values``), so fields can be renamed, added, or removed in +the Langflow code editor without touching this method. + +When the run envelope names the asked fields (``fields`` list, sent by +the API from the version's form schema), defaults are emitted only for +asked AND currently visible fields. Without the list (raw dict, +Langflow playground), all visible declared defaults apply. + +``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``. +""" + +from __future__ import annotations + +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 +from lfx.io import ( + DropdownInput, + MessageTextInput, + Output, + StrInput, +) +from lfx.schema.data import Data + +from kirche._form_core import ( + PLUMBING_INPUTS, + _field_conditions, + _json_safe, + collect_form_data, + parse_envelope, +) + +# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node() +# (LFX executes embedded source without the kirche package) --> + + +class CustomInputMitarbeiter(Component): + display_name = "Mitarbeiter Einstellen Form" + description = "Collects hiring form values into a single JSON object." + icon = "UserPlus" + + inputs: ClassVar[list] = [ + # Plumbing, not a form field: the API posts submitted web-form + # values as the run envelope (same {"payload": {...}} shape the + # other kirche components use); they arrive here through a ChatInput + # entry node and override the defaults below. Hidden in the GUI and + # skipped by the form parser (show=False). + MessageTextInput( + name="form_values", + display_name="Form values (JSON envelope)", + info="Submitted values as JSON; merged over the defaults", + value="", + show=False, + ), + DropdownInput( + name="rolle", + display_name="Rolle", + info="", + options=["Assistenz", "Pfarrer:in", "Vikar:in", "Messner:in"], + value="Assistenz", + required=True, + ), + DropdownInput( + name="einstufung", + display_name="Einstufung", + info="Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]", + options=["Stufe 1", "Stufe 2", "Stufe 3"], + value="Stufe 1", + ), + StrInput( + name="mitarbeiter_name", + display_name="Name", + info="Vor- und Nachname des Mitarbeiters", + value="", + required=True, + ), + StrInput( + name="geburtsdatum", + display_name="Geburtsdatum", + info="Datum eingeben (z. B. JJJJ-MM-TT)", + value="", + ), + ] + outputs: ClassVar[list] = [ + Output(display_name="Form Data", name="form_data", method="collect"), + ] + + def _field_names(self) -> list: + """Names of all form fields (every input except the plumbing ones).""" + inputs = getattr(self, "_inputs", None) + if isinstance(inputs, dict) and inputs: + return [name for name in inputs if name not in PLUMBING_INPUTS] + seen = [] + declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or [] + for inp in declared: + name = getattr(inp, "name", None) + if name and name not in PLUMBING_INPUTS and name not in seen: + seen.append(name) + return seen + + def _field_value(self, name: str): + """Runtime value of one input. + + Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would + otherwise resolve to the component attribute instead of its value. + """ + inputs = getattr(self, "_inputs", None) + if isinstance(inputs, dict) and name in inputs: + return getattr(inputs[name], "value", None) + try: + return getattr(self, name, None) + except (AttributeError, ValueError): + return None + + def _is_visible(self, name: str, effective: dict) -> bool: + """Whether a field's branch conditions hold. + + Single pass over effective values (submitted first, code defaults + after): no recursion, so trigger cycles cannot hang — a stale or + cyclic marker simply matches or not. Unknown triggers never match. + """ + inputs = getattr(self, "_inputs", None) + info = "" + structured = None + if isinstance(inputs, dict) and name in inputs: + info = getattr(inputs[name], "info", "") or "" + 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. + continue + actual = effective[trigger] + if isinstance(actual, list): + if expected not in actual: + return False + elif actual != expected: + return False + return True + + def collect(self) -> Data: + payload, asked = parse_envelope(self.form_values) + # Effective trigger values: submitted first, code defaults after. + declared = self._field_names() + 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). + 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 + # revision does not declare. + for key, val in payload.items(): + if isinstance(key, str) and key: + data[key] = _json_safe(val) + return Data(data=data) diff --git a/backend/flows/.flowsignore b/backend/flows/.flowsignore new file mode 100644 index 0000000..1201608 --- /dev/null +++ b/backend/flows/.flowsignore @@ -0,0 +1,3 @@ +# Flows excluded from the pipeline (sync, deploy, validation, frontend). +# One filename stem per line; these stay Langflow-only experiments. +i_love_this_app 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 new file mode 100644 index 0000000..4a093a8 --- /dev/null +++ b/backend/flows/bauvorhaben.json @@ -0,0 +1,921 @@ +{ + "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": { + "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" + }, + "gebaeude": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Kirche", + "Anderes Gebäude" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "gebaeude", + "value": " ", + "display_name": "Welches Gebäude soll verändert werden?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "bestandteil": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Glocke", + "Orgel", + "Innenraum", + "Fenster", + "Fassade" + ], + "combobox": false, + "toggle": false, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "bestandteil", + "value": [ + " " + ], + "display_name": "Welcher Bestandteil der Kirche?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Mehrfachauswahl möglich [visible_if gebaeude=Kirche]", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MultiselectInput" + }, + "glockenmassnahme": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Reinigung", + "Reparatur", + "Neuguss" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "glockenmassnahme", + "value": "Reinigung", + "display_name": "Welche Maßnahme an der Glocke?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if bestandteil=Glocke]", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "gussjahr": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "gussjahr", + "value": 0, + "display_name": "Gussjahr (nur bei Neuguss)?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if glockenmassnahme=Neuguss]", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "gebaeudeart": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Wohnhaus", + "Scheune", + "Sonstiges" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "gebaeudeart", + "value": "Wohnhaus", + "display_name": "Art des anderen Gebäudes?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if gebaeude=\"Anderes Gebäude\"]", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "denkmalschutz": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "denkmalschutz", + "value": false, + "display_name": "Steht das Gebäude unter Denkmalschutz?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "denkmalnummer": { + "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": "denkmalnummer", + "value": " ", + "display_name": "Aktenzeichen Denkmalschutz?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if denkmalschutz=true]", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "_type": "Component", + "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": "\"\"\"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.", + "icon": "Church", + "base_classes": [], + "display_name": "Bauvorhaben 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": "CustomInputBauvorhaben", + "id": "CustomInputBauvorhaben-bauvorhaben", + "showNode": true + }, + "id": "CustomInputBauvorhaben-bauvorhaben", + "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, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputBauvorhaben-bauvorhaben", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputBauvorhaben-bauvorhaben", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputBauvorhaben", + "id": "CustomInputBauvorhaben-bauvorhaben", + "name": "form_data", + "output_types": [ + "JSON" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-collected", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomInputBauvorhabenœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputBauvorhaben-bauvorhaben", + "sourceHandle": "{œdataTypeœ: œCustomInputBauvorhabenœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}", + "target": "ChatOutput-collected", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Bauvorhaben: verzweigtes Formular mit mehrstufigen Abhängigkeiten.", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "e3c345da-f97c-5767-983b-afdc44296f99", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "bauvorhaben", + "tags": [], + "webhook": false +} diff --git a/backend/flows/intake_form.json b/backend/flows/intake_form.json index 3c64649..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, @@ -38,7 +947,7 @@ "id": "CustomInputForm-intake", "name": "form_data", "output_types": [ - "Message" + "JSON" ] }, "targetHandle": { @@ -54,923 +963,14 @@ "type": "other" } }, - "id": "xy-edge__{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "id": "xy-edge__{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", "selected": false, "source": "CustomInputForm-intake", - "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}", + "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}", "target": "ChatOutput-collected", "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 message.", - "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": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "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``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\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.message import Message\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\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs = [\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 = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except Exception:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\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 new file mode 100644 index 0000000..9bd4176 --- /dev/null +++ b/backend/flows/mitarbeiter_einstellen.json @@ -0,0 +1,991 @@ +{ + "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": { + "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" + }, + "rolle": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Assistenz", + "Pfarrer:in", + "Vikar:in", + "Messner:in" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "rolle", + "value": "Assistenz", + "display_name": "Rolle", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "einstufung": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Stufe 1", + "Stufe 2", + "Stufe 3" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "einstufung", + "value": "Stufe 1", + "display_name": "Einstufung", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "mitarbeiter_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": "mitarbeiter_name", + "value": " ", + "display_name": "Name", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Vor- und Nachname des Mitarbeiters", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "geburtsdatum": { + "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": "geburtsdatum", + "value": "", + "display_name": "Geburtsdatum", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Datum eingeben (z. B. JJJJ-MM-TT)", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "_type": "Component", + "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": "\"\"\"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.", + "icon": "UserPlus", + "base_classes": [], + "display_name": "Mitarbeiter Einstellen 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": "CustomInputMitarbeiter", + "id": "CustomInputMitarbeiter-mitarbeiter", + "showNode": true + }, + "id": "CustomInputMitarbeiter-mitarbeiter", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_data": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_data", + "value": "", + "display_name": "Employee Data", + "advanced": false, + "api_editable": false, + "input_types": [ + "JSON", + "Data", + "Message", + "DataFrame", + "Table" + ], + "dynamic": false, + "info": "Employee data as JSON/Data/Message/DataFrame. Must contain 'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.custom import Component\nfrom lfx.io import HandleInput, Output\nfrom lfx.schema import Data, Message\n\ntry:\n from lfx.schema import DataFrame\nexcept Exception: # pragma: no cover\n DataFrame = None\n\nimport json\n\n\nclass GehaltsrechnerComponent(Component):\n display_name = \"Gehaltsrechner\"\n description = (\n \"Calculates the annual salary (Gehalt) for church employees based on their role \"\n \"(rolle) and level (einstufung). Use this when you need to determine the yearly \"\n \"salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in.\"\n )\n icon = \"Calculator\"\n\n inputs = [\n HandleInput(\n name=\"input_data\",\n display_name=\"Employee Data\",\n input_types=[\"JSON\", \"Data\", \"Message\", \"DataFrame\", \"Table\"],\n info=(\n \"Employee data as JSON/Data/Message/DataFrame. Must contain \"\n \"'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'.\"\n ),\n ),\n ]\n\n outputs = [\n Output(\n name=\"salary_text\",\n display_name=\"Salary Text\",\n method=\"calculate_salary\",\n ),\n ]\n\n # Salary mapping for simple roles\n BASE_SALARY = {\n \"Assistenz\": 42000,\n \"Vikar:in\": 48000,\n \"Messner:in\": 36000,\n }\n\n # Salary mapping for Pfarrer:in per Einstufung\n PFARRER_SALARY = {\n \"Stufe 1\": 55000,\n \"Stufe 2\": 65000,\n \"Stufe 3\": 75000,\n }\n\n def _extract_data(self, value) -> dict | None:\n '''Normalize the incoming value into a plain dict.'''\n if isinstance(value, dict):\n return value\n if isinstance(value, Data):\n return value.data if isinstance(value.data, dict) else None\n if isinstance(value, Message):\n value = value.text\n if DataFrame is not None and isinstance(value, DataFrame):\n try:\n records = value.to_dict(orient=\"records\")\n if records:\n return records[0]\n except Exception:\n return None\n return None\n if isinstance(value, str):\n text = value.strip()\n try:\n parsed = json.loads(text)\n if isinstance(parsed, dict):\n return parsed\n if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):\n return parsed[0]\n except (json.JSONDecodeError, TypeError, ValueError):\n return None\n return None\n\n def calculate_salary(self) -> Message:\n try:\n data = self._extract_data(self.input_data)\n if not isinstance(data, dict):\n return Message(\n text=\"Error: Could not parse employee data. Please provide a valid JSON object.\"\n )\n rolle = data.get(\"rolle\")\n einstufung = data.get(\"einstufung\")\n name = data.get(\"mitarbeiter_name\", \"unknown\")\n if not rolle:\n return Message(\n text=\"Error: Missing required field 'rolle' in employee data.\"\n )\n if rolle in self.BASE_SALARY:\n salary = self.BASE_SALARY[rolle]\n elif rolle == \"Pfarrer:in\":\n if einstufung not in self.PFARRER_SALARY:\n return Message(\n text=(\n \"Error: For role 'Pfarrer:in', a valid 'einstufung' \"\n \"(Stufe 1, Stufe 2 or Stufe 3) is required.\"\n )\n )\n salary = self.PFARRER_SALARY[einstufung]\n else:\n return Message(text=f\"Error: Unknown role '{rolle}'.\")\n einstufung_display = einstufung if einstufung else \"-\"\n return Message(\n text=(\n f\"Mitarbeiter:{name} | Rolle:{rolle} | \"\n f\"Einstufung:{einstufung_display} | Gehalt:{salary} EUR pro Jahr\"\n )\n )\n except Exception as exc: # never raise, always return a clear error string\n return Message(text=f\"Error: {exc}\")\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + } + }, + "description": "Calculates the annual salary (Gehalt) for church employees based on their role (rolle) and level (einstufung). Use this when you need to determine the yearly salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in.", + "icon": "Calculator", + "base_classes": [ + "Message" + ], + "display_name": "Gehaltsrechner", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "salary_text", + "display_name": "Salary Text", + "method": "calculate_salary", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [ + "input_data" + ], + "beta": false, + "legacy": false, + "edited": false, + "metadata": { + "module": "custom_components.gehaltsrechner", + "code_hash": "31079e5045cc", + "dependencies": { + "total_dependencies": 1, + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ] + } + }, + "tool_mode": false + }, + "type": "CustomComponent", + "id": "Gehaltsrechner-gehalt", + "showNode": true + }, + "id": "Gehaltsrechner-gehalt", + "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-collected", + "showNode": true + }, + "id": "ChatOutput-collected", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 1200, + "y": 200 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputMitarbeiter-mitarbeiter", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputMitarbeiter-mitarbeiter", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputMitarbeiter", + "id": "CustomInputMitarbeiter-mitarbeiter", + "name": "form_data", + "output_types": [ + "JSON" + ] + }, + "targetHandle": { + "fieldName": "input_data", + "id": "Gehaltsrechner-gehalt", + "inputTypes": [ + "JSON", + "Data", + "Message", + "DataFrame", + "Table" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomInputMitarbeiterœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_dataœ, œidœ: œGehaltsrechner-gehaltœ, œinputTypesœ: [œJSONœ, œDataœ, œMessageœ, œDataFrameœ, œTableœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputMitarbeiter-mitarbeiter", + "sourceHandle": "{œdataTypeœ: œCustomInputMitarbeiterœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}", + "target": "Gehaltsrechner-gehalt", + "targetHandle": "{œfieldNameœ: œinput_dataœ, œidœ: œGehaltsrechner-gehaltœ, œinputTypesœ: [œJSONœ, œDataœ, œMessageœ, œDataFrameœ, œTableœ], œtypeœ: œotherœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "Gehaltsrechner-gehalt", + "name": "salary_text", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-collected", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œGehaltsrechner-gehaltœ, œnameœ: œsalary_textœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "Gehaltsrechner-gehalt", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œGehaltsrechner-gehaltœ, œnameœ: œsalary_textœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-collected", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Mitarbeiter einstellen: Formular mit Gehaltsrechner.", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "b2076300-4ba0-5907-9633-c92b97237c35", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "mitarbeiter_einstellen", + "tags": [], + "webhook": false +} 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/flows/test_flow.json b/backend/flows/test_flow.json deleted file mode 100644 index d11759b..0000000 --- a/backend/flows/test_flow.json +++ /dev/null @@ -1,747 +0,0 @@ -{ - "data": { - "edges": [ - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomInputForm", - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "name": "form_data", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-HkK3i", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__ext:kirche:CustomInputForm@extra-BhL6Q{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-HkK3i{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}", - "selected": false, - "source": "ext:kirche:CustomInputForm@extra-BhL6Q", - "sourceHandle": "{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}", - "target": "ChatOutput-HkK3i", - "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}" - } - ], - "nodes": [ - { - "data": { - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "node": { - "base_classes": [ - "Message" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Collects structured form values into a single JSON message.", - "display_name": "CustomInput Form", - "documentation": "", - "edited": false, - "field_order": [ - "form_values", - "full_name", - "notes", - "password", - "age", - "amount", - "newsletter", - "department", - "topics", - "satisfaction", - "attachment" - ], - "frozen": false, - "icon": "ClipboardList", - "legacy": false, - "lf_version": "1.12.0", - "metadata": { - "code_hash": "6d186fa88d37", - "dependencies": { - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - } - ], - "total_dependencies": 1 - }, - "module": "custom_components.custominput_form" - }, - "minimized": false, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Form Data", - "group_outputs": false, - "loop_types": null, - "method": "collect", - "name": "form_data", - "options": null, - "required_inputs": null, - "selected": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "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 - }, - "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": [], - "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": "\"\"\"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``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.message import Message\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\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\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 collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except json.JSONDecodeError:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\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": "Youth" - }, - "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": "Hi there" - }, - "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": "Blabla" - }, - "password": { - "_input_type": "SecretStrInput", - "advanced": false, - "api_editable": false, - "display_name": "Password", - "dynamic": false, - "info": "Optional access phrase", - "input_types": [], - "load_from_db": false, - "name": "password", - "override_skip": false, - "password": true, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "value": "hidden" - }, - "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, - "min": 1, - "step": 1, - "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": [ - "Outreach", - "Courses" - ] - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "CustomInputForm" - }, - "dragging": false, - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "measured": { - "height": 933, - "width": 320 - }, - "position": { - "x": -2869.215421770588, - "y": -259.29504008742293 - }, - "selected": true, - "type": "genericNode" - }, - { - "data": { - "id": "ChatOutput-HkK3i", - "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": [ - "input_value", - "should_store_message", - "sender", - "sender_name", - "session_id", - "context_id", - "data_template", - "clean_data" - ], - "frozen": false, - "icon": "MessagesSquare", - "legacy": false, - "lf_version": "1.12.0", - "metadata": { - "code_hash": "84009527d08c", - "dependencies": { - "dependencies": [ - { - "name": "orjson", - "version": "3.11.9" - }, - { - "name": "fastapi", - "version": "0.139.2" - }, - { - "name": "lfx", - "version": null - } - ], - "total_dependencies": 3 - }, - "module": "lfx.components.input_output.chat_output.ChatOutput" - }, - "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-HkK3i", - "measured": { - "height": 168, - "width": 320 - }, - "position": { - "x": -2181.727435009515, - "y": 144.80068578973714 - }, - "selected": false, - "type": "genericNode" - } - ], - "viewport": { - "x": 1755.3131372405762, - "y": 167.2405536194492, - "zoom": 0.5755627009646302 - } - }, - "description": "The Pinnacle of Prompt Generation.", - "endpoint_name": null, - "id": "fd440bee-f669-44a2-aa12-6af3bc299731", - "is_component": false, - "name": "test_flow", - "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/migrations/versions/e8f3a1c2b4d6_input_schema_not_null.py b/backend/migrations/versions/e8f3a1c2b4d6_input_schema_not_null.py new file mode 100644 index 0000000..d0ccf88 --- /dev/null +++ b/backend/migrations/versions/e8f3a1c2b4d6_input_schema_not_null.py @@ -0,0 +1,65 @@ +"""Backfill + require FlowVersion.input_schema. + +Revision ID: e8f3a1c2b4d6 +Revises: c4d8f2a1e6b9 +Create Date: 2026-09-30 00:00:00.000000 + +Pre-Phase-3 rows have input_schema NULL. Backfill them from the stored +langflow_json (same parser the write choke point uses); unparseable rows +get the textarea fallback so reads never crash. Then enforce NOT NULL. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e8f3a1c2b4d6" +down_revision: str | Sequence[str] | None = "c4d8f2a1e6b9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_FALLBACK = { + "json_schema": {"type": "object", "properties": {}}, + "ui_schema": {}, + "fallback": "textarea", +} + + +def _derive(langflow_json) -> dict: + import json as stdlib_json + + try: + from app.flows import input_schema as parser + + if isinstance(langflow_json, str): + # SQLite stores JSON columns as TEXT; Postgres returns dict. + langflow_json = stdlib_json.loads(langflow_json) + return parser.extract_form_schema(langflow_json) + except Exception: # noqa: BLE001 -- backfill must never fail the migration + return dict(_FALLBACK) + + +def upgrade() -> None: + """Upgrade schema.""" + import json as stdlib_json + + connection = op.get_bind() + rows = connection.execute( + sa.text("SELECT id, langflow_json FROM flow_versions WHERE input_schema IS NULL") + ).all() + for row_id, langflow_json in rows: + schema = _derive(langflow_json) + connection.execute( + sa.text("UPDATE flow_versions SET input_schema = :schema WHERE id = :id"), + {"schema": stdlib_json.dumps(schema), "id": str(row_id)}, + ) + with op.batch_alter_table("flow_versions") as batch: + batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=False) + + +def downgrade() -> None: + """Downgrade schema (data kept, nullability restored).""" + with op.batch_alter_table("flow_versions") as batch: + batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=True) 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..2425f5d 100644 --- a/backend/scripts/check_prod.py +++ b/backend/scripts/check_prod.py @@ -11,10 +11,10 @@ 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 +57,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,14 +88,12 @@ 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")) -> None: + """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 + raise typer.Exit(1) failures, warnings = check(parse_env(path)) for message in warnings: print(f"WARN: {message}") @@ -102,10 +101,9 @@ def main() -> int: print(f"FAIL: {message}") if failures: print(f"{len(failures)} blocking issue(s) — fix before deploying") - return 1 + raise typer.Exit(1) print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else "")) - return 0 if __name__ == "__main__": - sys.exit(main()) + typer.run(main) diff --git a/backend/scripts/deploy_flow.py b/backend/scripts/deploy_flow.py index bcfa3ce..db5c88c 100644 --- a/backend/scripts/deploy_flow.py +++ b/backend/scripts/deploy_flow.py @@ -7,48 +7,46 @@ Usage: from __future__ import annotations -import argparse import json -import sys -import urllib.request + +import httpx +import typer def post(api: str, path: str, body: dict) -> dict: - request = urllib.request.Request( - api.rstrip("/") + path, - data=json.dumps(body).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) try: - with urllib.request.urlopen(request) as response: - return {"status": response.status, "body": json.loads(response.read())} - except urllib.error.HTTPError as exc: - return {"status": exc.code, "body": exc.read().decode()[:2000]} + response = httpx.post(api.rstrip("/") + path, json=body, timeout=60.0) + try: + payload = response.json() + except ValueError: + payload = response.text[:2000] + return {"status": response.status_code, "body": payload} + except httpx.HTTPError as exc: + 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"), +) -> None: + """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)) - return 0 if result["status"] in (200, 201) else 1 + if result["status"] not in (200, 201): + raise typer.Exit(1) if __name__ == "__main__": - sys.exit(main()) + typer.run(main) diff --git a/backend/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py index 49e309d..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 @@ -91,6 +159,8 @@ def kirche_node( node_id: str, values: dict | None = None, position: tuple[int, int] = (0, 0), + component_cls=None, + code_override: str | None = None, ) -> dict: """A node from our GUI-editable category lib (backend/components/kirche). @@ -101,15 +171,22 @@ def kirche_node( the same way). The template dict before that round-trip is complete and faithful, so we use it directly and append the ``code`` field by hand (mirroring the shape ``build_custom_component_template`` produces). + + ``component_cls`` builds a node from a variant class (e.g. a flow whose + fields differ from ``CustomInputForm``); ``code_override`` embeds that + variant's source (``set_class_code`` cannot inspect exec-built classes). """ sys.path.insert(0, str(REPO_ROOT / "backend" / "components")) - from kirche.form_inputs import CustomInputForm + if component_cls is None: + from kirche.form_inputs import CustomInputForm as component_cls from lfx.template.frontend_node.custom_components import ( ComponentFrontendNode, ) - comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm" - inst = CustomInputForm() + comp_name = getattr(component_cls, "name", None) or component_cls.__name__ + inst = component_cls() + if code_override is not None: + 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) @@ -117,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, @@ -147,14 +228,18 @@ def kirche_node( # form_values envelope (collect() merges), never via edges — so required # template fields carry a single-space sentinel value. The form parser # (app/flows/input_schema.py) treats whitespace-only values as "no - # default", keeping the web form clean. + # default", keeping the web form clean. Multiselect needs a list to + # survive runtime assignment, so it gets [" "] (skipped the same way + # via the enum-mismatch rule). for field_def in node_dict["data"]["node"]["template"].values(): if ( isinstance(field_def, dict) and field_def.get("required") and field_def.get("value") in (None, "", [], {}) ): - field_def["value"] = " " + field_def["value"] = ( + [" "] if field_def.get("_input_type") == "MultiselectInput" else " " + ) return node_dict @@ -406,6 +491,128 @@ class ApprovalCallbackComponent(Component): """ +GEHALT_CODE = """from lfx.custom import Component +from lfx.io import HandleInput, Output +from lfx.schema import Data, Message + +try: + from lfx.schema import DataFrame +except Exception: # pragma: no cover + DataFrame = None + +import json + + +class GehaltsrechnerComponent(Component): + display_name = "Gehaltsrechner" + description = ( + "Calculates the annual salary (Gehalt) for church employees based on their role " + "(rolle) and level (einstufung). Use this when you need to determine the yearly " + "salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in." + ) + icon = "Calculator" + + inputs = [ + HandleInput( + name="input_data", + display_name="Employee Data", + input_types=["JSON", "Data", "Message", "DataFrame", "Table"], + info=( + "Employee data as JSON/Data/Message/DataFrame. Must contain " + "'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'." + ), + ), + ] + + outputs = [ + Output( + name="salary_text", + display_name="Salary Text", + method="calculate_salary", + ), + ] + + # Salary mapping for simple roles + BASE_SALARY = { + "Assistenz": 42000, + "Vikar:in": 48000, + "Messner:in": 36000, + } + + # Salary mapping for Pfarrer:in per Einstufung + PFARRER_SALARY = { + "Stufe 1": 55000, + "Stufe 2": 65000, + "Stufe 3": 75000, + } + + def _extract_data(self, value) -> dict | None: + '''Normalize the incoming value into a plain dict.''' + if isinstance(value, dict): + return value + if isinstance(value, Data): + return value.data if isinstance(value.data, dict) else None + if isinstance(value, Message): + value = value.text + if DataFrame is not None and isinstance(value, DataFrame): + try: + records = value.to_dict(orient="records") + if records: + return records[0] + except Exception: + return None + return None + if isinstance(value, str): + text = value.strip() + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict): + return parsed[0] + except (json.JSONDecodeError, TypeError, ValueError): + return None + return None + + def calculate_salary(self) -> Message: + try: + data = self._extract_data(self.input_data) + if not isinstance(data, dict): + return Message( + text="Error: Could not parse employee data. Please provide a valid JSON object." + ) + rolle = data.get("rolle") + einstufung = data.get("einstufung") + name = data.get("mitarbeiter_name", "unknown") + if not rolle: + return Message( + text="Error: Missing required field 'rolle' in employee data." + ) + if rolle in self.BASE_SALARY: + salary = self.BASE_SALARY[rolle] + elif rolle == "Pfarrer:in": + if einstufung not in self.PFARRER_SALARY: + return Message( + text=( + "Error: For role 'Pfarrer:in', a valid 'einstufung' " + "(Stufe 1, Stufe 2 or Stufe 3) is required." + ) + ) + salary = self.PFARRER_SALARY[einstufung] + else: + return Message(text=f"Error: Unknown role '{rolle}'.") + einstufung_display = einstufung if einstufung else "-" + return Message( + text=( + f"Mitarbeiter:{name} | Rolle:{rolle} | " + f"Einstufung:{einstufung_display} | Gehalt:{salary} EUR pro Jahr" + ) + ) + except Exception as exc: # never raise, always return a clear error string + return Message(text=f"Error: {exc}") +""" + + # -------------------------------------------------------------------------- # flows # -------------------------------------------------------------------------- @@ -544,6 +751,108 @@ def build_intake_form() -> dict: ) +def build_bauvorhaben() -> dict: + """Branched Bauvorhaben form via the standalone Bauvorhaven component.""" + sys.path.insert(0, str(REPO_ROOT / "backend" / "components")) + from kirche.bauvorhaben_form import CustomInputBauvorhaben + + entry = builtin_node( + "lfx.components.input_output.chat", + "ChatInput", + "ChatInput-entry", + position=(0, 200), + ) + inst = CustomInputBauvorhaben() + if not getattr(inst, "_code", None): + inst.set_class_code() + form = kirche_node( + "CustomInputBauvorhaben-bauvorhaben", + position=(400, 200), + component_cls=CustomInputBauvorhaben, + code_override=inst._code, + ) + # Logical question order: RJSF renders schema properties in insertion + # order and the parser derives ui:order from it (lfx emits alphabetical). + order = [ + "form_values", + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + ] + template = form["data"]["node"]["template"] + form["data"]["node"]["template"] = {k: template[k] for k in order} | { + k: v for k, v in template.items() if k not in order + } + out = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-collected", + position=(800, 200), + ) + nodes = [entry, form, out] + edges = [ + edge(entry, "message", form, "form_values"), + edge(form, "form_data", out, "input_value"), + ] + return flow_doc( + "bauvorhaben", + "Bauvorhaben: verzweigtes Formular mit mehrstufigen Abhängigkeiten.", + nodes, + edges, + ) + + +def build_mitarbeiter() -> dict: + """Hiring form via the standalone Mitarbeiter component + Gehaltsrechner.""" + sys.path.insert(0, str(REPO_ROOT / "backend" / "components")) + from kirche.mitarbeiter_form import CustomInputMitarbeiter + + entry = builtin_node( + "lfx.components.input_output.chat", + "ChatInput", + "ChatInput-entry", + position=(0, 200), + ) + inst = CustomInputMitarbeiter() + if not getattr(inst, "_code", None): + inst.set_class_code() + form = kirche_node( + "CustomInputMitarbeiter-mitarbeiter", + position=(400, 200), + component_cls=CustomInputMitarbeiter, + code_override=inst._code, + ) + # Logical question order (see build_bauvorhaben). + order = ["form_values", "rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"] + template = form["data"]["node"]["template"] + form["data"]["node"]["template"] = {k: template[k] for k in order} | { + k: v for k, v in template.items() if k not in order + } + gehalt = custom_node(GEHALT_CODE, "Gehaltsrechner-gehalt", position=(800, 200)) + out = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-collected", + position=(1200, 200), + ) + nodes = [entry, form, gehalt, out] + edges = [ + edge(entry, "message", form, "form_values"), + edge(form, "form_data", gehalt, "input_data"), + edge(gehalt, "salary_text", out, "input_value"), + ] + return flow_doc( + "mitarbeiter_einstellen", + "Mitarbeiter einstellen: Formular mit Gehaltsrechner.", + nodes, + edges, + ) + + def main() -> None: FIXTURE_DIR.mkdir(parents=True, exist_ok=True) FLOW_DIR.mkdir(parents=True, exist_ok=True) @@ -551,11 +860,16 @@ def main() -> None: "simple_rule.json": build_simple_rule(), "approval_handoff.json": build_approval_handoff(), "intake_form.json": build_intake_form(), + "bauvorhaben.json": build_bauvorhaben(), + "mitarbeiter_einstellen.json": build_mitarbeiter(), } for filename, doc in flows.items(): payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" for target in (FIXTURE_DIR / filename, FLOW_DIR / filename): - target.write_text(payload) + # Explicit encoding + LF: the default locale/newline handling + # corrupts non-ASCII flow code on Windows (cp1252) and dirties + # every line with CRLF. + target.write_text(payload, encoding="utf-8", newline="\n") print(f"wrote {target}") diff --git a/backend/scripts/push_flows_to_langflow.py b/backend/scripts/push_flows_to_langflow.py new file mode 100644 index 0000000..5dbca5d --- /dev/null +++ b/backend/scripts/push_flows_to_langflow.py @@ -0,0 +1,40 @@ +"""Push canonical backend/flows/*.json into Langflow via API (no restart).""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +from app.flows.langflow_client import LangflowClient, LangflowError +from app.settings import settings + + +async def main() -> None: + base = os.environ.get("LANGFLOW_URL", "http://localhost:7860") + key = settings.langflow_api_key or os.environ.get("LANGFLOW_API_KEY", "") + print(f"Langflow: {base} key_len={len(key)}") + client = LangflowClient(base, api_key=key) + try: + flows = await client.list_flows() + print(f"existing: {len(flows)} flows") + for f in flows[:20]: + print(" -", f.get("name"), f.get("id")) + except LangflowError as exc: + print(f"list failed: {exc}") + return + flows_dir = Path(settings.flows_dir) + for path in sorted(flows_dir.glob("*.json")): + if path.name.startswith("."): + continue + doc = json.loads(path.read_text(encoding="utf-8")) + try: + result = await client.upsert_flow(doc) + print(f"pushed {path.name} id={result.get('id', 'ok')}") + except LangflowError as exc: + print(f"push {path.name} failed: {exc}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/smoke_e2e.py b/backend/scripts/smoke_e2e.py index f90b81e..b1220d8 100644 --- a/backend/scripts/smoke_e2e.py +++ b/backend/scripts/smoke_e2e.py @@ -11,52 +11,43 @@ from __future__ import annotations import json import os import shutil -import socket import subprocess import sys import tempfile -import time -import urllib.request from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tests._harness import ( + free_port, + migrate_throwaway, + plant_session_jwt, + spawn_lfx, + wait_healthy, +) + BACKEND = Path(__file__).resolve().parent.parent # Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH. VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin") FIXTURES = BACKEND / "tests" / "fixtures" / "flows" - -def _free_port() -> int: - # Ephemeral ports: the dev API/LFX may already occupy the defaults - # (e.g. when smoke runs inside the api container itself). - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -LFX_PORT = _free_port() -API_PORT = _free_port() +# Ephemeral ports: the dev API/LFX may already occupy the defaults +# (e.g. when smoke runs inside the api container itself). +LFX_PORT = free_port() +API_PORT = free_port() LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs" - -def wait_healthy(url: str, timeout: int = 180) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=3) as r: - if r.status == 200: - return - except OSError: - time.sleep(1) - raise RuntimeError(f"never healthy: {url}") +# Stateless admin session JWT, minted in main() after planting the user. +_SMOKE_JWT = "" def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]: import httpx with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c: - # 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() @@ -72,85 +63,55 @@ def check(name: str, cond: bool, detail: str = "") -> None: def main() -> None: tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-")) - flow_dir = Path("/tmp/smoke-e2e-flows") - flow_dir.mkdir(exist_ok=True) + flow_dir = Path(tempfile.mkdtemp(prefix="kirche-smoke-flows-")) (flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text()) # Migrate a throwaway SQLite schema to head before starting the API. - from alembic import command as alembic_command - from alembic.config import Config - db_url = f"sqlite:///{tmp / 'smoke.db'}" - cfg = Config() - cfg.set_main_option("script_location", str(BACKEND / "migrations")) - cfg.set_main_option("sqlalchemy.url", db_url) - # migrations/env.py takes the URL from settings (not the ini value): - # point this process at the throwaway DB before migrating. - from app.settings import settings as app_settings - - 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 + migrate_throwaway(BACKEND, db_url) + # 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 from sqlmodel import Session, create_engine - from app.models import User, UserSession + 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() + _, _SMOKE_JWT = plant_session_jwt(s, email="smoke@example.com", name="smoke") + # Smoke predates the shared harness key; keep its long serve key. + print("smoke admin JWT minted") engine.dispose() - env = { - **os.environ, - # lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set - # it. Clear it so the serve key below is the one we send. - "LANGFLOW_API_KEY": "", - "LFX_API_KEY": LFX_KEY, + env_extra = { "DATABASE_URL": db_url, "FLOWS_WATCH_ENABLED": "false", "LFX_URL": f"http://127.0.0.1:{LFX_PORT}", "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", "CALLBACK_TOKEN": "smoke-callback-token", } - lfx = subprocess.Popen( - [ - str(VENV_BIN / "lfx"), - "serve", - "--host", - "127.0.0.1", - "--port", - str(LFX_PORT), - "--flow-dir", - str(flow_dir), - str(flow_dir), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=env, - ) + lfx = spawn_lfx(flow_dir, LFX_PORT, env_extra=env_extra, api_key=LFX_KEY) uvicorn = subprocess.Popen( [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)], cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - env=env, + env={ + **os.environ, + "LANGFLOW_API_KEY": "", + "LFX_API_KEY": LFX_KEY, + **env_extra, + }, ) try: - wait_healthy(f"http://127.0.0.1:{LFX_PORT}/health") - wait_healthy(f"http://127.0.0.1:{API_PORT}/health") + wait_healthy(f"http://127.0.0.1:{LFX_PORT}", deadline_s=180) + wait_healthy(f"http://127.0.0.1:{API_PORT}", deadline_s=180) s, flows = api("GET", "/api/flows") check("list flows", s == 200 and isinstance(flows, list)) diff --git a/backend/scripts/sync_flows.py b/backend/scripts/sync_flows.py index 8eaeff1..e2782de 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 @@ -42,8 +42,28 @@ async def run_sync(flows_dir: Path) -> SyncReport: session.close() +async def run_full_pass(flows_dir: Path) -> SyncReport: + """Single orchestration: Langflow pull + file sync (shared with API).""" + reset_engine() + session = Session(get_engine()) + try: + lfx = LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) + langflow = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key) + return await sync.sync_full_pass(session, lfx, langflow, flows_dir) + finally: + session.close() + + async def main_async(args) -> int: flows_dir = Path(args.dir or settings.flows_dir) + if args.pull and args.sync: + try: + report = await run_full_pass(flows_dir) + except FileNotFoundError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + print(report.model_dump_json(indent=2)) + return 1 if report.errors else 0 if args.pull: pulled = await pull_from_langflow(flows_dir) print(json.dumps(pulled, indent=2, ensure_ascii=False)) @@ -60,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 - return asyncio.run(main_async(args)) +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"), +) -> None: + """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})() + raise typer.Exit(asyncio.run(main_async(args))) if __name__ == "__main__": - sys.exit(main()) + typer.run(main) diff --git a/backend/tests/_harness.py b/backend/tests/_harness.py new file mode 100644 index 0000000..19b2458 --- /dev/null +++ b/backend/tests/_harness.py @@ -0,0 +1,116 @@ +"""Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions. + +Single place for logic shared between tests/conftest.py and +scripts/smoke_e2e.py. Full real-LFX runs are kept. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +# 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 free_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def wait_healthy(url: str, deadline_s: float = 240.0) -> None: + deadline = time.time() + deadline_s + while time.time() < deadline: + try: + with urllib.request.urlopen(url + "/health", timeout=3) as response: + if response.status == 200: + return + except OSError: + time.sleep(1) + raise RuntimeError(f"server did not become healthy: {url}") + + +def spawn_lfx( + flow_dir: Path, port: int, env_extra: dict | None = None, api_key: str = TEST_LFX_KEY +) -> subprocess.Popen: + bindir = Path(sys.executable).parent + lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx" + env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": api_key} + if env_extra: + env.update(env_extra) + return 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, + ) + + +def migrate_throwaway(backend_dir: Path, db_url: str | None = None) -> None: + from alembic import command as alembic_command + from alembic.config import Config + + cfg = Config() + cfg.set_main_option("script_location", str(backend_dir / "migrations")) + if db_url is not None: + cfg.set_main_option("sqlalchemy.url", db_url) + # migrations/env.py takes the URL from settings (not the ini value). + from app.settings import settings as app_settings + + app_settings.database_url = db_url + alembic_command.upgrade(cfg, "head") + + +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") -> tuple: + """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 4f8c4e7..6490122 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,22 +210,111 @@ 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 thread.join(timeout=15) + + +# --- shared declutter helpers (single place, was copy-pasted across files) --- + +FIXTURES_DIR = Path(__file__).parent / "fixtures" / "flows" + + +def load_flow_fixture(name: str) -> dict: + """Load a fixture flow JSON (shared; was load_fixture() x3).""" + import json + + return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8")) + + +def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict: + """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": "aus Langflow", + "endpoint_name": None, + "data": fixture["data"], + "tags": [], + "webhook": False, + "is_component": False, + } + + +@pytest.fixture() +def anon_client(): + """Unauthenticated ASGI client (was 3x copy-pasted ASGITransport blocks).""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + async def _client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + return _client + + +@pytest.fixture() +def flow_helpers(client, login): + """API flow factory: create flow/version/deploy/deployed_flow in one place. + + Collapses _create_flow/_create_version/_deploy x4 files (~60 lines). + """ + + async def _create_flow(name: str = "regeltest") -> dict: + response = await client.post("/api/flows", json={"name": name, "description": "t"}) + assert response.status_code == 201, response.text + return response.json() + + async def _create_version(flow_id: str, fixture: str = "simple_rule.json") -> dict: + response = await client.post( + f"/api/flows/{flow_id}/versions", + json={"langflow_json": load_flow_fixture(fixture), "author": "test"}, + ) + assert response.status_code == 201, response.text + return response.json() + + async def _deploy(flow_id: str, version_no: int = 1) -> dict: + response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy") + assert response.status_code == 200, response.text + return response.json() + + async def _deployed_flow(name: str = "regeltest", fixture: str = "simple_rule.json") -> tuple: + await login() + flow = await _create_flow(name) + version = await _create_version(flow["id"], fixture) + deployed = await _deploy(flow["id"], version["version_no"]) + return flow, deployed + + async def _version_and_deploy(flow_id: str, fixture: str = "simple_rule.json") -> dict: + await _create_version(flow_id, fixture) + return await _deploy(flow_id) + + return { + "create_flow": _create_flow, + "create_version": _create_version, + "deploy": _deploy, + "deployed_flow": _deployed_flow, + "version_and_deploy": _version_and_deploy, + } + + +@pytest.fixture() +def lfx_client(): + """LFX client pointed at the real test server (was _lfx() x2).""" + from app.flows.lfx_client import LfxClient + from app.settings import settings + + return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) diff --git a/backend/tests/fixtures/flows/bauvorhaben.json b/backend/tests/fixtures/flows/bauvorhaben.json new file mode 100644 index 0000000..4a093a8 --- /dev/null +++ b/backend/tests/fixtures/flows/bauvorhaben.json @@ -0,0 +1,921 @@ +{ + "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": { + "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" + }, + "gebaeude": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Kirche", + "Anderes Gebäude" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "gebaeude", + "value": " ", + "display_name": "Welches Gebäude soll verändert werden?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "bestandteil": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Glocke", + "Orgel", + "Innenraum", + "Fenster", + "Fassade" + ], + "combobox": false, + "toggle": false, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "bestandteil", + "value": [ + " " + ], + "display_name": "Welcher Bestandteil der Kirche?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Mehrfachauswahl möglich [visible_if gebaeude=Kirche]", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MultiselectInput" + }, + "glockenmassnahme": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Reinigung", + "Reparatur", + "Neuguss" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "glockenmassnahme", + "value": "Reinigung", + "display_name": "Welche Maßnahme an der Glocke?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if bestandteil=Glocke]", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "gussjahr": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "gussjahr", + "value": 0, + "display_name": "Gussjahr (nur bei Neuguss)?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if glockenmassnahme=Neuguss]", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "gebaeudeart": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Wohnhaus", + "Scheune", + "Sonstiges" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "gebaeudeart", + "value": "Wohnhaus", + "display_name": "Art des anderen Gebäudes?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if gebaeude=\"Anderes Gebäude\"]", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "denkmalschutz": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "denkmalschutz", + "value": false, + "display_name": "Steht das Gebäude unter Denkmalschutz?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "denkmalnummer": { + "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": "denkmalnummer", + "value": " ", + "display_name": "Aktenzeichen Denkmalschutz?", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "[visible_if denkmalschutz=true]", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "_type": "Component", + "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": "\"\"\"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.", + "icon": "Church", + "base_classes": [], + "display_name": "Bauvorhaben 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": "CustomInputBauvorhaben", + "id": "CustomInputBauvorhaben-bauvorhaben", + "showNode": true + }, + "id": "CustomInputBauvorhaben-bauvorhaben", + "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, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputBauvorhaben-bauvorhaben", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputBauvorhaben-bauvorhaben", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputBauvorhaben", + "id": "CustomInputBauvorhaben-bauvorhaben", + "name": "form_data", + "output_types": [ + "JSON" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-collected", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomInputBauvorhabenœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputBauvorhaben-bauvorhaben", + "sourceHandle": "{œdataTypeœ: œCustomInputBauvorhabenœ, œidœ: œCustomInputBauvorhaben-bauvorhabenœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}", + "target": "ChatOutput-collected", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Bauvorhaben: verzweigtes Formular mit mehrstufigen Abhängigkeiten.", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "e3c345da-f97c-5767-983b-afdc44296f99", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "bauvorhaben", + "tags": [], + "webhook": false +} diff --git a/backend/tests/fixtures/flows/intake_form.json b/backend/tests/fixtures/flows/intake_form.json index efae03f..6fa9665 100644 --- a/backend/tests/fixtures/flows/intake_form.json +++ b/backend/tests/fixtures/flows/intake_form.json @@ -579,10 +579,10 @@ "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``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\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.message import Message\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\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs = [\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 = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except Exception:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\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 message.", + "description": "Collects structured form values into a single JSON object.", "icon": "ClipboardList", "base_classes": [], "display_name": "CustomInput Form", @@ -596,9 +596,9 @@ "outputs": [ { "types": [ - "Message" + "JSON" ], - "selected": "Message", + "selected": "JSON", "name": "form_data", "display_name": "Form Data", "method": "collect", @@ -947,7 +947,7 @@ "id": "CustomInputForm-intake", "name": "form_data", "output_types": [ - "Message" + "JSON" ] }, "targetHandle": { @@ -963,10 +963,10 @@ "type": "other" } }, - "id": "xy-edge__{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "id": "xy-edge__{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", "selected": false, "source": "CustomInputForm-intake", - "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}", + "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}", "target": "ChatOutput-collected", "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" } diff --git a/backend/tests/fixtures/flows/mitarbeiter_einstellen.json b/backend/tests/fixtures/flows/mitarbeiter_einstellen.json new file mode 100644 index 0000000..9bd4176 --- /dev/null +++ b/backend/tests/fixtures/flows/mitarbeiter_einstellen.json @@ -0,0 +1,991 @@ +{ + "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": { + "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" + }, + "rolle": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Assistenz", + "Pfarrer:in", + "Vikar:in", + "Messner:in" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "rolle", + "value": "Assistenz", + "display_name": "Rolle", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "einstufung": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Stufe 1", + "Stufe 2", + "Stufe 3" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "einstufung", + "value": "Stufe 1", + "display_name": "Einstufung", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "mitarbeiter_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": "mitarbeiter_name", + "value": " ", + "display_name": "Name", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Vor- und Nachname des Mitarbeiters", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "geburtsdatum": { + "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": "geburtsdatum", + "value": "", + "display_name": "Geburtsdatum", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Datum eingeben (z. B. JJJJ-MM-TT)", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "_type": "Component", + "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": "\"\"\"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.", + "icon": "UserPlus", + "base_classes": [], + "display_name": "Mitarbeiter Einstellen 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": "CustomInputMitarbeiter", + "id": "CustomInputMitarbeiter-mitarbeiter", + "showNode": true + }, + "id": "CustomInputMitarbeiter-mitarbeiter", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_data": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_data", + "value": "", + "display_name": "Employee Data", + "advanced": false, + "api_editable": false, + "input_types": [ + "JSON", + "Data", + "Message", + "DataFrame", + "Table" + ], + "dynamic": false, + "info": "Employee data as JSON/Data/Message/DataFrame. Must contain 'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.custom import Component\nfrom lfx.io import HandleInput, Output\nfrom lfx.schema import Data, Message\n\ntry:\n from lfx.schema import DataFrame\nexcept Exception: # pragma: no cover\n DataFrame = None\n\nimport json\n\n\nclass GehaltsrechnerComponent(Component):\n display_name = \"Gehaltsrechner\"\n description = (\n \"Calculates the annual salary (Gehalt) for church employees based on their role \"\n \"(rolle) and level (einstufung). Use this when you need to determine the yearly \"\n \"salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in.\"\n )\n icon = \"Calculator\"\n\n inputs = [\n HandleInput(\n name=\"input_data\",\n display_name=\"Employee Data\",\n input_types=[\"JSON\", \"Data\", \"Message\", \"DataFrame\", \"Table\"],\n info=(\n \"Employee data as JSON/Data/Message/DataFrame. Must contain \"\n \"'rolle' (and 'einstufung' for Pfarrer:in), plus 'mitarbeiter_name' and 'geburtsdatum'.\"\n ),\n ),\n ]\n\n outputs = [\n Output(\n name=\"salary_text\",\n display_name=\"Salary Text\",\n method=\"calculate_salary\",\n ),\n ]\n\n # Salary mapping for simple roles\n BASE_SALARY = {\n \"Assistenz\": 42000,\n \"Vikar:in\": 48000,\n \"Messner:in\": 36000,\n }\n\n # Salary mapping for Pfarrer:in per Einstufung\n PFARRER_SALARY = {\n \"Stufe 1\": 55000,\n \"Stufe 2\": 65000,\n \"Stufe 3\": 75000,\n }\n\n def _extract_data(self, value) -> dict | None:\n '''Normalize the incoming value into a plain dict.'''\n if isinstance(value, dict):\n return value\n if isinstance(value, Data):\n return value.data if isinstance(value.data, dict) else None\n if isinstance(value, Message):\n value = value.text\n if DataFrame is not None and isinstance(value, DataFrame):\n try:\n records = value.to_dict(orient=\"records\")\n if records:\n return records[0]\n except Exception:\n return None\n return None\n if isinstance(value, str):\n text = value.strip()\n try:\n parsed = json.loads(text)\n if isinstance(parsed, dict):\n return parsed\n if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):\n return parsed[0]\n except (json.JSONDecodeError, TypeError, ValueError):\n return None\n return None\n\n def calculate_salary(self) -> Message:\n try:\n data = self._extract_data(self.input_data)\n if not isinstance(data, dict):\n return Message(\n text=\"Error: Could not parse employee data. Please provide a valid JSON object.\"\n )\n rolle = data.get(\"rolle\")\n einstufung = data.get(\"einstufung\")\n name = data.get(\"mitarbeiter_name\", \"unknown\")\n if not rolle:\n return Message(\n text=\"Error: Missing required field 'rolle' in employee data.\"\n )\n if rolle in self.BASE_SALARY:\n salary = self.BASE_SALARY[rolle]\n elif rolle == \"Pfarrer:in\":\n if einstufung not in self.PFARRER_SALARY:\n return Message(\n text=(\n \"Error: For role 'Pfarrer:in', a valid 'einstufung' \"\n \"(Stufe 1, Stufe 2 or Stufe 3) is required.\"\n )\n )\n salary = self.PFARRER_SALARY[einstufung]\n else:\n return Message(text=f\"Error: Unknown role '{rolle}'.\")\n einstufung_display = einstufung if einstufung else \"-\"\n return Message(\n text=(\n f\"Mitarbeiter:{name} | Rolle:{rolle} | \"\n f\"Einstufung:{einstufung_display} | Gehalt:{salary} EUR pro Jahr\"\n )\n )\n except Exception as exc: # never raise, always return a clear error string\n return Message(text=f\"Error: {exc}\")\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + } + }, + "description": "Calculates the annual salary (Gehalt) for church employees based on their role (rolle) and level (einstufung). Use this when you need to determine the yearly salary for roles such as Assistenz, Vikar:in, Messner:in or Pfarrer:in.", + "icon": "Calculator", + "base_classes": [ + "Message" + ], + "display_name": "Gehaltsrechner", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "salary_text", + "display_name": "Salary Text", + "method": "calculate_salary", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [ + "input_data" + ], + "beta": false, + "legacy": false, + "edited": false, + "metadata": { + "module": "custom_components.gehaltsrechner", + "code_hash": "31079e5045cc", + "dependencies": { + "total_dependencies": 1, + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ] + } + }, + "tool_mode": false + }, + "type": "CustomComponent", + "id": "Gehaltsrechner-gehalt", + "showNode": true + }, + "id": "Gehaltsrechner-gehalt", + "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-collected", + "showNode": true + }, + "id": "ChatOutput-collected", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 1200, + "y": 200 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputMitarbeiter-mitarbeiter", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputMitarbeiter-mitarbeiter", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputMitarbeiter", + "id": "CustomInputMitarbeiter-mitarbeiter", + "name": "form_data", + "output_types": [ + "JSON" + ] + }, + "targetHandle": { + "fieldName": "input_data", + "id": "Gehaltsrechner-gehalt", + "inputTypes": [ + "JSON", + "Data", + "Message", + "DataFrame", + "Table" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomInputMitarbeiterœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_dataœ, œidœ: œGehaltsrechner-gehaltœ, œinputTypesœ: [œJSONœ, œDataœ, œMessageœ, œDataFrameœ, œTableœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputMitarbeiter-mitarbeiter", + "sourceHandle": "{œdataTypeœ: œCustomInputMitarbeiterœ, œidœ: œCustomInputMitarbeiter-mitarbeiterœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}", + "target": "Gehaltsrechner-gehalt", + "targetHandle": "{œfieldNameœ: œinput_dataœ, œidœ: œGehaltsrechner-gehaltœ, œinputTypesœ: [œJSONœ, œDataœ, œMessageœ, œDataFrameœ, œTableœ], œtypeœ: œotherœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomComponent", + "id": "Gehaltsrechner-gehalt", + "name": "salary_text", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-collected", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œGehaltsrechner-gehaltœ, œnameœ: œsalary_textœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "Gehaltsrechner-gehalt", + "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œGehaltsrechner-gehaltœ, œnameœ: œsalary_textœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-collected", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Mitarbeiter einstellen: Formular mit Gehaltsrechner.", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "b2076300-4ba0-5907-9633-c92b97237c35", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "mitarbeiter_einstellen", + "tags": [], + "webhook": false +} diff --git a/backend/tests/test_approvals.py b/backend/tests/test_approvals.py index 2ee5661..59e7daf 100644 --- a/backend/tests/test_approvals.py +++ b/backend/tests/test_approvals.py @@ -1,42 +1,19 @@ """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()) - - -async def _create_flow(client, name): - response = await client.post("/api/flows", json={"name": name}) - assert response.status_code == 201, response.text - return response.json() - - -async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"): - response = await client.post( - f"/api/flows/{flow_id}/versions", - json={"langflow_json": load_fixture(fixture)}, - ) - assert response.status_code == 201, response.text - response = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") - assert response.status_code == 200, response.text - return response.json() - - -async def _handoff_task(client, live_server, monkeypatch): +async def _handoff_task(client, live_server, monkeypatch, flow_helpers): """Run approval_handoff over threshold; returns (run, task).""" from app.settings import settings monkeypatch.setattr(settings, "callback_base_url", live_server) - flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}") - await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json") + flow = await flow_helpers["create_flow"](f"handoff-{uuid4().hex[:8]}") + await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json") body = {"input": {"betrag": 250000, "schwelle": 200000}} run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json() assert run["status"] == "pending_approval" @@ -45,9 +22,11 @@ async def _handoff_task(client, live_server, monkeypatch): return run, task -async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch): +async def test_approve_without_continuation( + client, lfx_server, live_server, login, monkeypatch, flow_helpers +): await login() - _, task = await _handoff_task(client, live_server, monkeypatch) + _, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers) response = await client.post( f"/api/approvals/{task['id']}/decision", json={"decision": "approve", "note": "ok"}, @@ -61,11 +40,11 @@ async def test_approve_without_continuation(client, lfx_server, live_server, log async def test_approve_with_continuation_runs_facts( - client, lfx_server, live_server, login, monkeypatch + client, lfx_server, live_server, login, monkeypatch, flow_helpers ): await login() - cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}") - await _version_and_deploy(client, cont["id"], fixture="simple_rule.json") + cont = await flow_helpers["create_flow"](f"cont-{uuid4().hex[:8]}") + await flow_helpers["version_and_deploy"](cont["id"], fixture="simple_rule.json") # Continuation is authoring-time wiring on the definition (admin-only); # run bodies cannot set it. @@ -75,7 +54,7 @@ async def test_approve_with_continuation_runs_facts( ) assert response.status_code == 201, response.text flow = response.json() - await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json") + await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json") from app.settings import settings @@ -106,16 +85,18 @@ async def test_approve_with_continuation_runs_facts( assert "Sonderfall" in cont_run["output"]["result"] -async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch): +async def test_client_continuation_is_ignored( + client, lfx_server, live_server, login, monkeypatch, flow_helpers +): """A run body cannot chain arbitrary flows (G).""" from app.settings import settings await login() monkeypatch.setattr(settings, "callback_base_url", live_server) - cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}") - await _version_and_deploy(client, cont["id"], fixture="simple_rule.json") - flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}") - await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json") + cont = await flow_helpers["create_flow"](f"cont-{uuid4().hex[:8]}") + await flow_helpers["version_and_deploy"](cont["id"], fixture="simple_rule.json") + flow = await flow_helpers["create_flow"](f"handoff-{uuid4().hex[:8]}") + await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json") run = ( await client.post( f"/api/flows/{flow['id']}/run", @@ -131,9 +112,9 @@ async def test_client_continuation_is_ignored(client, lfx_server, live_server, l assert task["continuation_flow_id"] is None -async def test_reject_task(client, lfx_server, live_server, login, monkeypatch): +async def test_reject_task(client, lfx_server, live_server, login, monkeypatch, flow_helpers): await login() - _, task = await _handoff_task(client, live_server, monkeypatch) + _, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers) # Any logged-in user (not just admins) can decide; identity is the session. await login(email="chef@example.com", role="member") response = await client.post( @@ -148,9 +129,9 @@ async def test_reject_task(client, lfx_server, live_server, login, monkeypatch): assert body["continuation_run"] is None -async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch): +async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch, flow_helpers): await login() - _, task = await _handoff_task(client, live_server, monkeypatch) + _, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers) url = f"/api/approvals/{task['id']}/decision" response = await client.post(url, json={"decision": "approve"}) @@ -161,7 +142,7 @@ async def test_decision_guards(client, lfx_server, live_server, login, monkeypat response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"}) assert response.status_code == 404 - _, task2 = await _handoff_task(client, live_server, monkeypatch) + _, task2 = await _handoff_task(client, live_server, monkeypatch, flow_helpers) response = await client.post( f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"} ) @@ -175,15 +156,15 @@ async def test_decision_guards(client, lfx_server, live_server, login, monkeypat assert response.status_code == 200 -async def test_decision_bad_continuation_reference(client, session, login): +async def test_decision_bad_continuation_reference(client, session, login, flow_helpers): from app.flows import store as flow_store await login() - flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}") + flow = await flow_helpers["create_flow"](f"badcont-{uuid4().hex[:8]}") version = ( await client.post( f"/api/flows/{flow['id']}/versions", - json={"langflow_json": load_fixture("simple_rule.json")}, + json={"langflow_json": load_flow_fixture("simple_rule.json")}, ) ).json() run = flow_store.create_run( @@ -201,11 +182,11 @@ async def test_decision_bad_continuation_reference(client, session, login): assert response.status_code == 422 -async def test_tenant_isolation(client, lfx_server, login): +async def test_tenant_isolation(client, lfx_server, login, flow_helpers): """Strict session scoping: explicit ?tenant_id= cannot widen reads (E).""" await login() - flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}") - await _version_and_deploy(client, flow["id"]) + flow = await flow_helpers["create_flow"](f"tenant-{uuid4().hex[:8]}") + await flow_helpers["version_and_deploy"](flow["id"]) await login(email="a@example.com", role="member", tenant_id="pfarramt-a") response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}) @@ -236,14 +217,14 @@ async def test_tenant_isolation(client, lfx_server, login): assert response.status_code == 200 -async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login): +async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login, flow_helpers): from app.settings import settings await login() - flow = await _create_flow(client, f"down-{uuid4().hex[:8]}") + flow = await flow_helpers["create_flow"](f"down-{uuid4().hex[:8]}") await client.post( f"/api/flows/{flow['id']}/versions", - json={"langflow_json": load_fixture("simple_rule.json")}, + json={"langflow_json": load_flow_fixture("simple_rule.json")}, ) monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9") response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy") @@ -258,13 +239,13 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login): assert response.status_code == 503 -async def test_lfx_drift_maps_to_502(client, lfx_server, login): +async def test_lfx_drift_maps_to_502(client, lfx_server, login, flow_helpers): from app.flows.lfx_client import LfxClient from app.settings import settings await login() - flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}") - deployed = await _version_and_deploy(client, flow["id"]) + flow = await flow_helpers["create_flow"](f"drift-{uuid4().hex[:8]}") + deployed = await flow_helpers["version_and_deploy"](flow["id"]) await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow( deployed["lfx_flow_id"] ) @@ -272,9 +253,9 @@ async def test_lfx_drift_maps_to_502(client, lfx_server, login): assert response.status_code == 502 -async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login): +async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login, flow_helpers): await login() - flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}") + flow = await flow_helpers["create_flow"](f"corrupt-{uuid4().hex[:8]}") bogus = { "name": "bogus", "data": { diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e48de8a..5c0f1e1 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -8,7 +8,7 @@ import pytest from sqlmodel import select from app.auth import service -from app.auth.deps import SESSION_COOKIE, resolve_tenant +from app.auth.deps import SESSION_COOKIE from app.models import MagicLinkToken, User from app.settings import settings @@ -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): @@ -202,6 +200,28 @@ async def test_no_token_or_session_in_logs(client, session, caplog): assert service.hash_token(token) not in caplog.text +def test_session_jwt_short_expiry_and_claims(session): + """Stateless sessions expire in days (no revocation — bound the window).""" + user = _get_or_make_user(session, MEMBER) + claims = service.decode_session_token(service.mint_session_token(user)) + assert claims["sub"] == str(user.id) + assert claims["tenant_id"] == user.tenant_id + assert claims["exp"] - claims["iat"] == 7 * 24 * 3600 + + +async def test_deleted_user_token_fails_closed(client, session): + """Tokens of deleted users stop working (row reloaded per request).""" + user = _get_or_make_user(session, MEMBER) + token = service.issue_magic_token(session, user) + response = await client.post("/api/auth/magic/consume", json={"token": token}) + assert response.status_code == 200, response.text + jwt_cookie = response.cookies[SESSION_COOKIE] + session.delete(user) + session.commit() + client.cookies.set(SESSION_COOKIE, jwt_cookie) + assert (await client.get("/api/auth/me")).status_code == 401 + + async def test_access_request_rate_limit(client): email = f"impatient-{uuid4()}@example.com" for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR): @@ -216,24 +236,13 @@ async def test_access_request_rate_limit(client): assert limited.status_code == 429 -def test_resolve_tenant(): - assert resolve_tenant(None, None) == "default" - assert resolve_tenant(None, "acme") == "acme" - user = User(email="a@b.c", tenant_id="parish-7") - assert resolve_tenant(user, None) == "parish-7" - assert resolve_tenant(user, "other") == "parish-7" - - 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 +324,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_export.py b/backend/tests/test_export.py index f500575..e738e97 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -2,18 +2,26 @@ import json +import pytest + from app.flows import export -def test_filename_stem_keeps_original_names(): - assert export.filename_stem("simple_rule") == "simple_rule" - assert export.filename_stem("test_flow") == "test_flow" - assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung" - assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k" - assert export.filename_stem(" A/B\\C ") == "A-B-C" - assert export.filename_stem('a:b*c?d"eg|h') == "a-b-c-d-e-f-g-h" - assert export.filename_stem("trailing...") == "trailing" - assert export.filename_stem("") == "flow" +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("simple_rule", "simple_rule"), + ("test_flow", "test_flow"), + ("Gehaltserhöhung", "Gehaltserhöhung"), + ("Dachsanierung 200k", "Dachsanierung 200k"), + (" A/B\\C ", "A-B-C"), + ('a:b*c?d"eg|h', "a-b-c-d-e-f-g-h"), + ("trailing...", "trailing"), + ("", "flow"), + ], +) +def test_filename_stem_keeps_original_names(name, expected): + assert export.filename_stem(name) == expected def test_canonical_bytes_stable_under_key_reorder(): @@ -43,6 +51,28 @@ def test_iter_flow_files_only_top_level_json(tmp_path): assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"] +def test_flowsignore_parsing_and_filtering(tmp_path): + assert export.ignored_stems(tmp_path) == set() + (tmp_path / export.IGNORE_FILENAME).write_text( + "# experiments stay Langflow-only\n\ni_love_this_app\n", encoding="utf-8" + ) + assert export.ignored_stems(tmp_path) == {"i_love_this_app"} + (tmp_path / "a.json").write_text("{}") + (tmp_path / "i_love_this_app.json").write_text("{}") + assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"] + # Explicit override still works. + assert export.iter_flow_files(tmp_path, ignored=set()) == [ + tmp_path / "a.json", + tmp_path / "i_love_this_app.json", + ] + + +def test_find_reuse_path_ignores_ignored_files(tmp_path): + (tmp_path / export.IGNORE_FILENAME).write_text("alt\n", encoding="utf-8") + (tmp_path / "alt.json").write_text(json.dumps({"name": "Neu"})) + assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False) + + def test_find_reuse_path_matches_identical_content_first(tmp_path): doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}} other = tmp_path / "other-name.json" diff --git a/backend/tests/test_flows_api.py b/backend/tests/test_flows_api.py index cc29aff..3668753 100644 --- a/backend/tests/test_flows_api.py +++ b/backend/tests/test_flows_api.py @@ -1,43 +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()) - - -async def _create_flow(client, name="regeltest"): - response = await client.post("/api/flows", json={"name": name, "description": "test flow"}) - assert response.status_code == 201, response.text - return response.json() - - -async def _create_version(client, flow_id, fixture="simple_rule.json"): - response = await client.post( - f"/api/flows/{flow_id}/versions", - json={"langflow_json": load_fixture(fixture), "author": "test"}, - ) - assert response.status_code == 201, response.text - return response.json() - - -async def _deploy(client, flow_id, version_no=1): - response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy") - assert response.status_code == 200, response.text - return response.json() - - -async def test_flow_crud(client, login): +async def test_flow_crud(client, login, flow_helpers): await login() - created = await _create_flow(client, "crud-flow") + created = await flow_helpers["create_flow"]("crud-flow") assert created["status"] == "active" response = await client.get(f"/api/flows/{created['id']}") @@ -55,68 +32,68 @@ 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(target, json=body or {}) + else: + response = await client.get(target) + assert response.status_code == 403, (method, target, response.text) + + +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, flow_helpers, method, path, body +): + """Run, run reads and approval reads are login-gated (parametrized).""" + await login() + flow = await flow_helpers["create_flow"]("gated") + target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4()) + async for anon in anon_client(): if method == "POST": - response = await client.post(path, json=body or {}) + response = await anon.post(target, json=body or {}) else: - response = await client.get(path) - assert response.status_code == 403, (method, path, response.text) + response = await anon.get(target) + assert response.status_code == 401, (method, target, response.text) -async def test_runs_and_reads_require_login(client, login): - """Run, run reads and approval reads are login-gated (A).""" +async def test_version_validation_and_numbering(client, login, flow_helpers): 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 + flow = await flow_helpers["create_flow"]("versioned") - 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) - - -async def test_version_validation_and_numbering(client, login): - await login() - flow = await _create_flow(client, "versioned") - - v1 = await _create_version(client, flow["id"]) + v1 = await flow_helpers["create_version"](flow["id"]) assert v1["version_no"] == 1 - v2 = await _create_version(client, flow["id"]) + v2 = await flow_helpers["create_version"](flow["id"]) assert v2["version_no"] == 2 assert v1["deployed_at"] is None @@ -127,7 +104,7 @@ async def test_version_validation_and_numbering(client, login): response = await client.post( f"/api/flows/{uuid4()}/versions", - json={"langflow_json": load_fixture("simple_rule.json")}, + json={"langflow_json": load_flow_fixture("simple_rule.json")}, ) assert response.status_code == 404 @@ -135,11 +112,11 @@ async def test_version_validation_and_numbering(client, login): assert [v["version_no"] for v in response.json()] == [1, 2] -async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login): +async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login, flow_helpers): await login() - flow = await _create_flow(client, "dachsanierung") - await _create_version(client, flow["id"]) - deployed = await _deploy(client, flow["id"]) + flow = await flow_helpers["create_flow"]("dachsanierung") + await flow_helpers["create_version"](flow["id"]) + deployed = await flow_helpers["deploy"](flow["id"]) assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1) assert deployed["deployed_at"] is not None @@ -167,20 +144,20 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login) assert response.json()["id"] == run["id"] -async def test_run_undeployed_version_fails(client, login): +async def test_run_undeployed_version_fails(client, login, flow_helpers): await login() - flow = await _create_flow(client, "undeployed") - await _create_version(client, flow["id"]) + flow = await flow_helpers["create_flow"]("undeployed") + await flow_helpers["create_version"](flow["id"]) response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}) assert response.status_code == 422 -async def test_run_explicit_version_no(client, lfx_server, login): +async def test_run_explicit_version_no(client, lfx_server, login, flow_helpers): await login() - flow = await _create_flow(client, "explicit-version") - await _create_version(client, flow["id"]) - await _create_version(client, flow["id"]) - await _deploy(client, flow["id"], version_no=2) + flow = await flow_helpers["create_flow"]("explicit-version") + await flow_helpers["create_version"](flow["id"]) + await flow_helpers["create_version"](flow["id"]) + await flow_helpers["deploy"](flow["id"], version_no=2) response = await client.post( f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}, "version_no": 2}, @@ -189,11 +166,11 @@ async def test_run_explicit_version_no(client, lfx_server, login): assert response.json()["status"] == "completed" -async def test_run_stream_events(client, lfx_server, login): +async def test_run_stream_events(client, lfx_server, login, flow_helpers): await login() - flow = await _create_flow(client, "streamed") - await _create_version(client, flow["id"]) - await _deploy(client, flow["id"]) + flow = await flow_helpers["create_flow"]("streamed") + await flow_helpers["create_version"](flow["id"]) + await flow_helpers["deploy"](flow["id"]) events: list[tuple[str, str]] = [] async with client.stream( @@ -211,26 +188,29 @@ 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 assert response.json()["status"] == "completed" -async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch): +async def test_approval_handoff_end_to_end( + client, lfx_server, live_server, login, monkeypatch, flow_helpers +): from app.settings import settings await login() monkeypatch.setattr(settings, "callback_base_url", live_server) - flow = await _create_flow(client, "freigabe") - await _create_version(client, flow["id"], fixture="approval_handoff.json") - await _deploy(client, flow["id"]) + flow = await flow_helpers["create_flow"]("freigabe") + await flow_helpers["create_version"](flow["id"], fixture="approval_handoff.json") + await flow_helpers["deploy"](flow["id"]) response = await client.post( f"/api/flows/{flow['id']}/run", @@ -252,15 +232,15 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server, logi async def test_approval_handoff_auto_creates_no_task( - client, lfx_server, live_server, login, monkeypatch + client, lfx_server, live_server, login, monkeypatch, flow_helpers ): from app.settings import settings await login() monkeypatch.setattr(settings, "callback_base_url", live_server) - flow = await _create_flow(client, "freigabe-auto") - await _create_version(client, flow["id"], fixture="approval_handoff.json") - await _deploy(client, flow["id"]) + flow = await flow_helpers["create_flow"]("freigabe-auto") + await flow_helpers["create_version"](flow["id"], fixture="approval_handoff.json") + await flow_helpers["deploy"](flow["id"]) response = await client.post( f"/api/flows/{flow['id']}/run", @@ -274,14 +254,14 @@ async def test_approval_handoff_auto_creates_no_task( assert response.json() == [] -async def test_approval_callback_contract(client, session, login): +async def test_approval_callback_contract(client, session, login, flow_helpers): from uuid import UUID from app.flows import store as flow_store await login() - flow = await _create_flow(client, "callback-target") - version = await _create_version(client, flow["id"]) + flow = await flow_helpers["create_flow"]("callback-target") + version = await flow_helpers["create_version"](flow["id"]) version_obj = flow_store.get_version(session, UUID(version["id"])) run = flow_store.create_run(session, version_obj, input={}, tenant_id="default") @@ -319,15 +299,15 @@ async def test_approval_callback_contract(client, session, login): assert response.status_code == 404 -async def test_approval_callback_token_required(client, session, login): +async def test_approval_callback_token_required(client, session, login, flow_helpers): """Forged callbacks without the shared token are rejected (C).""" from uuid import UUID from app.flows import store as flow_store await login() - flow = await _create_flow(client, "callback-gated") - version = await _create_version(client, flow["id"]) + flow = await flow_helpers["create_flow"]("callback-gated") + version = await flow_helpers["create_version"](flow["id"]) version_obj = flow_store.get_version(session, UUID(version["id"])) run = flow_store.create_run(session, version_obj, input={}, tenant_id="default") diff --git a/backend/tests/test_form_components.py b/backend/tests/test_form_components.py index 3d047b5..e8f2379 100644 --- a/backend/tests/test_form_components.py +++ b/backend/tests/test_form_components.py @@ -4,6 +4,7 @@ import json import sys from pathlib import Path +import pytest from lfx.custom import Component REPO_ROOT = Path(__file__).resolve().parent.parent.parent @@ -39,48 +40,381 @@ def test_component_contract(): def test_collect_merges_submitted_values_over_defaults(): - from lfx.schema.message import Message + from lfx.schema.data import Data form = CustomInputForm() - default = json.loads(form.collect().text) - assert default["full_name"] == "" - assert default["department"] == "Music" - assert isinstance(form.collect(), Message) + default = form.collect() + assert isinstance(default, Data) + assert default.data["full_name"] == "" + assert default.data["department"] == "Music" form.form_values = json.dumps({"full_name": "Maria", "age": 41}) - merged = json.loads(form.collect().text) + merged = form.collect().data assert merged["full_name"] == "Maria" assert merged["age"] == 41 assert merged["department"] == "Music" # default kept # Run envelope shape {"payload": {...}} unwraps like the other components. form.form_values = json.dumps({"payload": {"notes": "hi"}}) - assert json.loads(form.collect().text)["notes"] == "hi" + assert form.collect().data["notes"] == "hi" form.form_values = "not json" - assert json.loads(form.collect().text)["full_name"] == "" + assert form.collect().data["full_name"] == "" -def test_intake_fixture_shape(): - doc = json.loads((FIXTURES / "intake_form.json").read_text()) +def test_collect_picks_up_langflow_added_field(): + """A field added in the Langflow code editor needs no collect() change.""" + from lfx.io import StrInput + + form = CustomInputForm() + form._inputs["room"] = StrInput(name="room", display_name="Room", info="Where", value="Chapel") + collected = form.collect().data + assert collected["room"] == "Chapel" + assert collected["full_name"] == "" + + +def test_collect_survives_rename(): + """Renaming `full_name` to `name` drops the old key, keeps the new one.""" + from lfx.io import StrInput + + form = CustomInputForm() + del form._inputs["full_name"] + form._inputs["name"] = StrInput(name="name", display_name="Name", info="", value="Maria") + collected = form.collect().data + assert "full_name" not in collected + assert collected["name"] == "Maria" + + +def test_collect_keeps_unknown_envelope_keys(): + """Frontend payloads with renamed keys merge even if undeclared here.""" + form = CustomInputForm() + form.form_values = json.dumps({"payload": {"nickname": "Mia"}}) + collected = form.collect().data + assert collected["nickname"] == "Mia" + assert collected["department"] == "Music" # default kept + + +def test_collect_fields_list_restricts_defaults(): + """Asked-field list: unasked code defaults stay out of the output.""" + form = CustomInputForm() + form.form_values = json.dumps( + {"payload": {"full_name": "Maria"}, "fields": ["full_name", "age"]} + ) + collected = form.collect().data + assert collected == {"full_name": "Maria", "age": 18} + + +def test_collect_fields_list_fills_missing_from_defaults(): + form = CustomInputForm() + form.form_values = json.dumps( + {"payload": {"full_name": "Maria"}, "fields": ["full_name", "department"]} + ) + collected = form.collect().data + assert collected == {"full_name": "Maria", "department": "Music"} + + +def test_collect_fields_list_ignores_undeclared_names(): + """Asked-but-undeclared template fields contribute no Nones.""" + form = CustomInputForm() + form.form_values = json.dumps({"payload": {"room": "Chapel"}, "fields": ["full_name", "room"]}) + collected = form.collect().data + assert collected == {"full_name": "", "room": "Chapel"} + + +def test_collect_raw_dict_ignores_fields_key(): + """A raw dict is payload, not envelope: 'fields' is just a value.""" + form = CustomInputForm() + form.form_values = json.dumps({"fields": ["x"], "full_name": "Maria"}) + collected = form.collect().data + assert collected["fields"] == ["x"] + assert collected["full_name"] == "Maria" + assert collected["age"] == 18 # all defaults apply without envelope + + +def test_collect_hides_branch_default_until_trigger_matches(): + """Hidden branch fields contribute no defaults (frontend parity).""" + form = CustomInputForm() + form._inputs["notes"].info = "Anything [visible_if department=Youth]" + assert "notes" not in form.collect().data # department defaults to Music + form.form_values = json.dumps({"payload": {"department": "Youth"}}) + assert form.collect().data["notes"] == "" + + +def test_collect_branch_visibility_uses_submitted_triggers(): + form = CustomInputForm() + form._inputs["notes"].info = "[visible_if age=41]" + form.form_values = json.dumps({"payload": {"age": 41}, "fields": ["age", "notes"]}) + assert form.collect().data["notes"] == "" + form.form_values = json.dumps({"payload": {"age": 18}, "fields": ["age", "notes"]}) + assert "notes" not in form.collect().data + + +def test_collect_dangling_marker_stays_visible(): + form = CustomInputForm() + form._inputs["notes"].info = "[visible_if nope=x]" + assert "notes" in form.collect().data + + +def test_bauvorhaben_component_contract(): + from kirche.bauvorhaben_form import CustomInputBauvorhaben + + assert CustomInputBauvorhaben.display_name == "Bauvorhaben Form" + inputs = {i.name: i for i in CustomInputBauvorhaben.inputs} + assert sorted(n for n in inputs if n != "form_values") == sorted( + [ + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + ] + ) + assert inputs["form_values"].show is False + outputs = {o.name: o for o in CustomInputBauvorhaben.outputs} + assert set(outputs) == {"form_data"} + + +def test_bauvorhaben_collect_only_own_fields(): + """Standalone component: no intake leftovers, hidden branches dropped.""" + from kirche.bauvorhaben_form import CustomInputBauvorhaben + + form = CustomInputBauvorhaben() + default = form.collect().data + assert "full_name" not in default and "age" not in default + assert default["gebaeude"] == "" # class default (template ships " " sentinel) + assert "bestandteil" not in default # hidden (gebaeude default ""). + assert "gebaeudeart" not in default + + form.form_values = json.dumps( + { + "payload": {"gebaeude": "Kirche", "bestandteil": ["Glocke"]}, + "fields": [ + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + ], + } + ) + collected = form.collect().data + assert collected["bestandteil"] == ["Glocke"] + assert collected["glockenmassnahme"] == "Reinigung" # visible branch default + assert "gussjahr" not in collected # hidden branch + assert "gebaeudeart" not in collected + assert "full_name" not in collected + + +def _parse_chat_json(text: str) -> dict: + """Parse terminal ChatOutput text: raw JSON or a ```json fenced block.""" + text = text.strip() + if text.startswith("```"): + lines = text.splitlines() + assert lines[0].startswith("```"), text[:60] + assert lines[-1].startswith("```"), text[-60:] + text = "\n".join(lines[1:-1]) + return json.loads(text) + + +@pytest.mark.parametrize( + ( + "fixture_file", + "doc_name", + "node_id", + "expected_type", + "expected_display", + "display_mode", + "expected_fields", + "expected_edges", + ), + [ + ( + "intake_form.json", + None, + "CustomInputForm-intake", + "CustomInputForm", + "CustomInput", + "startswith", + { + "full_name", + "notes", + "password", + "age", + "amount", + "newsletter", + "department", + "topics", + "satisfaction", + "attachment", + }, + [ + ("ChatInput-entry", "CustomInputForm-intake"), + ("CustomInputForm-intake", "ChatOutput-collected"), + ], + ), + ( + "bauvorhaben.json", + "bauvorhaben", + "CustomInputBauvorhaben-bauvorhaben", + "CustomInputBauvorhaben", + "Bauvorhaben Form", + "exact", + { + "form_values", + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + "code", + }, + [ + ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben"), + ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected"), + ], + ), + ( + "mitarbeiter_einstellen.json", + "mitarbeiter_einstellen", + "CustomInputMitarbeiter-mitarbeiter", + "CustomInputMitarbeiter", + "Mitarbeiter Einstellen Form", + "exact", + { + "form_values", + "rolle", + "einstufung", + "mitarbeiter_name", + "geburtsdatum", + "code", + }, + [ + ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter"), + ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt"), + ("Gehaltsrechner-gehalt", "ChatOutput-collected"), + ], + ), + ], + ids=["intake", "bauvorhaben", "mitarbeiter"], +) +def test_fixture_shape( + fixture_file, + doc_name, + node_id, + expected_type, + expected_display, + display_mode, + expected_fields, + expected_edges, +): + doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8")) + if doc_name is not None: + assert doc["name"] == doc_name nodes = {n["id"]: n for n in doc["data"]["nodes"]} - form = nodes["CustomInputForm-intake"] - assert form["data"]["type"] == "CustomInputForm" - assert form["data"]["node"]["display_name"].startswith("CustomInput") + form = nodes[node_id] + assert form["data"]["type"] == expected_type + display = form["data"]["node"]["display_name"] + if display_mode == "startswith": + assert display.startswith(expected_display) + else: + assert display == expected_display template = form["data"]["node"]["template"] - for field in FORM_FIELDS: - assert field in template, field + assert set(template) >= expected_fields assert template["form_values"]["show"] is False assert len(template["code"]["value"]) > 200 + outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]} + assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]} - assert ("ChatInput-entry", "CustomInputForm-intake") in edges - assert ("CustomInputForm-intake", "ChatOutput-collected") in edges + for edge in expected_edges: + assert edge in edges -async def test_intake_deploy_and_run_with_values(client, lfx_server, login): +async def _check_intake_result(client, flow_id, version, run): + # collect() returns Data: the terminal ChatOutput renders it as a + # fenced ```json block, so unwrap before parsing. + result = _parse_chat_json(run["output"]["result"]) + assert result["full_name"] == "Maria Muster" + assert result["age"] == 41 + assert result["department"] == "Music" # default preserved + + +async def _check_bauvorhaben_result(client, flow_id, version, run): + # Branch rules are cached per version and served via input-schema. + cached = version["input_schema"] + assert len(cached["json_schema"]["allOf"]) == 5 + schema = await client.get(f"/api/flows/{flow_id}/input-schema") + assert schema.status_code == 200, schema.text + assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"} + result = _parse_chat_json(run["output"]["result"]) + assert result["gebaeude"] == "Kirche" + assert result["bestandteil"] == ["Glocke", "Orgel"] + assert result["gussjahr"] == 1890 + assert result["denkmalnummer"] == "D-123" + # Only asked fields: unasked code defaults (age, amount, ...) stay out. + assert set(result) == { + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "denkmalschutz", + "denkmalnummer", + } + + +async def _check_mitarbeiter_result(client, flow_id, version, run): + result = run["output"]["result"] + assert "65000" in result + assert "Maria Muster" in result + + +@pytest.mark.parametrize( + ("fixture_file", "flow_name", "run_input", "check_result"), + [ + ( + "intake_form.json", + "intake-e2e", + {"full_name": "Maria Muster", "age": 41}, + _check_intake_result, + ), + ( + "bauvorhaben.json", + "bauvorhaben-e2e", + { + "gebaeude": "Kirche", + "bestandteil": ["Glocke", "Orgel"], + "glockenmassnahme": "Neuguss", + "gussjahr": 1890, + "denkmalschutz": True, + "denkmalnummer": "D-123", + }, + _check_bauvorhaben_result, + ), + ( + "mitarbeiter_einstellen.json", + "mitarbeiter-e2e", + { + "rolle": "Pfarrer:in", + "einstufung": "Stufe 2", + "mitarbeiter_name": "Maria Muster", + "geburtsdatum": "1980-01-01", + }, + _check_mitarbeiter_result, + ), + ], + ids=["intake", "bauvorhaben", "mitarbeiter"], +) +async def test_deploy_and_run( + client, lfx_server, login, fixture_file, flow_name, run_input, check_result +): await login() - doc = json.loads((FIXTURES / "intake_form.json").read_text()) - created = await client.post("/api/flows", json={"name": "intake-e2e"}) + doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8")) + created = await client.post("/api/flows", json={"name": flow_name}) assert created.status_code == 201, created.text flow_id = created.json()["id"] @@ -93,13 +427,148 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login): deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") assert deployed.status_code == 200, deployed.text - run = await client.post( - f"/api/flows/{flow_id}/run", - json={"input": {"full_name": "Maria Muster", "age": 41}}, - ) + run = await client.post(f"/api/flows/{flow_id}/run", json={"input": run_input}) assert run.status_code == 200, run.text assert run.json()["status"] == "completed" - result = json.loads(run.json()["output"]["result"]) - assert result["full_name"] == "Maria Muster" - assert result["age"] == 41 - assert result["department"] == "Music" # default preserved + await check_result(client, flow_id, version.json(), run.json()) + + +def test_embedded_code_matches_template_fields(): + """Node template fields == code-declared inputs (no unasked defaults). + + The envelope tolerates drift at runtime, but authoring must stay + honest: what Langflow shows in the code editor must be what the form + asks. Building blocks: lfx's own create_class on the embedded code. + """ + from lfx.custom import validate as validate_mod + + cases = { + "intake_form.json": ("CustomInputForm-intake", "CustomInputForm"), + "bauvorhaben.json": ("CustomInputBauvorhaben-bauvorhaben", "CustomInputBauvorhaben"), + "mitarbeiter_einstellen.json": ( + "CustomInputMitarbeiter-mitarbeiter", + "CustomInputMitarbeiter", + ), + } + for filename, (node_id, class_name) in cases.items(): + doc = json.loads((FIXTURES / filename).read_text(encoding="utf-8")) + node = next(n for n in doc["data"]["nodes"] if n["id"] == node_id) + assert node["data"]["type"] == class_name, filename + template = node["data"]["node"]["template"] + template_names = { + 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_schema_branches(): + from app.flows import input_schema as parser + + doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8")) + out = parser.extract_form_schema(doc) + schema = out["json_schema"] + assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"} + assert schema["required"] == ["gebaeude"] + 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", + "gussjahr", + "gebaeudeart", + "denkmalnummer", + } + assert by_field["bestandteil"]["if"]["properties"] == {"gebaeude": {"const": "Kirche"}} + assert by_field["glockenmassnahme"]["if"]["properties"] == { + "bestandteil": {"contains": {"const": "Glocke"}} + } + assert by_field["gussjahr"]["if"]["properties"] == {"glockenmassnahme": {"const": "Neuguss"}} + assert by_field["gebaeudeart"]["if"]["properties"] == {"gebaeude": {"const": "Anderes Gebäude"}} + assert by_field["denkmalnummer"]["if"]["properties"] == {"denkmalschutz": {"const": True}} + assert by_field["bestandteil"]["then"]["required"] == ["bestandteil"] + assert by_field["denkmalnummer"]["then"]["required"] == ["denkmalnummer"] + assert "required" not in by_field["gussjahr"]["then"] + # Markers stripped from descriptions. + assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][ + "bestandteil" + ].get("description", "") + order = out["ui_schema"]["ui:order"] + assert order[-1] == "*" + assert set(order[:-1]) == { + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + } + + +def test_mitarbeiter_component_contract(): + from kirche.mitarbeiter_form import CustomInputMitarbeiter + + assert CustomInputMitarbeiter.display_name == "Mitarbeiter Einstellen Form" + inputs = {i.name: i for i in CustomInputMitarbeiter.inputs} + assert sorted(n for n in inputs if n != "form_values") == sorted( + ["rolle", "einstufung", "mitarbeiter_name", "geburtsdatum"] + ) + assert inputs["form_values"].show is False + outputs = {o.name: o for o in CustomInputMitarbeiter.outputs} + assert set(outputs) == {"form_data"} + + +def test_mitarbeiter_collect_branch_default(): + """Einstufung default applies only when Pfarrer:in is selected.""" + from kirche.mitarbeiter_form import CustomInputMitarbeiter + + form = CustomInputMitarbeiter() + assert "einstufung" not in form.collect().data # rolle defaults to Assistenz + form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}}) + assert form.collect().data["einstufung"] == "Stufe 1" diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py index b81e3fd..3591baa 100644 --- a/backend/tests/test_input_schema.py +++ b/backend/tests/test_input_schema.py @@ -4,12 +4,12 @@ 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): @@ -38,14 +38,65 @@ def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"): return {"data": {"nodes": [node], "edges": []}} -def test_type_mapping_table(): +def _dropdown(name, options, **kw): + return _field(name, "DropdownInput", options=options, **kw) + + +def _branch_schema(doc): + schema = parser.extract_form_schema(doc)["json_schema"] + assert "allOf" in schema + return schema + + +@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", @@ -65,16 +116,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", @@ -88,12 +134,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(): @@ -112,7 +152,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(): @@ -151,13 +193,9 @@ def test_real_intake_fixture_parses(): assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"} -def _lfx() -> LfxClient: - return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) - - -async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path): +async def test_sync_caches_schema_per_version(session, lfx_server, lfx_client, tmp_path): (tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text()) - report = await sync.sync_from_directory(session, _lfx(), tmp_path) + report = await sync.sync_from_directory(session, lfx_client, tmp_path) assert not report.errors, report.errors from app.flows import store @@ -192,3 +230,211 @@ async def test_input_schema_endpoint(client, lfx_server, login): assert body["fallback"] is None assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"} assert body["ui_schema"]["password"] == {"ui:widget": "password"} + + +def test_free_rename_keys_kept_verbatim(): + doc = _doc( + _field("name", "StrInput", display_name="Name", required=True), + _field("favorite room", "StrInput"), + ) + schema = parser.extract_form_schema(doc) + assert set(schema["json_schema"]["properties"]) == {"name", "favorite room"} + assert schema["json_schema"]["required"] == ["name"] + + +def test_empty_name_skipped_and_collision_suffixed(): + a = _field("a", "StrInput") + clash = _field("other_key", "StrInput") + clash["name"] = "a" # Langflow rename collision: second field gets a suffix + empty = _field("zzz", "StrInput") + empty["name"] = " " + node = { + "data": { + "type": "CustomInputForm", + "node": { + "display_name": "CustomInput Form", + "template": {"a": a, "other_key": clash, "zzz": empty}, + }, + } + } + doc = {"data": {"nodes": [node], "edges": []}} + props = parser.extract_form_schema(doc)["json_schema"]["properties"] + assert set(props) == {"a", "a_2"} + + +def test_visible_if_single_branch(): + doc = _doc( + _dropdown("gebaeude", ["Kirche", "Halle"], required=True), + _field( + "bestandteil", + "MultiselectInput", + options=["Glocke", "Orgel"], + info="Bitte wählen [visible_if gebaeude=Kirche]", + required=True, + ), + ) + out = parser.extract_form_schema(doc) + schema = out["json_schema"] + # Conditional field lives only in its branch, not at root. + assert set(schema["properties"]) == {"gebaeude"} + assert schema["required"] == ["gebaeude"] + assert len(schema["allOf"]) == 1 + rule = schema["allOf"][0] + assert rule["if"] == { + "properties": {"gebaeude": {"const": "Kirche"}}, + "required": ["gebaeude"], + } + assert rule["then"]["properties"]["bestandteil"]["type"] == "array" + assert rule["then"]["required"] == ["bestandteil"] + # Marker stripped from the shown description. + assert schema["properties"]["gebaeude"]["title"] == "Gebaeude" + order = out["ui_schema"]["ui:order"] + assert order[-1] == "*" + assert set(order[:-1]) == {"gebaeude", "bestandteil"} + + +def test_visible_if_nested_three_levels(): + doc = _doc( + _dropdown("a", ["x", "y"]), + _dropdown("b", ["p", "q"], info="[visible_if a=x]"), + _field("c", "IntInput", info="[visible_if b=p]"), + _field("d", "StrInput", info="[visible_if c=3]"), + ) + schema = _branch_schema(doc) + assert set(schema["properties"]) == {"a"} + assert len(schema["allOf"]) == 3 + by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]} + assert by_field["b"]["if"] == { + "properties": {"a": {"const": "x"}}, + "required": ["a"], + } + assert by_field["c"]["if"]["properties"] == {"b": {"const": "p"}} + assert by_field["d"]["if"]["properties"] == {"c": {"const": 3}} + + +def test_visible_if_and_quoted_bool_multiselect(): + doc = _doc( + _dropdown("a", ["x", "y"]), + _field("flag", "BoolInput"), + _field( + "m", + "MultiselectInput", + options=["Glocke", "Orgel"], + info='[visible_if a="x" & flag=true]', + ), + ) + schema = _branch_schema(doc) + rule = schema["allOf"][0] + assert rule["if"] == { + "properties": {"a": {"const": "x"}, "flag": {"const": True}}, + "required": ["a", "flag"], + } + assert rule["then"]["properties"]["m"]["type"] == "array" + + +def test_visible_if_multiselect_trigger_uses_contains(): + doc = _doc( + _field("tags", "MultiselectInput", options=["Glocke", "Orgel"]), + _field("detail", "StrInput", info="[visible_if tags=Glocke]"), + ) + schema = _branch_schema(doc) + assert schema["allOf"][0]["if"]["properties"] == {"tags": {"contains": {"const": "Glocke"}}} + + +def test_visible_if_dangling_unsupported_cycle_fail_open(): + doc = _doc( + _dropdown("a", ["x", "y"]), + _field("ok", "StrInput", info="[visible_if a=x]"), + _field("dangling", "StrInput", info="[visible_if nope=x]"), + _field("slider_dep", "StrInput", info="[visible_if slid=x]"), + _field( + "slid", + "SliderInput", + value=3, + range_spec={"min": 1, "max": 5, "step": 1}, + ), + _field("c1", "StrInput", info="[visible_if c2=x]"), + _field("c2", "StrInput", info="[visible_if c1=x]"), + _field("selfish", "StrInput", info="[visible_if selfish=x]"), + ) + schema = parser.extract_form_schema(doc)["json_schema"] + # Only the valid marker produces a branch; the rest stay at root. + assert len(schema["allOf"]) == 1 + assert set(schema["properties"]) >= { + "a", + "dangling", + "slider_dep", + "slid", + "c1", + "c2", + "selfish", + } + assert "ok" not in schema["properties"] + + +def test_no_markers_no_allof(): + doc = _doc(_dropdown("a", ["x", "y"])) + schema = parser.extract_form_schema(doc)["json_schema"] + assert "allOf" not in schema + assert set(schema["properties"]) == {"a"} + + +def test_custom_component_form_display_match(): + """User-built * Form components parse without a kirche base class.""" + doc = _doc( + _field("mitarbeiter_name", "StrInput", info="Vor- und Nachname"), + _dropdown("rolle", ["Assistenz", "Pfarrer:in"]), + node_type="CustomComponent", + display="Mitarbeiter Einstellen Form", + ) + schema = parser.extract_form_schema(doc) + assert "fallback" not in schema + props = schema["json_schema"]["properties"] + assert set(props) == {"mitarbeiter_name", "rolle"} + assert props["rolle"]["enum"] == ["Assistenz", "Pfarrer:in"] + assert props["mitarbeiter_name"]["description"] == "Vor- und Nachname" + + +def test_custom_component_non_form_falls_back(): + doc = _doc( + _field("payload", "StrInput"), + node_type="CustomComponent", + display="Rule Check", + ) + assert parser.extract_form_schema(doc)["fallback"] == "textarea" + + +def test_real_mitarbeiter_file_parses(): + path = Path(__file__).parent.parent / "flows" / "mitarbeiter_einstellen.json" + doc = json.loads(path.read_text(encoding="utf-8")) + schema = parser.extract_form_schema(doc) + assert "fallback" not in schema + props = schema["json_schema"]["properties"] + assert set(props) == {"rolle", "mitarbeiter_name", "geburtsdatum"} + assert schema["json_schema"]["required"] == ["rolle", "mitarbeiter_name"] + # Einstufung is branched (visible only for Pfarrer:in). + assert len(schema["json_schema"]["allOf"]) == 1 + rule = schema["json_schema"]["allOf"][0] + assert rule["if"]["properties"] == {"rolle": {"const": "Pfarrer:in"}} + assert set(rule["then"]["properties"]) == {"einstufung"} + + +def test_schema_field_names_covers_branches(): + doc = _doc( + _dropdown("a", ["x", "y"]), + _dropdown("b", ["p", "q"], info="[visible_if a=x]"), + _field("c", "IntInput", info="[visible_if b=p]"), + ) + cached = parser.extract_form_schema(doc) + assert parser.schema_field_names(cached) == ["a", "b", "c"] + plain = parser.extract_form_schema( + { + "data": { + "nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}], + "edges": [], + } + } + ) + assert parser.schema_field_names(plain) == [] + assert parser.schema_field_names({}) == [] + assert parser.schema_field_names({"json_schema": None}) == [] diff --git a/backend/tests/test_lfx_validate.py b/backend/tests/test_lfx_validate.py index 393cd71..cfdcef0 100644 --- a/backend/tests/test_lfx_validate.py +++ b/backend/tests/test_lfx_validate.py @@ -9,11 +9,13 @@ import subprocess import sys from pathlib import Path +from app.flows import export + FLOW_DIR = Path(__file__).resolve().parent.parent / "flows" def test_canonical_flows_pass_lfx_validate(): - files = sorted(FLOW_DIR.glob("*.json")) + files = export.iter_flow_files(FLOW_DIR) assert files, "no canonical flows found" process = subprocess.run( [ 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..959ffaf 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,5 +1,7 @@ """Model-level tests: defaults, constraints, JSON roundtrip, linkage.""" +import uuid + import pytest from sqlalchemy.exc import IntegrityError from sqlmodel import select @@ -8,6 +10,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 +23,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): @@ -82,3 +88,48 @@ def test_run_and_task_linkage_with_tenant_default(session): tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all() assert len(tasks) == 1 assert tasks[0].payload == {} + + +def test_create_version_populates_input_schema(session): + from app.flows import store + from app.flows.schemas import FlowVersionCreate + + d = FlowDefinition(name="cached") + session.add(d) + session.commit() + doc = { + "name": "cached", + "data": { + "nodes": [ + { + "id": "n1", + "data": { + "type": "ChatInput", + "node": {"display_name": "Chat", "template": {}}, + }, + } + ], + "edges": [], + }, + } + version = store.create_version(session, d.id, FlowVersionCreate(langflow_json=doc)) + assert version.input_schema["fallback"] == "textarea" + + +def test_input_schema_null_rejected(session): + from sqlalchemy import text + from sqlalchemy.exc import IntegrityError + + d = FlowDefinition(name="nonnull") + session.add(d) + session.commit() + with pytest.raises(IntegrityError): + session.execute( + text( + "INSERT INTO flow_versions (id, flow_id, version_no, langflow_json," + " author, input_schema) VALUES (:id, :flow_id, 1, :doc, :author, NULL)" + ), + {"id": str(uuid.uuid4()), "flow_id": str(d.id), "doc": "{}", "author": "t"}, + ) + session.commit() + session.rollback() diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index 6c582b4..ef8bb01 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -9,30 +9,22 @@ from pathlib import Path import pytest from app.flows import store, sync -from app.flows.lfx_client import LfxClient, LfxNotFound +from app.flows.lfx_client import LfxNotFound 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 -async def test_sync_adds_new_flow(session, lfx_server, tmp_path): +async def test_sync_adds_new_flow(session, lfx_server, lfx_client, tmp_path): _seed_dir(tmp_path) - report = await sync.sync_from_directory(session, _lfx(), tmp_path) + report = await sync.sync_from_directory(session, lfx_client, tmp_path) assert report.added == ["simple_rule"] assert report.errors == {} definition = store.get_definition_by_source_path(session, "simple_rule.json") @@ -44,75 +36,83 @@ async def test_sync_adds_new_flow(session, lfx_server, tmp_path): assert versions[0].deployed_at is not None -async def test_sync_skips_unchanged(session, lfx_server, tmp_path): +async def test_sync_skips_ignored_files(session, lfx_server, lfx_client, tmp_path): _seed_dir(tmp_path) - first = await sync.sync_from_directory(session, _lfx(), tmp_path) + (tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8") + report = await sync.sync_from_directory(session, lfx_client, tmp_path) + assert report.added == [] and report.errors == {} + assert store.get_definition_by_source_path(session, "simple_rule.json") is None + + +async def test_sync_skips_unchanged(session, lfx_server, lfx_client, tmp_path): + _seed_dir(tmp_path) + first = await sync.sync_from_directory(session, lfx_client, tmp_path) assert first.added == ["simple_rule"] - second = await sync.sync_from_directory(session, _lfx(), tmp_path) + second = await sync.sync_from_directory(session, lfx_client, tmp_path) assert second.skipped == ["simple_rule"] assert second.added == second.updated == [] definition = store.get_definition_by_source_path(session, "simple_rule.json") assert len(store.list_versions(session, definition.id)) == 1 -async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, tmp_path): +async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, lfx_client, tmp_path): _seed_dir(tmp_path) - await sync.sync_from_directory(session, _lfx(), tmp_path) + await sync.sync_from_directory(session, lfx_client, tmp_path) definition = store.get_definition_by_source_path(session, "simple_rule.json") old_lfx_id = lfx_id_for(definition.id, 1) - doc = load_fixture("simple_rule.json") + doc = load_flow_fixture("simple_rule.json") doc["description"] = "geaendert" (tmp_path / "simple_rule.json").write_text(json.dumps(doc)) - report = await sync.sync_from_directory(session, _lfx(), tmp_path) + report = await sync.sync_from_directory(session, lfx_client, tmp_path) assert report.updated == ["simple_rule"] versions = store.list_versions(session, definition.id) assert [v.version_no for v in versions] == [1, 2] assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2) # Superseded LFX registration is gone. with pytest.raises(LfxNotFound): - await _lfx().flow_info(old_lfx_id) + await lfx_client.flow_info(old_lfx_id) -async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, tmp_path): +async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, lfx_client, tmp_path): _seed_dir(tmp_path) - await sync.sync_from_directory(session, _lfx(), tmp_path) + await sync.sync_from_directory(session, lfx_client, tmp_path) definition = store.get_definition_by_source_path(session, "simple_rule.json") version = store.list_versions(session, definition.id)[0] run = store.create_run(session, version, input={"a": 1}, tenant_id="default") lfx_id = lfx_id_for(definition.id, 1) (tmp_path / "simple_rule.json").unlink() - report = await sync.sync_from_directory(session, _lfx(), tmp_path) + report = await sync.sync_from_directory(session, lfx_client, tmp_path) assert report.deleted == ["simple_rule"] assert store.get_definition_by_source_path(session, "simple_rule.json") is None with pytest.raises(store.NotFoundError): store.get_run(session, run.id) with pytest.raises(LfxNotFound): - await _lfx().flow_info(lfx_id) + await lfx_client.flow_info(lfx_id) -async def test_sync_reports_invalid_json(session, lfx_server, tmp_path): +async def test_sync_reports_invalid_json(session, lfx_server, lfx_client, tmp_path): (tmp_path / "kaputt.json").write_text("{kein json") - report = await sync.sync_from_directory(session, _lfx(), tmp_path) + report = await sync.sync_from_directory(session, lfx_client, tmp_path) assert "kaputt.json" in report.errors assert report.added == [] assert store.list_definitions(session) == [] -async def test_sync_missing_dir_raises(session, lfx_server, tmp_path): +async def test_sync_missing_dir_raises(session, lfx_server, lfx_client, tmp_path): with pytest.raises(FileNotFoundError): - await sync.sync_from_directory(session, _lfx(), tmp_path / "gibts-nicht") + await sync.sync_from_directory(session, lfx_client, tmp_path / "gibts-nicht") -async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path): +async def test_sync_rename_updates_definition_name(session, lfx_server, lfx_client, tmp_path): _seed_dir(tmp_path) - await sync.sync_from_directory(session, _lfx(), tmp_path) - doc = load_fixture("simple_rule.json") + await sync.sync_from_directory(session, lfx_client, tmp_path) + doc = load_flow_fixture("simple_rule.json") doc["name"] = "umbenannt" (tmp_path / "simple_rule.json").write_text(json.dumps(doc)) - report = await sync.sync_from_directory(session, _lfx(), tmp_path) + report = await sync.sync_from_directory(session, lfx_client, tmp_path) assert report.updated == ["simple_rule"] definition = store.get_definition_by_source_path(session, "simple_rule.json") assert definition.name == "umbenannt" @@ -136,107 +136,100 @@ async def test_sync_endpoint_returns_report( assert response.json()["skipped"] == ["simple_rule"] -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, - } - - -async def test_sync_endpoint_pulls_from_langflow_then_syncs( - client, lfx_server, tmp_path, monkeypatch, login, override_langflow +@pytest.mark.parametrize( + ("seed", "flow_name", "flow_id", "extra_flows", "extra_docs", "expected_file"), + [ + ( + None, + "Gehaltserhöhung", + "22222222-3333-4444-8555-666666666666", + [], + {}, + "Gehaltserhöhung.json", + ), + ( + # Seeded from simple_rule.json: updates in place instead of + # duplicating as simple-rule.json (slug drift). + "simple_rule.json", + "simple_rule", + "44444444-5555-4666-8777-888888888888", + [], + {}, + "simple_rule.json", + ), + ( + None, + "Gut", + "33333333-4444-4555-8666-777777777777", + [{"id": "kaputt-id", "name": "Kaputt"}], + {}, + "Gut.json", + ), + ], + ids=["pull-new", "pull-reuse", "pull-error-continues"], +) +async def test_sync_endpoint_pull_matrix( + client, + lfx_server, + tmp_path, + monkeypatch, + login, + override_langflow, + seed, + flow_name, + flow_id, + extra_flows, + extra_docs, + expected_file, ): await login() monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - langflow_id = "22222222-3333-4444-8555-666666666666" + if seed is not None: + _seed_dir(tmp_path, seed) override_langflow( - flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}], - docs={langflow_id: _langflow_doc("Gehaltserhöhung", langflow_id)}, + flows=[{"id": flow_id, "name": flow_name}, *extra_flows], + docs={flow_id: langflow_export_doc(flow_name, flow_id), **extra_docs}, ) response = await client.post("/api/flows/sync") assert response.status_code == 200, response.text body = response.json() - assert body["pulled"] == ["Gehaltserhöhung"] - assert body["added"] == ["Gehaltserhöhung"] - assert body["errors"] == {} - assert (tmp_path / "Gehaltserhöhung.json").is_file() + assert body["pulled"] == [flow_name] + assert body["added"] == [flow_name] + assert (tmp_path / expected_file).is_file() + if flow_name == "Gut": + assert "kaputt-id" in body["errors"] + else: + assert body["errors"] == {} + assert not (tmp_path / "simple-rule.json").exists() # Second pass: same content pulls again but syncs nothing new. response = await client.post("/api/flows/sync") assert response.status_code == 200, response.text body = response.json() - assert body["pulled"] == ["Gehaltserhöhung"] - assert body["skipped"] == ["Gehaltserhöhung"] + assert body["pulled"] == [flow_name] + assert body["skipped"] == [flow_name] -async def test_sync_endpoint_pull_reuses_same_named_file( - 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, ): - """A pulled flow seeded from simple_rule.json updates it in place - instead of duplicating it as simple-rule.json (slug drift).""" + """Listing failures map to 503/502 (parametrized).""" await login() monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - _seed_dir(tmp_path) # fixture shape, name "simple_rule" - langflow_id = "44444444-5555-4666-8777-888888888888" - override_langflow( - flows=[{"id": langflow_id, "name": "simple_rule"}], - docs={langflow_id: _langflow_doc("simple_rule", langflow_id)}, - ) + override_langflow(flows=[], list_status=list_status) response = await client.post("/api/flows/sync") - assert response.status_code == 200, response.text - body = response.json() - assert body["pulled"] == ["simple_rule"] - assert body["added"] == ["simple_rule"] - assert body["errors"] == {} - assert not (tmp_path / "simple-rule.json").exists() - - response = await client.post("/api/flows/sync") - assert response.json()["skipped"] == ["simple_rule"] - - -async def test_sync_endpoint_pull_error_continues( - client, lfx_server, tmp_path, monkeypatch, login, override_langflow -): - await login() - monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - good_id = "33333333-4444-4555-8666-777777777777" - override_langflow( - flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}], - docs={good_id: _langflow_doc("Gut", good_id)}, - ) - response = await client.post("/api/flows/sync") - assert response.status_code == 200, response.text - body = response.json() - assert body["pulled"] == ["Gut"] - assert body["added"] == ["Gut"] - assert "kaputt-id" in body["errors"] - - -async def test_sync_endpoint_langflow_unreachable( - client, lfx_server, tmp_path, monkeypatch, login, override_langflow -): - await login() - monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - override_langflow(flows=[], list_status=0) - 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/docs/flow-authoring.md b/docs/flow-authoring.md new file mode 100644 index 0000000..fd9848b --- /dev/null +++ b/docs/flow-authoring.md @@ -0,0 +1,33 @@ +# Flow authoring (Langflow → Webapp) + +So kommt ein neuer Flow in die Webapp: + +1. **In Langflow entwerfen und speichern** (bestehende Flows sind dort + bereits geladen). Name vergeben, speichern. +2. **Sync-Button in der Verwaltung klicken** ("Aus Langflow + synchronisieren"): holt alle Flows aus Langflow nach + `backend/flows/*.json` und deployed sie danach automatisch (neue + Version nur bei geändertem Inhalt, sonst übersprungen). +3. **Prüfen:** Die Versions-Tabelle zeigt Version, Autor und Status. + Danach erscheint der Flow automatisch auf der Seite Workflows mit + seinem Formular. Flows ohne Formular-Knoten zeigen ein Freitext-Feld. + +## Regeln + +- Die Datei heißt wie der Flow in Langflow (z. B. `intake_form.json`). + Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt + bestehen und muss bei Bedarf gelöscht werden. +- Eine gelöschte Datei löscht den Flow inklusive seiner Läufe + (Datei bleibt in Git erhalten). +- Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON). + Der automatische Beobachter im Hintergrund syncet nur Dateien, + Langflow holt nur dieser Button. + +## Formular-Felder + +Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: +jeder Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename += Label). Die Verbindung ChatInput nach `form_values` bitte behalten — +darüber kommen die ausgefüllten Werte an. Unbekannte Feldtypen fallen +automatisch auf ein Textfeld zurück. Details: +`backend/components/kirche/README.md`. diff --git a/docs/new_workflow_module.md b/docs/new_workflow_module.md new file mode 100644 index 0000000..2ff249a --- /dev/null +++ b/docs/new_workflow_module.md @@ -0,0 +1,101 @@ +# New workflow module: form flow end to end + +How to add a form-based workflow (web form → run → result), learned from +building intake, Bauvorhaben, and Mitarbeiter-Einstellen. Three layers: +component (Langflow palette), flow (canvas), backend parsing (automatic). + +## 1. Form component (`backend/components/kirche/_form.py`) + +Copy `mitarbeiter_form.py` (small) or `bauvorhaben_form.py` (branching). +Rename the class with a **`CustomInput*` prefix** (e.g. +`CustomInputMitarbeiter`) — the backend parser matches that prefix; a +plain `CustomComponent` name additionally needs a display name ending +in `"Form"`. + +Rules, all load-bearing: + +- **Self-contained**: stdlib + `lfx` only. NEVER import from sibling + form modules — LFX executes the embedded module source without our + package, and Langflow round-trips the code. Duplicated helpers are + structural, not laziness. +- **Inputs**: one `lfx.io` entry per question (`name` = stable JSON key, + `display_name` = label, `info` = help, `options`/`value`/`required` + as needed) plus the hidden `form_values` plumbing input (reserved + name — never use it for a field). +- **Branching**: `[visible_if feld=Wert]` markers in `info` + (`&` for AND, quotes for spaces). Any depth; trigger types: + Dropdown/Text `const`, Bool, Multiselect `contains`, Int/Float exact. + Stale markers, cycles, and unknown triggers fail open (always visible). +- **Required sentinels**: required fields with empty values carry + `" "` (`[" "]` for Multiselect — a plain string crashes LFX at build, + `[]` fails validation). The parser treats them as "no default". +- **Outputs**: exactly `Output(name="form_data", method="collect")` + returning `Data`. Do not touch `collect()` — it is fully dynamic + (asked + visible defaults, submitted values win). +- Export in `backend/components/kirche/__init__.py`. + +Then: `docker compose restart langflow` (components load at startup), +check the palette via `GET /api/v1/all` (or sidebar search, hard-refresh +the browser tab if the entry is missing — the API is source of truth). + +## 2. Flow JSON (`backend/flows/.json`) + +Never hand-edit: add `build_()` in +`backend/scripts/make_flow_fixtures.py` and run it (from `backend/`). + +- Form node via `kirche_node(node_id, component_cls=..., code_override=...)`. +- Logic nodes via `custom_node(CODE, ...)` (keep return annotations like + `-> Message`: edge handles derive output types from them). +- Always wire `ChatInput → form_values` (run envelope), then + `form_data → … → ChatOutput`. +- Reorder template keys to question order (lfx emits alphabetical; RJSF + renders insertion order, and `ui:order` follows it). +- The script writes fixtures + canonical files; `git checkout` the churn + on unrelated flows, keep canonical files CRLF like the repo. +- Custom logic code (e.g. salary math) lives as a `*_CODE` constant in + the script — one owner for template and code. + +## 3. Backend parsing (automatic, know the contract) + +`app/flows/input_schema.py` turns the node template into +`{json_schema, ui_schema}` per version: `name` → key (verbatim; empty +skipped, collisions suffixed), `display_name`/`info`/`value`/`options` +→ title/description/default/enum, `show: false` skipped, unknown types +degrade to text. Markers become `allOf`/`if`/`then` branches; +branch-`required` applies only while visible. + +Runs carry an envelope (`payload`, `fields` = asked names from the +cached schema, run context). `collect()` emits defaults only for asked +AND visible fields; submitted values always win; hidden values are kept +but never inventoried. Raw dicts and the Langflow playground use all +visible declared defaults. + +## 4. Langflow round-trip (where flows go to die, if careless) + +- Node **type names must be distinct per component** — Langflow + normalizes same-type nodes to the installed class on import and + silently discards variant templates. +- Upload via `POST /api/v1/flows/upload/`, then fetch the flow back and + assert node type, template fields, and code are intact. +- Restart re-seeds `backend/flows/` into Langflow; the sync button pulls + Langflow → files **matched by flow name, overwriting silently**. + Commit before syncing, review the `pulled` report, never rename or + repurpose flows casually. +- Experiments that must stay out: list their stem in + `backend/flows/.flowsignore` (skipped by sync, deploy, validation, + frontend, and future pulls). + +## 5. Verify before reporting done + +- `uv run pytest` (full suite incl. real-LFX deploy+run e2e) green. +- `uv run ruff check .` clean, `uv run ruff format --check .` clean. +- `uv run lfx validate --level 4 tests/fixtures/flows/` all pass. +- Live: `GET /api/flows/{id}/input-schema` shows `fallback: null` + with expected properties (cached schemas refresh only on new + versions — unchanged files keep their cached schema). +- Browser click-through in Workflows, including a branched path and an + empty submit (branch-required errors). + +New behavior ships with tests; schema changes ship as Alembic revisions +(never edit applied migrations); commit on `feat/`, never +directly on `main`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0221c78..333ebf8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,21 +8,29 @@ "name": "kirche-einfach-frontend", "version": "0.1.0", "dependencies": { - "@mantine/core": "^9.6.1", - "@mantine/hooks": "^9.6.1", + "@mantine/core": "^9.6.3", + "@mantine/dates": "^9.6.3", + "@mantine/form": "^9.6.3", + "@mantine/hooks": "^9.6.3", + "@mantine/notifications": "^9.6.3", "@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", @@ -855,9 +1373,9 @@ } }, "node_modules/@mantine/core": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.1.tgz", - "integrity": "sha512-UkcPo8Zq0uSdodvyXV62HVmfIfHCcrjFrFrH0TggTy6Wtp8a78znGP8RJOz8zC9XSPpJmKjBQjg0M+nOvIzftA==", + "version": "9.6.3", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.3.tgz", + "integrity": "sha512-o9xzpkma6RiLbENNU4VoxKnCU4VaFlB6Yh6wAjAUiU329ypPV6tBY7VkVeQQ5Z+e12D3I+NTPrUw43onlYCqTg==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.20", @@ -867,32 +1385,70 @@ "type-fest": "^5.8.0" }, "peerDependencies": { - "@mantine/hooks": "9.6.1", + "@mantine/hooks": "9.6.3", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "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", - "integrity": "sha512-pfxM95WozngB0ZALw6h99QV1TQ0qniOkFhzi0+dmSnKpxkVSR0xeNsvPpGDj9Teu+M69fRoE4IkzyMnu5Xld3w==", + "version": "9.6.3", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.3.tgz", + "integrity": "sha512-b3uJx1p5vlXOmLEgp4OqzC3FUxSIc5bmqPPxPLHsgR6S0aBiqn9psaihJP7u/v//mr3qeCmyMeIwSw9ROu4pXg==", + "license": "MIT", + "peerDependencies": { + "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" @@ -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..74e5568 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,21 +11,29 @@ "icons": "node scripts/icons.mjs" }, "dependencies": { - "@mantine/core": "^9.6.1", - "@mantine/hooks": "^9.6.1", + "@mantine/core": "^9.6.3", + "@mantine/dates": "^9.6.3", + "@mantine/form": "^9.6.3", + "@mantine/hooks": "^9.6.3", + "@mantine/notifications": "^9.6.3", "@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 deleted file mode 100644 index b36dddb..0000000 --- a/frontend/scripts/fetch-photos.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Fetch landing-page photos (one-time + re-runnable). - -Downloads royalty-free originals (see SOURCES.md next to the photos), -resizes to max 1600px wide and stores them as WebP q80 in -frontend/public/photos/ (self-hosted: no hotlinking, no tracking). - -Run in the api container (has network + Pillow): - docker compose exec api /opt/venv/bin/python \\ - /srv/kirche-einfach/frontend/scripts/fetch-photos.py -""" - -from __future__ import annotations - -import sys -import urllib.request -from io import BytesIO -from pathlib import Path - -try: - from PIL import Image -except ImportError: - sys.exit("Pillow required (present in the api image)") - -ROOT = Path(__file__).resolve().parent.parent.parent -OUT = ROOT / "frontend" / "public" / "photos" - - -def pexels(photo_id: int) -> str: - return ( - f"https://images.pexels.com/photos/{photo_id}/" - f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600" - ) - - -PHOTOS = { - # target file -> (direct file URL, source page for SOURCES.md) - "hero-church.webp": ( - pexels(31630127), - "https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/", - ), - "church-pews.webp": ( - pexels(7566207), - "https://www.pexels.com/photo/church-pews-7566207/", - ), - "paperwork-desk.webp": ( - 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/", - ), -} - - -def fetch(url: str) -> bytes: - req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"}) - with urllib.request.urlopen(req, timeout=120) as response: - return response.read() - - -def main() -> None: - OUT.mkdir(parents=True, exist_ok=True) - for filename, (url, _page) in PHOTOS.items(): - raw = fetch(url) - with Image.open(BytesIO(raw)) as img: - img = img.convert("RGB") - if img.width > 1600: - img = img.resize( - (1600, round(img.height * 1600 / img.width)), Image.LANCZOS - ) - target = OUT / Path(filename).with_suffix(".webp").name - img.save(target, "WEBP", quality=80, method=6) - print(f"wrote {target} ({target.stat().st_size // 1024} KB)") - - -if __name__ == "__main__": - main() 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 e693c20..aee7dc2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,19 @@ /* Typed client for the kirche-einfach API (served via vite /api proxy). */ +/** All statuses the backend can report (single color language in StatusBadge). */ +export type Status = + | "completed" + | "approved" + | "genehmigt" + | "deployed" + | "running" + | "open" + | "pending" + | "pending_approval" + | "failed" + | "rejected" + | "abgelehnt"; + export interface FlowDefinition { id: string; name: string; @@ -33,7 +47,7 @@ export interface Run { id: string; version_id: string; tenant_id: string; - status: string; + status: Status; input: unknown; output: RunOutput | null; session_id: string | null; @@ -50,7 +64,7 @@ export interface ApprovalTask { payload: unknown; continuation_flow_id: string | null; approver: string | null; - status: string; + status: Status; decision_note: string; created_at: string; decided_at: string | null; @@ -61,6 +75,11 @@ export interface Decision { continuation_run: Run | null; } +export interface NamedRun { + flow_name: string; + run: Run; +} + export interface SyncReport { added: string[]; updated: string[]; @@ -83,7 +102,7 @@ export interface AccessRequest { name: string; email: string; reason: string; - status: string; + status: Status; created_at: string; decided_at: string | null; decided_by: string | null; @@ -153,6 +172,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`, { @@ -202,10 +222,10 @@ export const api = { method: "POST", body: JSON.stringify({ name, email, reason }), }), - listAccessRequests: (status?: string) => - req( - `/api/access/requests${status ? `?status=${status}` : ""}`, - ), + listAccessRequests: (status?: string) => { + const qs = new URLSearchParams(status ? { status } : {}).toString(); + return req(`/api/access/requests${qs ? `?${qs}` : ""}`); + }, decideAccess: (id: string, approve: boolean) => req<{ request: AccessRequest; email_sent: boolean }>( `/api/access/requests/${id}/${approve ? "approve" : "reject"}`, 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 c695386..30e7b30 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,113 @@ 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 { useQuery, useQueryClient } from "@tanstack/react-query"; +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(), +}); + +async function fetchVersions( + flows: FlowDefinition[], +): Promise> { + const entries = await Promise.all( + flows.map(async (f) => [f.id, await api.listVersions(f.id)] as const), + ); + return Object.fromEntries(entries); +} + export default function AdminTab() { - const [flows, setFlows] = useState([]); - const [versions, setVersions] = useState>({}); - const [langflowId, setLangflowId] = useState(""); - const [importName, setImportName] = useState(""); + const queryClient = useQueryClient(); + 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 () => { - try { - const list = await api.listFlows(); - setFlows(list); - const all: Record = {}; - for (const f of list) { - all[f.id] = await api.listVersions(f.id); - } - setVersions(all); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - }, []); + function notifyInfo(message: string) { + notifications.show({ message, color: "green" }); + } + const flowsQuery = useQuery({ + queryKey: ["flows"], + queryFn: api.listFlows, + }); + const flows = flowsQuery.data ?? []; + const versionsQuery = useQuery({ + queryKey: ["versions", flows.map((f) => f.id)], + queryFn: () => fetchVersions(flows), + enabled: flows.length > 0, + }); + const versions = versionsQuery.data ?? {}; useEffect(() => { - void load(); - }, [load]); + const e = flowsQuery.error ?? versionsQuery.error; + if (e) setError(formatApiError(e)); + }, [flowsQuery.error, versionsQuery.error]); + + async function reload() { + await queryClient.invalidateQueries({ queryKey: ["flows"] }); + await queryClient.invalidateQueries({ queryKey: ["versions"] }); + } 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(""); - await load(); + importForm.reset(); + await reload(); } 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})`); - await load(); + notifyInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`); + await reload(); } 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 +127,10 @@ export default function AdminTab() { const errs = Object.entries(r.errors) .map(([f, e]) => `${f}: ${e}`) .join("; "); - setInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`); - await load(); + notifyInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`); + await reload(); } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + setError(formatApiError(e)); } } @@ -110,16 +139,7 @@ export default function AdminTab() { Flows & Versionen - {error && ( - } mb="md"> - {error} - - )} - {info && ( - } mb="md"> - {info} - - )} + Flows werden visuell in Langflow entworfen:{" "} @@ -130,20 +150,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")} /> @@ -179,40 +197,67 @@ export default function AdminTab() { - {flows.map((f) => - (versions[f.id] ?? []).map((v, i) => ( - + {flows.map((f) => { + const list = versions[f.id] ?? []; + const latest = list[list.length - 1]; + if (!latest) { + return ( + + + {f.name}{" "} + + {f.source_path ? `(${f.source_path})` : "(API)"} + + + – + + + + – + + + ); + } + const live = list + .filter((v) => v.deployed_at && v.id !== latest.id) + .at(-1); + return ( + - {i === 0 && ( - <> - {f.name}{" "} - - {f.source_path ? `(${f.source_path})` : "(API)"} - - + {f.name}{" "} + + {f.source_path ? `(${f.source_path})` : "(API)"} + + + + v{latest.version_no} + {live && ( + + {" "} + (live: v{live.version_no}) + )} - v{v.version_no} - {v.deployed_at ? ( + {latest.deployed_at ? ( ) : ( )} - {v.author || "–"} + {latest.author || "–"} - )), - )} + ); + })} )} 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/ErrorAlert.tsx b/frontend/src/components/ErrorAlert.tsx new file mode 100644 index 0000000..401e1c4 --- /dev/null +++ b/frontend/src/components/ErrorAlert.tsx @@ -0,0 +1,11 @@ +import { Alert } from "@mantine/core"; +import { IconAlertCircle } from "@tabler/icons-react"; + +export function ErrorAlert({ message }: { message: string | null }) { + if (!message) return null; + return ( + }> + {message} + + ); +} diff --git a/frontend/src/components/FlowHelp.tsx b/frontend/src/components/FlowHelp.tsx index 1b82010..5866554 100644 --- a/frontend/src/components/FlowHelp.tsx +++ b/frontend/src/components/FlowHelp.tsx @@ -1,12 +1,5 @@ import { useState } from "react"; -import { - Anchor, - Button, - Card, - List, - Text, - Title, -} from "@mantine/core"; +import { Anchor, Button, Card, List, Text, Title } from "@mantine/core"; import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react"; import { LANGFLOW_URL } from "../api"; @@ -23,70 +16,46 @@ export default function FlowHelp() { {open ? "Anleitung verbergen" : "Anleitung: Neuer Flow in Langflow"} {open && ( -
- - So kommt ein neuer Flow in die Webapp - - - - - 1. In Langflow entwerfen und speichern:{" "} - - - Langflow öffnen - - . Die bestehenden Flows sind dort bereits geladen. Name vergeben, speichern. - - - - 2. Sync-Button unten klicken:{" "} - - holt alle Flows aus Langflow nach{" "} +
+ + So kommt ein neuer Flow in die Webapp + + + + + 1. In Langflow entwerfen und speichern:{" "} + + + Langflow öffnen + + . Die bestehenden Flows sind dort bereits geladen. + + + + 2. Sync-Button unten klicken:{" "} + + holt alle Flows nach{" "} + + backend/flows/*.json + {" "} + und deployed sie automatisch. + + + + 3. Prüfen:{" "} + + danach erscheint der Flow auf der Seite Workflows. Flows ohne + Formular-Knoten zeigen ein Freitext-Feld. + + + + Regeln, Formular-Felder und Sync-Sicherheit: siehe{" "} - backend/flows/*.json + docs/flow-authoring.md {" "} - und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt, - sonst übersprungen). - - - - 3. Prüfen:{" "} - - Tabelle unten zeigt Version, Autor und Status. Danach erscheint der Flow - automatisch auf der Seite Workflows mit seinem Formular. Flows ohne - Formular-Knoten zeigen ein Freitext-Feld. - - - - Regeln - - - - Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json). - Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt - bestehen und muss bei Bedarf gelöscht werden. - - - Eine gelöschte Datei löscht den Flow inklusive seiner Läufe - (Datei bleibt in Git erhalten). - - - Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON). - Der automatische Beobachter im Hintergrund syncet nur Dateien, - Langflow holt nur dieser Button. - - - - Formular-Felder - - - Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: jeder - Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename = Label). - Die Verbindung ChatInput nach form_values bitte behalten — darüber kommen - die ausgefüllten Werte an. Unbekannte Feldtypen fallen automatisch auf ein - Textfeld zurück. - -
+ im Repo. + +
)} ); 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 new file mode 100644 index 0000000..3c42eeb --- /dev/null +++ b/frontend/src/components/RunsTable.tsx @@ -0,0 +1,42 @@ +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, + flowName, +}: { + runs: Run[]; + flowName?: (run: Run) => string; +}) { + return ( + + + + Status + {flowName && Flow} + Ergebnis + Erstellt + + + + {runs.map((r) => ( + + + + + {flowName && {flowName(r)}} + + + + + + + + ))} + +
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 645c325..9b5e15f 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,8 +1,9 @@ import { Badge } from "@mantine/core"; +import type { Status } from "../api"; /* Single color language for every status in the app. */ -const COLORS: Record = { +const COLORS: Record = { completed: "green", approved: "green", genehmigt: "green", @@ -16,6 +17,6 @@ const COLORS: Record = { abgelehnt: "red", }; -export default function StatusBadge({ status }: { status: string }) { - return {status}; +export default function StatusBadge({ status }: { status: Status }) { + return {status}; } 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")} />