diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ace98a..a0356b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- 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 Flows aus Langflow nach `backend/flows/*.json` und deployed sie danach diff --git a/README.md b/README.md index 1c09b1c..8a7edcf 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,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 diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index eaca507..eee8e4b 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -317,6 +317,9 @@ async def _run_stream( callback_base_url=settings.callback_base_url, continuation_flow_id=definition.continuation_flow_id, callback_token=settings.callback_token, + fields=form_schema.schema_field_names( + version.input_schema or form_schema.extract_form_schema(version.langflow_json) + ), ) run_id = run.id lfx_flow_id = version.lfx_flow_id 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..7910269 100644 --- a/backend/app/flows/export.py +++ b/backend/app/flows/export.py @@ -15,6 +15,24 @@ 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" + + +def ignored_stems(flows_dir: str | Path) -> set[str]: + """Stems excluded from the pipeline (empty when no ignore file).""" + try: + text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8") + except OSError: + return set() + return { + line.strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("#") + } + def filename_stem(name: str) -> str: """Map a flow name to a filename stem, keeping the original name. @@ -113,6 +131,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..2e072a4 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,10 @@ 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. A flow without such a node gets ``{fallback: "textarea"}``: the frontend renders one free-text box instead of a generated form. @@ -20,9 +29,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 +56,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 +135,106 @@ 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) -> tuple[str, list[tuple[str, object]]]: + """(marker-free description, conditions) for a field's ``info`` text.""" + conditions: list[tuple[str, object]] = [] + + 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 +245,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 "")) + 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/runner.py b/backend/app/flows/runner.py index 4c894b9..7c81ee3 100644 --- a/backend/app/flows/runner.py +++ b/backend/app/flows/runner.py @@ -9,6 +9,7 @@ from sqlmodel import Session, select from app.models import ApprovalTask, FlowRun, FlowVersion +from . import input_schema as form_schema from . import store from .lfx_client import LfxClient, LfxRunFailed from .store import InvalidError @@ -21,16 +22,21 @@ 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: @@ -66,12 +72,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 or form_schema.extract_form_schema(version.langflow_json) 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( diff --git a/backend/app/flows/sync.py b/backend/app/flows/sync.py index 3ec3cd5..ad520a2 100644 --- a/backend/app/flows/sync.py +++ b/backend/app/flows/sync.py @@ -211,7 +211,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( diff --git a/backend/components/kirche/README.md b/backend/components/kirche/README.md index ba7a06f..95b050f 100644 --- a/backend/components/kirche/README.md +++ b/backend/components/kirche/README.md @@ -8,44 +8,186 @@ 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. + +## Branched questionnaires (`visible_if`) + +A field can appear only when other fields have certain values — nested +to any depth. Declare it in the code editor with a marker in `info`: + +```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) + +A flow whose fields differ from `CustomInputForm` gets its own +standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form" +in the palette): same class pattern, own `inputs` list. Standalone +means it: no imports between form modules — the LFX runtime executes +the embedded module source without our `kirche` package, so every form +component must be self-contained (stdlib + `lfx` only). Copy the file +as the starting point for future forms. + +Rule: code-declared inputs must equal template fields +(`test_embedded_code_matches_template_fields` enforces it; required +Multiselect uses the `[" "]` sentinel). Generate node + flow with +`kirche_node(..., component_cls, code_override)` and +`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 +198,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/bauvorhaben_form.py b/backend/components/kirche/bauvorhaben_form.py new file mode 100644 index 0000000..348e064 --- /dev/null +++ b/backend/components/kirche/bauvorhaben_form.py @@ -0,0 +1,299 @@ +"""Bauvorhaben form component for kirche-einfach flows. + +Standalone by design: this module shares NO code with ``form_inputs`` +(no imports from it). The LFX runtime executes the embedded module +source without our ``kirche`` package, so every form component must be +self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated +machinery below into a shared import — it would break flow execution. +Copy this file as the starting point for future forms. + +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 +import re +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 + + +def _text(value) -> str: + if value is None: + return "" + if hasattr(value, "text"): + return value.text or "" + return str(value) + + +def _json_safe(value): + """Plain JSON value for one field: unwrap LFX wrappers, keep scalars.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + text = getattr(value, "text", None) + if isinstance(text, str): + return text + data = getattr(value, "data", None) + if isinstance(data, dict): + return _json_safe(data) + return str(value) + + +def _split_conditions(text: str) -> list: + parts: list = [] + buf: list = [] + quoted = False + for char in text: + if char == '"': + quoted = not quoted + buf.append(char) + elif char == "&" and not quoted: + parts.append("".join(buf)) + buf = [] + else: + buf.append(char) + parts.append("".join(buf)) + return [part.strip() for part in parts if part.strip()] + + +def _parse_condition(token: str): + field, sep, value = token.partition("=") + field, value = field.strip(), value.strip() + if not sep or not field or not value: + return None + if len(value) >= 2 and value.startswith('"') and value.endswith('"'): + value = value[1:-1] + lowered = value.lower() + if lowered == "true": + return field, True + if lowered == "false": + return field, False + try: + return field, int(value) + except ValueError: + pass + try: + return field, float(value) + except ValueError: + pass + return field, value + + +def _field_conditions(info: object) -> list: + """``[(trigger, value)]`` from a field's ``info`` markers.""" + conditions: list = [] + + def _collect(match) -> str: + for token in _split_conditions(match.group(1)): + parsed = _parse_condition(token) + if parsed is not None: + conditions.append(parsed) + return "" + + _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "") + return conditions + + +#: Reserved input carrying the run envelope — a real runtime input, so it +#: must stay out of the output. Never name a form field ``form_values``. +PLUMBING_INPUTS = frozenset({"form_values"}) + +#: ``[visible_if ...]`` marker (same syntax as the backend parser in +#: ``app/flows/input_schema.py`` — keep the two in sync). +_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]") + + +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 = "" + if isinstance(inputs, dict) and name in inputs: + info = getattr(inputs[name], "info", "") or "" + for trigger, expected in _field_conditions(info): + 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: + try: + envelope = json.loads(_text(self.form_values) or "{}") + except (json.JSONDecodeError, ValueError, AttributeError): + envelope = {} + payload: dict = {} + asked: list | None = None + if isinstance(envelope, dict): + # Asked-field list counts only in true envelope shape + # ({"payload": {...}, "fields": [...]}); a raw dict or a form + # field literally named "fields" must not confuse it. + if isinstance(envelope.get("payload"), dict): + payload = envelope["payload"] + fields = envelope.get("fields") + if isinstance(fields, list) and all(isinstance(item, str) for item in fields): + asked = [item for item in fields if item] + else: + payload = envelope + # Effective trigger values: submitted first, code defaults after. + declared = self._field_names() + effective = {name: _json_safe(self._field_value(name)) for name in declared} + for key, val in payload.items(): + if isinstance(key, str) and key: + effective[key] = _json_safe(val) + # Defaults only for asked AND visible fields (hidden branch fields + # stay out, like the frontend never submits them); without an asked + # list, all visible declared fields (Langflow playground). + scope = [name for name in (asked if asked is not None else declared) if name in declared] + data = {name: effective[name] for name in scope if self._is_visible(name, effective)} + # Submitted values win over the field defaults. Unknown keys are + # kept: the frontend may already know renamed fields this code + # 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..090ccdb 100644 --- a/backend/components/kirche/form_inputs.py +++ b/backend/components/kirche/form_inputs.py @@ -11,6 +11,23 @@ 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. """ @@ -18,6 +35,7 @@ upload comes later. from __future__ import annotations import json +import re from typing import ClassVar from lfx.custom import Component @@ -36,7 +54,7 @@ from lfx.io import ( SliderInput, StrInput, ) -from lfx.schema.message import Message +from lfx.schema.data import Data def _text(value) -> str: @@ -47,9 +65,93 @@ def _text(value) -> str: return str(value) +def _json_safe(value): + """Plain JSON value for one field: unwrap LFX wrappers, keep scalars.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + text = getattr(value, "text", None) + if isinstance(text, str): + return text + data = getattr(value, "data", None) + if isinstance(data, dict): + return _json_safe(data) + return str(value) + + +def _split_conditions(text: str) -> list: + parts: list = [] + buf: list = [] + quoted = False + for char in text: + if char == '"': + quoted = not quoted + buf.append(char) + elif char == "&" and not quoted: + parts.append("".join(buf)) + buf = [] + else: + buf.append(char) + parts.append("".join(buf)) + return [part.strip() for part in parts if part.strip()] + + +def _parse_condition(token: str): + field, sep, value = token.partition("=") + field, value = field.strip(), value.strip() + if not sep or not field or not value: + return None + if len(value) >= 2 and value.startswith('"') and value.endswith('"'): + value = value[1:-1] + lowered = value.lower() + if lowered == "true": + return field, True + if lowered == "false": + return field, False + try: + return field, int(value) + except ValueError: + pass + try: + return field, float(value) + except ValueError: + pass + return field, value + + +def _field_conditions(info: object) -> list: + """``[(trigger, value)]`` from a field's ``info`` markers.""" + conditions: list = [] + + def _collect(match) -> str: + for token in _split_conditions(match.group(1)): + parsed = _parse_condition(token) + if parsed is not None: + conditions.append(parsed) + return "" + + _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "") + return conditions + + +#: Reserved input carrying the run envelope — a real runtime input, so it +#: must stay out of the output (otherwise the whole envelope JSON would +#: leak in as a field value on every path without an asked-fields list). +#: Never name a form field ``form_values``. +PLUMBING_INPUTS = frozenset({"form_values"}) + +#: ``[visible_if ...]`` marker (same syntax as the backend parser in +#: ``app/flows/input_schema.py`` — keep the two in sync; this module must +#: stay import-light for the LFX runtime and cannot import it). +_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]") + + 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 +236,90 @@ 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 = "" + if isinstance(inputs, dict) and name in inputs: + info = getattr(inputs[name], "info", "") or "" + for trigger, expected in _field_conditions(info): + 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: + try: + envelope = json.loads(_text(self.form_values) or "{}") + except (json.JSONDecodeError, ValueError, AttributeError): + envelope = {} + payload: dict = {} + asked: list | None = None + if isinstance(envelope, dict): + # Asked-field list counts only in true envelope shape + # ({"payload": {...}, "fields": [...]}); a raw dict or a form + # field literally named "fields" must not confuse it. + if isinstance(envelope.get("payload"), dict): + payload = envelope["payload"] + fields = envelope.get("fields") + if isinstance(fields, list) and all(isinstance(item, str) for item in fields): + asked = [item for item in fields if item] + else: + payload = envelope + # Effective trigger values: submitted first, code defaults after. + declared = self._field_names() + effective = {name: _json_safe(self._field_value(name)) for name in declared} + for key, val in payload.items(): + if isinstance(key, str) and key: + effective[key] = _json_safe(val) + # Defaults only for asked AND visible fields (hidden branch fields + # stay out, like the frontend never submits them); without an asked + # list, all visible declared fields (Langflow playground). + scope = [name for name in (asked if asked is not None else declared) if name in declared] + data = {name: effective[name] for name in scope if self._is_visible(name, effective)} + # Submitted values win over the field defaults. Unknown keys are + # kept: the frontend may already know renamed fields this code + # 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..d27e217 --- /dev/null +++ b/backend/components/kirche/mitarbeiter_form.py @@ -0,0 +1,276 @@ +"""Mitarbeiter-einstellen form component for kirche-einfach flows. + +Standalone by design: this module shares NO code with the other form +modules (no imports from them). The LFX runtime executes the embedded +module source without our ``kirche`` package, so every form component +must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the +duplicated machinery below into a shared import — it would break flow +execution. Copy this file (or ``bauvorhaben_form.py``) as the starting +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 +import re +from typing import ClassVar + +from lfx.custom import Component +from lfx.io import ( + DropdownInput, + MessageTextInput, + Output, + StrInput, +) +from lfx.schema.data import Data + + +def _text(value) -> str: + if value is None: + return "" + if hasattr(value, "text"): + return value.text or "" + return str(value) + + +def _json_safe(value): + """Plain JSON value for one field: unwrap LFX wrappers, keep scalars.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + text = getattr(value, "text", None) + if isinstance(text, str): + return text + data = getattr(value, "data", None) + if isinstance(data, dict): + return _json_safe(data) + return str(value) + + +def _split_conditions(text: str) -> list: + parts: list = [] + buf: list = [] + quoted = False + for char in text: + if char == '"': + quoted = not quoted + buf.append(char) + elif char == "&" and not quoted: + parts.append("".join(buf)) + buf = [] + else: + buf.append(char) + parts.append("".join(buf)) + return [part.strip() for part in parts if part.strip()] + + +def _parse_condition(token: str): + field, sep, value = token.partition("=") + field, value = field.strip(), value.strip() + if not sep or not field or not value: + return None + if len(value) >= 2 and value.startswith('"') and value.endswith('"'): + value = value[1:-1] + lowered = value.lower() + if lowered == "true": + return field, True + if lowered == "false": + return field, False + try: + return field, int(value) + except ValueError: + pass + try: + return field, float(value) + except ValueError: + pass + return field, value + + +def _field_conditions(info: object) -> list: + """``[(trigger, value)]`` from a field's ``info`` markers.""" + conditions: list = [] + + def _collect(match) -> str: + for token in _split_conditions(match.group(1)): + parsed = _parse_condition(token) + if parsed is not None: + conditions.append(parsed) + return "" + + _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "") + return conditions + + +#: Reserved input carrying the run envelope — a real runtime input, so it +#: must stay out of the output. Never name a form field ``form_values``. +PLUMBING_INPUTS = frozenset({"form_values"}) + +#: ``[visible_if ...]`` marker (same syntax as the backend parser in +#: ``app/flows/input_schema.py`` — keep the two in sync). +_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]") + + +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 = "" + if isinstance(inputs, dict) and name in inputs: + info = getattr(inputs[name], "info", "") or "" + for trigger, expected in _field_conditions(info): + 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: + try: + envelope = json.loads(_text(self.form_values) or "{}") + except (json.JSONDecodeError, ValueError, AttributeError): + envelope = {} + payload: dict = {} + asked: list | None = None + if isinstance(envelope, dict): + # Asked-field list counts only in true envelope shape + # ({"payload": {...}, "fields": [...]}); a raw dict or a form + # field literally named "fields" must not confuse it. + if isinstance(envelope.get("payload"), dict): + payload = envelope["payload"] + fields = envelope.get("fields") + if isinstance(fields, list) and all(isinstance(item, str) for item in fields): + asked = [item for item in fields if item] + else: + payload = envelope + # Effective trigger values: submitted first, code defaults after. + declared = self._field_names() + effective = {name: _json_safe(self._field_value(name)) for name in declared} + for key, val in payload.items(): + if isinstance(key, str) and key: + effective[key] = _json_safe(val) + # Defaults only for asked AND visible fields (hidden branch fields + # stay out, like the frontend never submits them); without an asked + # list, all visible declared fields (Langflow playground). + scope = [name for name in (asked if asked is not None else declared) if name in declared] + data = {name: effective[name] for name in scope if self._is_visible(name, effective)} + # Submitted values win over the field defaults. Unknown keys are + # kept: the frontend may already know renamed fields this code + # 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/bauvorhaben.json b/backend/flows/bauvorhaben.json new file mode 100644 index 0000000..ee0b311 --- /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\nStandalone by design: this module shares NO code with ``form_inputs``\n(no imports from it). The LFX runtime executes the embedded module\nsource without our ``kirche`` package, so every form component must be\nself-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated\nmachinery below into a shared import — it would break flow execution.\nCopy this file as the starting point for future forms.\n\nA flow that starts with :class:`CustomInputBauvorhaben` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches, see below),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultiselectInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputBauvorhaben(Component):\n display_name = \"Bauvorhaben Form\"\n description = \"Collects Bauvorhaben form values into a single JSON object.\"\n icon = \"Church\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"gebaeude\",\n display_name=\"Welches Gebäude soll verändert werden?\",\n info=\"\",\n options=[\"Kirche\", \"Anderes Gebäude\"],\n value=\"\",\n required=True,\n ),\n MultiselectInput(\n name=\"bestandteil\",\n display_name=\"Welcher Bestandteil der Kirche?\",\n info=\"Mehrfachauswahl möglich [visible_if gebaeude=Kirche]\",\n options=[\"Glocke\", \"Orgel\", \"Innenraum\", \"Fenster\", \"Fassade\"],\n value=[],\n required=True,\n ),\n DropdownInput(\n name=\"glockenmassnahme\",\n display_name=\"Welche Maßnahme an der Glocke?\",\n info=\"[visible_if bestandteil=Glocke]\",\n options=[\"Reinigung\", \"Reparatur\", \"Neuguss\"],\n value=\"Reinigung\",\n ),\n IntInput(\n name=\"gussjahr\",\n display_name=\"Gussjahr (nur bei Neuguss)?\",\n info=\"[visible_if glockenmassnahme=Neuguss]\",\n value=0,\n ),\n DropdownInput(\n name=\"gebaeudeart\",\n display_name=\"Art des anderen Gebäudes?\",\n info='[visible_if gebaeude=\"Anderes Gebäude\"]',\n options=[\"Wohnhaus\", \"Scheune\", \"Sonstiges\"],\n value=\"Wohnhaus\",\n ),\n BoolInput(\n name=\"denkmalschutz\",\n display_name=\"Steht das Gebäude unter Denkmalschutz?\",\n info=\"\",\n value=False,\n ),\n StrInput(\n name=\"denkmalnummer\",\n display_name=\"Aktenzeichen Denkmalschutz?\",\n info=\"[visible_if denkmalschutz=true]\",\n value=\"\",\n required=True,\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" + } + }, + "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..4d14be7 100644 --- a/backend/flows/intake_form.json +++ b/backend/flows/intake_form.json @@ -38,7 +38,7 @@ "id": "CustomInputForm-intake", "name": "form_data", "output_types": [ - "Message" + "JSON" ] }, "targetHandle": { @@ -54,10 +54,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œ}" } @@ -350,7 +350,7 @@ "beta": false, "conditional_paths": [], "custom_fields": {}, - "description": "Collects structured form values into a single JSON message.", + "description": "Collects structured form values into a single JSON object.", "display_name": "CustomInput Form", "documentation": "", "edited": false, @@ -369,10 +369,10 @@ "group_outputs": false, "method": "collect", "name": "form_data", - "selected": "Message", + "selected": "JSON", "tool_mode": true, "types": [ - "Message" + "JSON" ], "value": "__UNDEFINED__" } @@ -466,7 +466,7 @@ "show": true, "title_case": false, "type": "code", - "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``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\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\n#: Inputs that are plumbing, not form fields (never part of the output).\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def collect(self) -> Data:\n data = {}\n for name in self._field_names():\n data[name] = _json_safe(self._field_value(name))\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n # Unknown keys are kept: the frontend may already know renamed\n # fields this code revision does not declare.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key, val in override.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" }, "department": { "_input_type": "DropdownInput", diff --git a/backend/flows/mitarbeiter_einstellen.json b/backend/flows/mitarbeiter_einstellen.json new file mode 100644 index 0000000..e383193 --- /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\nStandalone by design: this module shares NO code with the other form\nmodules (no imports from them). The LFX runtime executes the embedded\nmodule source without our ``kirche`` package, so every form component\nmust be self-contained (stdlib + ``lfx`` only). Do NOT refactor the\nduplicated machinery below into a shared import — it would break flow\nexecution. Copy this file (or ``bauvorhaben_form.py``) as the starting\npoint for future forms.\n\nA flow that starts with :class:`CustomInputMitarbeiter` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n DropdownInput,\n MessageTextInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputMitarbeiter(Component):\n display_name = \"Mitarbeiter Einstellen Form\"\n description = \"Collects hiring form values into a single JSON object.\"\n icon = \"UserPlus\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"rolle\",\n display_name=\"Rolle\",\n info=\"\",\n options=[\"Assistenz\", \"Pfarrer:in\", \"Vikar:in\", \"Messner:in\"],\n value=\"Assistenz\",\n required=True,\n ),\n DropdownInput(\n name=\"einstufung\",\n display_name=\"Einstufung\",\n info=\"Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]\",\n options=[\"Stufe 1\", \"Stufe 2\", \"Stufe 3\"],\n value=\"Stufe 1\",\n ),\n StrInput(\n name=\"mitarbeiter_name\",\n display_name=\"Name\",\n info=\"Vor- und Nachname des Mitarbeiters\",\n value=\"\",\n required=True,\n ),\n StrInput(\n name=\"geburtsdatum\",\n display_name=\"Geburtsdatum\",\n info=\"Datum eingeben (z. B. JJJJ-MM-TT)\",\n value=\"\",\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" + } + }, + "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/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py index 49e309d..96e7952 100644 --- a/backend/scripts/make_flow_fixtures.py +++ b/backend/scripts/make_flow_fixtures.py @@ -91,6 +91,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 +103,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 = 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) @@ -147,14 +156,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 +419,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 +679,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 +788,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/tests/fixtures/flows/bauvorhaben.json b/backend/tests/fixtures/flows/bauvorhaben.json new file mode 100644 index 0000000..ee0b311 --- /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\nStandalone by design: this module shares NO code with ``form_inputs``\n(no imports from it). The LFX runtime executes the embedded module\nsource without our ``kirche`` package, so every form component must be\nself-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated\nmachinery below into a shared import — it would break flow execution.\nCopy this file as the starting point for future forms.\n\nA flow that starts with :class:`CustomInputBauvorhaben` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches, see below),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultiselectInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputBauvorhaben(Component):\n display_name = \"Bauvorhaben Form\"\n description = \"Collects Bauvorhaben form values into a single JSON object.\"\n icon = \"Church\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"gebaeude\",\n display_name=\"Welches Gebäude soll verändert werden?\",\n info=\"\",\n options=[\"Kirche\", \"Anderes Gebäude\"],\n value=\"\",\n required=True,\n ),\n MultiselectInput(\n name=\"bestandteil\",\n display_name=\"Welcher Bestandteil der Kirche?\",\n info=\"Mehrfachauswahl möglich [visible_if gebaeude=Kirche]\",\n options=[\"Glocke\", \"Orgel\", \"Innenraum\", \"Fenster\", \"Fassade\"],\n value=[],\n required=True,\n ),\n DropdownInput(\n name=\"glockenmassnahme\",\n display_name=\"Welche Maßnahme an der Glocke?\",\n info=\"[visible_if bestandteil=Glocke]\",\n options=[\"Reinigung\", \"Reparatur\", \"Neuguss\"],\n value=\"Reinigung\",\n ),\n IntInput(\n name=\"gussjahr\",\n display_name=\"Gussjahr (nur bei Neuguss)?\",\n info=\"[visible_if glockenmassnahme=Neuguss]\",\n value=0,\n ),\n DropdownInput(\n name=\"gebaeudeart\",\n display_name=\"Art des anderen Gebäudes?\",\n info='[visible_if gebaeude=\"Anderes Gebäude\"]',\n options=[\"Wohnhaus\", \"Scheune\", \"Sonstiges\"],\n value=\"Wohnhaus\",\n ),\n BoolInput(\n name=\"denkmalschutz\",\n display_name=\"Steht das Gebäude unter Denkmalschutz?\",\n info=\"\",\n value=False,\n ),\n StrInput(\n name=\"denkmalnummer\",\n display_name=\"Aktenzeichen Denkmalschutz?\",\n info=\"[visible_if denkmalschutz=true]\",\n value=\"\",\n required=True,\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" + } + }, + "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..792e3ff 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\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\n#: Inputs that are plumbing, not form fields (never part of the output).\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def collect(self) -> Data:\n data = {}\n for name in self._field_names():\n data[name] = _json_safe(self._field_value(name))\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n # Unknown keys are kept: the frontend may already know renamed\n # fields this code revision does not declare.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key, val in override.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" } }, - "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..e383193 --- /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\nStandalone by design: this module shares NO code with the other form\nmodules (no imports from them). The LFX runtime executes the embedded\nmodule source without our ``kirche`` package, so every form component\nmust be self-contained (stdlib + ``lfx`` only). Do NOT refactor the\nduplicated machinery below into a shared import — it would break flow\nexecution. Copy this file (or ``bauvorhaben_form.py``) as the starting\npoint for future forms.\n\nA flow that starts with :class:`CustomInputMitarbeiter` declares the\nfields of its web form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}``\nfrom the ``lfx.io`` input definitions below — one JSON-Schema property\nper field, keyed by ``name``:\n\n* ``display_name`` → ``title``, ``info`` → ``description``\n (``[visible_if ...]`` markers declare conditional branches),\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array (branch-required applies only while visible)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\none (``form_values``), so fields can be renamed, added, or removed in\nthe Langflow code editor without touching this method.\n\nWhen the run envelope names the asked fields (``fields`` list, sent by\nthe API from the version's form schema), defaults are emitted only for\nasked AND currently visible fields. Without the list (raw dict,\nLangflow playground), all visible declared defaults apply.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.io import (\n DropdownInput,\n MessageTextInput,\n Output,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\ndef _split_conditions(text: str) -> list:\n parts: list = []\n buf: list = []\n quoted = False\n for char in text:\n if char == '\"':\n quoted = not quoted\n buf.append(char)\n elif char == \"&\" and not quoted:\n parts.append(\"\".join(buf))\n buf = []\n else:\n buf.append(char)\n parts.append(\"\".join(buf))\n return [part.strip() for part in parts if part.strip()]\n\n\ndef _parse_condition(token: str):\n field, sep, value = token.partition(\"=\")\n field, value = field.strip(), value.strip()\n if not sep or not field or not value:\n return None\n if len(value) >= 2 and value.startswith('\"') and value.endswith('\"'):\n value = value[1:-1]\n lowered = value.lower()\n if lowered == \"true\":\n return field, True\n if lowered == \"false\":\n return field, False\n try:\n return field, int(value)\n except ValueError:\n pass\n try:\n return field, float(value)\n except ValueError:\n pass\n return field, value\n\n\ndef _field_conditions(info: object) -> list:\n \"\"\"``[(trigger, value)]`` from a field's ``info`` markers.\"\"\"\n conditions: list = []\n\n def _collect(match) -> str:\n for token in _split_conditions(match.group(1)):\n parsed = _parse_condition(token)\n if parsed is not None:\n conditions.append(parsed)\n return \"\"\n\n _VISIBLE_RE.sub(_collect, info if isinstance(info, str) else \"\")\n return conditions\n\n\n#: Reserved input carrying the run envelope — a real runtime input, so it\n#: must stay out of the output. Never name a form field ``form_values``.\nPLUMBING_INPUTS = frozenset({\"form_values\"})\n\n#: ``[visible_if ...]`` marker (same syntax as the backend parser in\n#: ``app/flows/input_schema.py`` — keep the two in sync).\n_VISIBLE_RE = re.compile(r\"\\[visible_if\\s+([^\\]]+)\\]\")\n\n\nclass CustomInputMitarbeiter(Component):\n display_name = \"Mitarbeiter Einstellen Form\"\n description = \"Collects hiring form values into a single JSON object.\"\n icon = \"UserPlus\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n DropdownInput(\n name=\"rolle\",\n display_name=\"Rolle\",\n info=\"\",\n options=[\"Assistenz\", \"Pfarrer:in\", \"Vikar:in\", \"Messner:in\"],\n value=\"Assistenz\",\n required=True,\n ),\n DropdownInput(\n name=\"einstufung\",\n display_name=\"Einstufung\",\n info=\"Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]\",\n options=[\"Stufe 1\", \"Stufe 2\", \"Stufe 3\"],\n value=\"Stufe 1\",\n ),\n StrInput(\n name=\"mitarbeiter_name\",\n display_name=\"Name\",\n info=\"Vor- und Nachname des Mitarbeiters\",\n value=\"\",\n required=True,\n ),\n StrInput(\n name=\"geburtsdatum\",\n display_name=\"Geburtsdatum\",\n info=\"Datum eingeben (z. B. JJJJ-MM-TT)\",\n value=\"\",\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def _is_visible(self, name: str, effective: dict) -> bool:\n \"\"\"Whether a field's branch conditions hold.\n\n Single pass over effective values (submitted first, code defaults\n after): no recursion, so trigger cycles cannot hang — a stale or\n cyclic marker simply matches or not. Unknown triggers never match.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n info = \"\"\n if isinstance(inputs, dict) and name in inputs:\n info = getattr(inputs[name], \"info\", \"\") or \"\"\n for trigger, expected in _field_conditions(info):\n if trigger == name or trigger not in effective:\n # Self-reference or unknown trigger: fail open (visible),\n # mirroring the backend parser.\n continue\n actual = effective[trigger]\n if isinstance(actual, list):\n if expected not in actual:\n return False\n elif actual != expected:\n return False\n return True\n\n def collect(self) -> Data:\n try:\n envelope = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n envelope = {}\n payload: dict = {}\n asked: list | None = None\n if isinstance(envelope, dict):\n # Asked-field list counts only in true envelope shape\n # ({\"payload\": {...}, \"fields\": [...]}); a raw dict or a form\n # field literally named \"fields\" must not confuse it.\n if isinstance(envelope.get(\"payload\"), dict):\n payload = envelope[\"payload\"]\n fields = envelope.get(\"fields\")\n if isinstance(fields, list) and all(isinstance(item, str) for item in fields):\n asked = [item for item in fields if item]\n else:\n payload = envelope\n # Effective trigger values: submitted first, code defaults after.\n declared = self._field_names()\n effective = {name: _json_safe(self._field_value(name)) for name in declared}\n for key, val in payload.items():\n if isinstance(key, str) and key:\n effective[key] = _json_safe(val)\n # Defaults only for asked AND visible fields (hidden branch fields\n # stay out, like the frontend never submits them); without an asked\n # list, all visible declared fields (Langflow playground).\n scope = [name for name in (asked if asked is not None else declared) if name in declared]\n data = {name: effective[name] for name in scope if self._is_visible(name, effective)}\n # Submitted values win over the field defaults. Unknown keys are\n # kept: the frontend may already know renamed fields this code\n # revision does not declare.\n for key, val in payload.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" + } + }, + "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_export.py b/backend/tests/test_export.py index f500575..1f0695d 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -43,6 +43,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_form_components.py b/backend/tests/test_form_components.py index 3d047b5..d1f9516 100644 --- a/backend/tests/test_form_components.py +++ b/backend/tests/test_form_components.py @@ -39,26 +39,173 @@ 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_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 test_intake_fixture_shape(): @@ -72,6 +219,8 @@ def test_intake_fixture_shape(): assert field in template, field 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 @@ -99,7 +248,253 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login): ) assert run.status_code == 200, run.text assert run.json()["status"] == "completed" - result = json.loads(run.json()["output"]["result"]) + # collect() returns Data: the terminal ChatOutput renders it as a + # fenced ```json block, so unwrap before parsing. + result = _parse_chat_json(run.json()["output"]["result"]) assert result["full_name"] == "Maria Muster" assert result["age"] == 41 assert result["department"] == "Music" # default preserved + + +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) + + +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"] + 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_bauvorhaben_fixture_shape(): + doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8")) + assert doc["name"] == "bauvorhaben" + nodes = {n["id"]: n for n in doc["data"]["nodes"]} + form = nodes["CustomInputBauvorhaben-bauvorhaben"] + assert form["data"]["type"] == "CustomInputBauvorhaben" + assert form["data"]["node"]["display_name"] == "Bauvorhaben Form" + template = form["data"]["node"]["template"] + assert set(template) >= { + "form_values", + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + "code", + } + outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]} + assert outputs["form_data"]["types"] == ["JSON"] + edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]} + assert ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben") in edges + assert ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected") in edges + + +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"] + assert len(schema["allOf"]) == 5 + by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]} + 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", "") + assert out["ui_schema"]["ui:order"] == [ + "gebaeude", + "bestandteil", + "glockenmassnahme", + "gussjahr", + "gebaeudeart", + "denkmalschutz", + "denkmalnummer", + "*", + ] + + +async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login): + await login() + doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8")) + created = await client.post("/api/flows", json={"name": "bauvorhaben-e2e"}) + assert created.status_code == 201, created.text + flow_id = created.json()["id"] + + version = await client.post( + f"/api/flows/{flow_id}/versions", + json={"langflow_json": doc, "author": "test"}, + ) + assert version.status_code == 201, version.text + # Branch rules are cached per version and served via input-schema. + cached = version.json()["input_schema"] + assert len(cached["json_schema"]["allOf"]) == 5 + + deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") + assert deployed.status_code == 200, deployed.text + 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"} + + run = await client.post( + f"/api/flows/{flow_id}/run", + json={ + "input": { + "gebaeude": "Kirche", + "bestandteil": ["Glocke", "Orgel"], + "glockenmassnahme": "Neuguss", + "gussjahr": 1890, + "denkmalschutz": True, + "denkmalnummer": "D-123", + } + }, + ) + assert run.status_code == 200, run.text + assert run.json()["status"] == "completed" + result = _parse_chat_json(run.json()["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", + } + + +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" + + +def test_mitarbeiter_fixture_shape(): + doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8")) + assert doc["name"] == "mitarbeiter_einstellen" + nodes = {n["id"]: n for n in doc["data"]["nodes"]} + form = nodes["CustomInputMitarbeiter-mitarbeiter"] + assert form["data"]["type"] == "CustomInputMitarbeiter" + assert form["data"]["node"]["display_name"] == "Mitarbeiter Einstellen Form" + template = form["data"]["node"]["template"] + assert set(template) >= { + "form_values", + "rolle", + "einstufung", + "mitarbeiter_name", + "geburtsdatum", + "code", + } + outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]} + assert outputs["form_data"]["types"] == ["JSON"] + edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]} + assert ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter") in edges + assert ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt") in edges + assert ("Gehaltsrechner-gehalt", "ChatOutput-collected") in edges + + +async def test_mitarbeiter_deploy_and_run_salary(client, lfx_server, login): + await login() + doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8")) + created = await client.post("/api/flows", json={"name": "mitarbeiter-e2e"}) + assert created.status_code == 201, created.text + flow_id = created.json()["id"] + + version = await client.post( + f"/api/flows/{flow_id}/versions", + json={"langflow_json": doc, "author": "test"}, + ) + assert version.status_code == 201, version.text + 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": { + "rolle": "Pfarrer:in", + "einstufung": "Stufe 2", + "mitarbeiter_name": "Maria Muster", + "geburtsdatum": "1980-01-01", + } + }, + ) + assert run.status_code == 200, run.text + assert run.json()["status"] == "completed" + result = run.json()["output"]["result"] + assert "65000" in result + assert "Maria Muster" in result diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py index b81e3fd..1eea92e 100644 --- a/backend/tests/test_input_schema.py +++ b/backend/tests/test_input_schema.py @@ -38,6 +38,16 @@ def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"): return {"data": {"nodes": [node], "edges": []}} +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 + + def test_type_mapping_table(): doc = _doc( _field("a", "StrInput", value="x"), @@ -192,3 +202,209 @@ 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" + assert out["ui_schema"]["ui:order"] == ["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_sync.py b/backend/tests/test_sync.py index 6c582b4..2eb35b8 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -44,6 +44,14 @@ 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_ignored_files(session, lfx_server, tmp_path): + _seed_dir(tmp_path) + (tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8") + report = await sync.sync_from_directory(session, _lfx(), 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, tmp_path): _seed_dir(tmp_path) first = await sync.sync_from_directory(session, _lfx(), tmp_path) 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/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx index c695386..da08d8a 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -179,40 +179,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] + .reverse() + .find((v) => v.deployed_at && v.id !== latest.id); + 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 || "–"} - )), - )} + ); + })} )}