From 0d43b0e8fbe6d76b45c87a7027885f28c2ce4cdc Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Sun, 27 Sep 2026 22:26:48 +0200 Subject: [PATCH] feat: dynamic form fields, JSON output, branched questionnaires --- CHANGELOG.md | 17 + backend/app/flows/input_schema.py | 220 ++++- backend/components/kirche/README.md | 139 ++- backend/components/kirche/form_inputs.py | 92 +- backend/flows/bauvorhaben.json | 919 ++++++++++++++++++ backend/flows/intake_form.json | 14 +- backend/scripts/make_flow_fixtures.py | 5 +- backend/tests/fixtures/flows/bauvorhaben.json | 919 ++++++++++++++++++ backend/tests/fixtures/flows/intake_form.json | 14 +- backend/tests/test_form_components.py | 177 +++- backend/tests/test_input_schema.py | 155 +++ 11 files changed, 2579 insertions(+), 92 deletions(-) create mode 100644 backend/flows/bauvorhaben.json create mode 100644 backend/tests/fixtures/flows/bauvorhaben.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ace98a..fb37b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ 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. +### 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/backend/app/flows/input_schema.py b/backend/app/flows/input_schema.py index b453cb4..e0fe6a5 100644 --- a/backend/app/flows/input_schema.py +++ b/backend/app/flows/input_schema.py @@ -5,7 +5,10 @@ Looks for ``CustomInput*`` nodes (``data.type`` or the inner ``backend/components/kirche/form_inputs.py``) 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 +16,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,6 +27,16 @@ 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.""" @@ -112,6 +129,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 +239,92 @@ 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} diff --git a/backend/components/kirche/README.md b/backend/components/kirche/README.md index ba7a06f..c4d2b24 100644 --- a/backend/components/kirche/README.md +++ b/backend/components/kirche/README.md @@ -8,35 +8,111 @@ 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). + +## 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 @@ -45,7 +121,10 @@ derives a JSON Schema + uiSchema from the node's template (`backend/scripts/make_flow_fixtures.py`) writes a single-space sentinel value for such fields. 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 +135,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/form_inputs.py b/backend/components/kirche/form_inputs.py index 320de7b..e9f6cf9 100644 --- a/backend/components/kirche/form_inputs.py +++ b/backend/components/kirche/form_inputs.py @@ -11,6 +11,17 @@ 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. + +``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. """ @@ -36,7 +47,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 +58,30 @@ 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) + + +#: Inputs that are plumbing, not form fields (never part of the output). +PLUMBING_INPUTS = frozenset({"form_values", "code"}) + + 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 +166,49 @@ 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), - } + 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 collect(self) -> Data: + data = {} + for name in self._field_names(): + data[name] = _json_safe(self._field_value(name)) # Submitted values (via form_values) win over the field defaults. # Accepts the raw dict or the run envelope {"payload": {...}}. + # Unknown keys are kept: the frontend may already know renamed + # fields this code revision does not declare. try: override = json.loads(_text(self.form_values) or "{}") - except json.JSONDecodeError: + except (json.JSONDecodeError, ValueError, AttributeError): 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)) + for key, val in override.items(): + if isinstance(key, str) and key: + data[key] = _json_safe(val) + return Data(data=data) diff --git a/backend/flows/bauvorhaben.json b/backend/flows/bauvorhaben.json new file mode 100644 index 0000000..b23f21f --- /dev/null +++ b/backend/flows/bauvorhaben.json @@ -0,0 +1,919 @@ +{ + "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": "", + "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" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\nones (``form_values``, ``code``), so fields can be renamed, added, or\nremoved in the Langflow code editor without touching this method.\n``name`` is the stable JSON key (freely renamable, must stay unique and\nnon-empty); downstream components read the keys as-is.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\n#: Inputs that are plumbing, not form fields (never part of the output).\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def collect(self) -> Data:\n data = {}\n for name in self._field_names():\n data[name] = _json_safe(self._field_value(name))\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n # Unknown keys are kept: the frontend may already know renamed\n # fields this code revision does not declare.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key, val in override.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" + }, + "_type": "Component" + }, + "description": "Collects structured form values into a single JSON object.", + "icon": "ClipboardList", + "base_classes": [], + "display_name": "CustomInput Form", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "JSON" + ], + "selected": "JSON", + "name": "form_data", + "display_name": "Form Data", + "method": "collect", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "CustomInputForm", + "id": "CustomInputForm-bauvorhaben", + "showNode": true + }, + "id": "CustomInputForm-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": "CustomInputForm-bauvorhaben", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputForm-bauvorhaben", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "CustomInputForm-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œ: œCustomInputFormœ, œidœ: œCustomInputForm-bauvorhabenœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputForm-bauvorhaben", + "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-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 (CustomInputForm).", + "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/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py index 49e309d..6f05ee6 100644 --- a/backend/scripts/make_flow_fixtures.py +++ b/backend/scripts/make_flow_fixtures.py @@ -555,7 +555,10 @@ def main() -> None: 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..b23f21f --- /dev/null +++ b/backend/tests/fixtures/flows/bauvorhaben.json @@ -0,0 +1,919 @@ +{ + "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": "", + "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" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``collect()`` is fully dynamic: it emits every input except the plumbing\nones (``form_values``, ``code``), so fields can be renamed, added, or\nremoved in the Langflow code editor without touching this method.\n``name`` is the stable JSON key (freely renamable, must stay unique and\nnon-empty); downstream components read the keys as-is.\n\n``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the\nvalues travel as a real JSON object. A terminal ``ChatOutput`` renders it\nas a fenced ```json block; components consuming it should read ``.data``\n(or ``get_text()``), not ``.text``.\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.data import Data\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\ndef _json_safe(value):\n \"\"\"Plain JSON value for one field: unwrap LFX wrappers, keep scalars.\"\"\"\n if value is None or isinstance(value, (bool, int, float, str)):\n return value\n if isinstance(value, list):\n return [_json_safe(item) for item in value]\n if isinstance(value, dict):\n return {str(key): _json_safe(item) for key, item in value.items()}\n text = getattr(value, \"text\", None)\n if isinstance(text, str):\n return text\n data = getattr(value, \"data\", None)\n if isinstance(data, dict):\n return _json_safe(data)\n return str(value)\n\n\n#: Inputs that are plumbing, not form fields (never part of the output).\nPLUMBING_INPUTS = frozenset({\"form_values\", \"code\"})\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON object.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def _field_names(self) -> list:\n \"\"\"Names of all form fields (every input except the plumbing ones).\"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and inputs:\n return [name for name in inputs if name not in PLUMBING_INPUTS]\n seen = []\n declared = getattr(self, \"inputs\", None) or getattr(type(self), \"inputs\", None) or []\n for inp in declared:\n name = getattr(inp, \"name\", None)\n if name and name not in PLUMBING_INPUTS and name not in seen:\n seen.append(name)\n return seen\n\n def _field_value(self, name: str):\n \"\"\"Runtime value of one input.\n\n Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would\n otherwise resolve to the component attribute instead of its value.\n \"\"\"\n inputs = getattr(self, \"_inputs\", None)\n if isinstance(inputs, dict) and name in inputs:\n return getattr(inputs[name], \"value\", None)\n try:\n return getattr(self, name, None)\n except (AttributeError, ValueError):\n return None\n\n def collect(self) -> Data:\n data = {}\n for name in self._field_names():\n data[name] = _json_safe(self._field_value(name))\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n # Unknown keys are kept: the frontend may already know renamed\n # fields this code revision does not declare.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except (json.JSONDecodeError, ValueError, AttributeError):\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key, val in override.items():\n if isinstance(key, str) and key:\n data[key] = _json_safe(val)\n return Data(data=data)\n" + }, + "_type": "Component" + }, + "description": "Collects structured form values into a single JSON object.", + "icon": "ClipboardList", + "base_classes": [], + "display_name": "CustomInput Form", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "JSON" + ], + "selected": "JSON", + "name": "form_data", + "display_name": "Form Data", + "method": "collect", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "CustomInputForm", + "id": "CustomInputForm-bauvorhaben", + "showNode": true + }, + "id": "CustomInputForm-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": "CustomInputForm-bauvorhaben", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputForm-bauvorhaben", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-bauvorhabenœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "CustomInputForm-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œ: œCustomInputFormœ, œidœ: œCustomInputForm-bauvorhabenœ, œnameœ: œform_dataœ, œoutput_typesœ: [œJSONœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputForm-bauvorhaben", + "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-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 (CustomInputForm).", + "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/test_form_components.py b/backend/tests/test_form_components.py index 3d047b5..83c6901 100644 --- a/backend/tests/test_form_components.py +++ b/backend/tests/test_form_components.py @@ -39,26 +39,58 @@ 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_intake_fixture_shape(): @@ -72,6 +104,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 +133,132 @@ 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_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["CustomInputForm-bauvorhaben"] + assert form["data"]["type"] == "CustomInputForm" + 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", "CustomInputForm-bauvorhaben") in edges + assert ("CustomInputForm-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" diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py index b81e3fd..4639f81 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,148 @@ 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"}