From 46ecab00dbd7b275b0d2bdbba95c8956eed6dfd2 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Sat, 12 Sep 2026 07:36:05 +0200 Subject: [PATCH] feat: form-input library with schema parsing and endpoint --- backend/app/flows/api.py | 25 + backend/app/flows/input_schema.py | 147 +++ backend/app/flows/schemas.py | 12 + backend/app/flows/store.py | 4 + backend/app/models/flow.py | 3 + backend/components/kirche/__init__.py | 12 + backend/components/kirche/form_inputs.py | 161 +++ backend/flows/intake_form.json | 991 ++++++++++++++++++ .../b7e2d4a1c9f3_cache_form_schema.py | 32 + backend/scripts/make_flow_fixtures.py | 94 +- backend/tests/fixtures/flows/intake_form.json | 991 ++++++++++++++++++ backend/tests/test_form_components.py | 104 ++ backend/tests/test_input_schema.py | 201 ++++ 13 files changed, 2775 insertions(+), 2 deletions(-) create mode 100644 backend/app/flows/input_schema.py create mode 100644 backend/components/kirche/__init__.py create mode 100644 backend/components/kirche/form_inputs.py create mode 100644 backend/flows/intake_form.json create mode 100644 backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py create mode 100644 backend/tests/fixtures/flows/intake_form.json create mode 100644 backend/tests/test_form_components.py create mode 100644 backend/tests/test_input_schema.py diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index 0a4dd0b..9769b75 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -18,6 +18,7 @@ from app.settings import settings from app.auth.deps import get_optional_user, resolve_tenant from . import runner, store +from . import input_schema as form_schema from .deploy import import_from_langflow from .sync import lfx_id_for, sync_from_directory from .langflow_client import ( @@ -46,6 +47,7 @@ from .schemas import ( FlowImportRequest, FlowVersionCreate, FlowVersionRead, + InputSchemaRead, RunRead, RunRequest, SyncReport, @@ -91,6 +93,7 @@ def _read_version(v) -> FlowVersionRead: deployed_at=v.deployed_at, author=v.author, created_at=v.created_at, + input_schema=v.input_schema, ) @@ -386,6 +389,28 @@ async def import_flow( ) +@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead) +def get_input_schema( + flow_id: UUID, + session: Session = Depends(get_db), +): + """Form schema of the latest deployed version (404 when none).""" + try: + version = store.get_latest_deployed_version(session, flow_id) + except (store.NotFoundError, store.InvalidError) as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + cached = version.input_schema or form_schema.extract_form_schema( + version.langflow_json + ) + return InputSchemaRead( + flow_id=flow_id, + version_no=version.version_no, + json_schema=cached.get("json_schema", {}), + ui_schema=cached.get("ui_schema", {}), + fallback=cached.get("fallback"), + ) + + @flows_router.get("/runs/{run_id}", response_model=RunRead) def get_run( run_id: UUID, diff --git a/backend/app/flows/input_schema.py b/backend/app/flows/input_schema.py new file mode 100644 index 0000000..863c102 --- /dev/null +++ b/backend/app/flows/input_schema.py @@ -0,0 +1,147 @@ +"""Derive a web-form schema from a Langflow export. + +Looks for ``CustomInput*`` nodes (``data.type`` or the inner +``display_name`` starting with ``CustomInput`` — see +``backend/components/kirche/form_inputs.py``) and maps their ``template`` +(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF: + +* ``name`` → property key, ``display_name`` → ``title``, + ``info`` → ``description``, ``value`` → ``default``, + ``options`` → ``enum``, ``required`` → required array +* ``show: false`` fields are plumbing (e.g. the run envelope input), not + form fields, and are skipped +* ``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) + +A flow without such a node gets ``{fallback: "textarea"}``: the frontend +renders one free-text box instead of a generated form. +""" + +from __future__ import annotations + + +def _form_templates(langflow_json: dict) -> list[dict]: + """Inner node dicts (``data.node``) of all CustomInput* nodes.""" + if not isinstance(langflow_json, dict): + return [] + data = langflow_json.get("data") or {} + nodes = data.get("nodes") or [] + found = [] + for node in nodes: + if not isinstance(node, dict): + continue + node_data = node.get("data") or {} + node_type = str(node_data.get("type") or "") + inner = node_data.get("node") or {} + display = str(inner.get("display_name") or "") + if node_type.startswith("CustomInput") or display.startswith("CustomInput"): + found.append(inner) + return found + + +def _field_schema(name: str, field: dict) -> tuple[dict, dict]: + """(JSON-Schema property, uiSchema entry) for one template field.""" + input_type = str(field.get("_input_type") or "") + prop: dict = {} + ui: dict = {} + title = field.get("display_name") or name + info = field.get("info") or "" + if title: + prop["title"] = title + if info: + prop["description"] = info + + options = field.get("options") or [] + if input_type in ("StrInput", "MessageTextInput"): + prop["type"] = "string" + elif input_type == "MultilineInput": + prop["type"] = "string" + ui["ui:widget"] = "textarea" + elif input_type == "SecretStrInput": + prop["type"] = "string" + ui["ui:widget"] = "password" + elif input_type == "IntInput": + prop["type"] = "integer" + elif input_type == "FloatInput": + prop["type"] = "number" + elif input_type == "BoolInput": + prop["type"] = "boolean" + elif input_type == "DropdownInput": + prop["type"] = "string" + if options: + prop["enum"] = list(options) + elif input_type == "MultiselectInput": + prop["type"] = "array" + items: dict = {"type": "string"} + if options: + items["enum"] = list(options) + prop["items"] = items + prop["uniqueItems"] = True + elif input_type == "SliderInput": + prop["type"] = "number" + spec = field.get("range_spec") or {} + if spec.get("min") is not None: + prop["minimum"] = spec["min"] + if spec.get("max") is not None: + prop["maximum"] = spec["max"] + if spec.get("step"): + prop["multipleOf"] = spec["step"] + ui["ui:widget"] = "range" + elif input_type == "FileInput": + # Stub for now: string reference only, real upload comes later. + prop["type"] = "string" + else: + prop["type"] = "string" + + value = field.get("value") + if value is not None: + # A default outside its enum would fail validation on touch. + enum = prop.get("enum") + if not (enum is not None and value not in enum): + # bool is an int subclass; keep integer defaults clean. + if prop.get("type") == "integer" and isinstance(value, bool): + pass + else: + prop["default"] = value + return prop, ui + + +def extract_form_schema(langflow_json: dict) -> dict: + """``{json_schema, ui_schema}``, or with ``fallback: "textarea"``.""" + templates = _form_templates(langflow_json) + if not templates: + return { + "json_schema": {"type": "object", "properties": {}}, + "ui_schema": {}, + "fallback": "textarea", + } + template = templates[0].get("template") or {} + properties: dict = {} + required: list[str] = [] + ui_schema: dict = {} + normal: list[str] = [] + advanced: list[str] = [] + 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) + if ui: + ui_schema[name] = ui + (advanced if field.get("advanced") else normal).append(name) + if advanced: + ui_schema["ui:order"] = normal + advanced + ["*"] + return { + "json_schema": { + "type": "object", + "properties": properties, + "required": required, + }, + "ui_schema": ui_schema, + } diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py index a3eb907..c3a4146 100644 --- a/backend/app/flows/schemas.py +++ b/backend/app/flows/schemas.py @@ -37,6 +37,18 @@ class FlowVersionRead(BaseModel): deployed_at: datetime | None author: str created_at: datetime + input_schema: dict | None = None + + +class InputSchemaRead(BaseModel): + """Form schema of the latest deployed version (RJSF renders it).""" + + flow_id: UUID + version_no: int + json_schema: dict + ui_schema: dict = Field(default_factory=dict) + # Set when the flow has no CustomInput node: render a free-text box. + fallback: str | None = None class RunRequest(BaseModel): diff --git a/backend/app/flows/store.py b/backend/app/flows/store.py index e11f2c2..eb21f3a 100644 --- a/backend/app/flows/store.py +++ b/backend/app/flows/store.py @@ -10,6 +10,7 @@ from sqlmodel import Session, func, select from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion +from . import input_schema as form_schema from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate @@ -154,6 +155,9 @@ def create_version( version_no=(max_no or 0) + 1, langflow_json=data.langflow_json, author=data.author, + # Single choke point: sync, import, and API creation all cache the + # form schema here, so reads never re-parse. + input_schema=form_schema.extract_form_schema(data.langflow_json), ) session.add(version) session.commit() diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py index 14273d2..0e893ea 100644 --- a/backend/app/models/flow.py +++ b/backend/app/models/flow.py @@ -54,6 +54,9 @@ class FlowVersion(SQLModel, table=True): deployed_at: datetime | None = Field(default=None, sa_column=_ts_column()) author: str = Field(default="", max_length=200) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + # Cached form schema for this exact JSON (see app/flows/input_schema.py). + # Computed at version creation; nullable for pre-Phase-3 rows. + input_schema: dict | None = Field(default=None, sa_column=_json_column()) class FlowRun(SQLModel, table=True): diff --git a/backend/components/kirche/__init__.py b/backend/components/kirche/__init__.py new file mode 100644 index 0000000..252d226 --- /dev/null +++ b/backend/components/kirche/__init__.py @@ -0,0 +1,12 @@ +"""Kirche form-input components (Langflow category ``kirche``). + +GUI-editable by design: plain :class:`lfx.custom.Component` subclasses with +``lfx.io`` inputs only (stdlib + ``lfx``), so both the Langflow authoring UI +and the LFX runtime can load them. The backend parses the exported node +template into a web form (``app/flows/input_schema.py``); the frontend +renders that schema with RJSF. +""" + +from .form_inputs import CustomInputForm + +__all__ = ["CustomInputForm"] diff --git a/backend/components/kirche/form_inputs.py b/backend/components/kirche/form_inputs.py new file mode 100644 index 0000000..e9b857e --- /dev/null +++ b/backend/components/kirche/form_inputs.py @@ -0,0 +1,161 @@ +"""Structured form-input component for kirche-einfach flows. + +A flow that starts with :class:`CustomInputForm` declares the fields of its +web form. The API reads the exported node template +(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from +the ``lfx.io`` input definitions below — one JSON-Schema property per field, +keyed by ``name``. Field metadata maps as follows: + +* ``display_name`` → ``title``, ``info`` → ``description``, + ``value`` → ``default``, ``options`` → ``enum`` +* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint +* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``) + +``FileInput`` is a stub for now: it carries a string reference only, real +upload comes later. +""" + +from __future__ import annotations + +import json + +from lfx.custom import Component +from lfx.field_typing.range_spec import RangeSpec +from lfx.io import ( + BoolInput, + DropdownInput, + FileInput, + FloatInput, + IntInput, + MessageTextInput, + MultilineInput, + MultiselectInput, + Output, + SecretStrInput, + SliderInput, + StrInput, +) +from lfx.schema.message import Message + + +def _text(value) -> str: + if value is None: + return "" + if hasattr(value, "text"): + return value.text or "" + return str(value) + + +class CustomInputForm(Component): + display_name = "CustomInput Form" + description = "Collects structured form values into a single JSON message." + icon = "ClipboardList" + + inputs = [ + # Plumbing, not a form field: the API posts submitted web-form + # values as the run envelope (same {"payload": {...}} shape the + # other kirche components use); they arrive here through a ChatInput + # entry node and override the defaults below. Hidden in the GUI and + # skipped by the form parser (show=False). + MessageTextInput( + name="form_values", + display_name="Form values (JSON envelope)", + info="Submitted values as JSON; merged over the defaults", + value="", + show=False, + ), + StrInput( + name="full_name", + display_name="Full name", + info="Your first and last name", + value="", + required=True, + ), + MultilineInput( + name="notes", + display_name="Notes", + info="Anything else we should know", + value="", + ), + SecretStrInput( + name="password", + display_name="Password", + info="Optional access phrase", + value="", + ), + IntInput( + name="age", + display_name="Age", + info="Your age in years", + value=18, + ), + FloatInput( + name="amount", + display_name="Amount", + info="Amount in euros", + value=0.0, + ), + BoolInput( + name="newsletter", + display_name="Newsletter", + info="Send me the parish newsletter", + value=False, + ), + DropdownInput( + name="department", + display_name="Department", + info="Which team is this for", + options=["Music", "Youth", "Seniors", "Admin"], + value="Music", + ), + MultiselectInput( + name="topics", + display_name="Topics", + info="What are you interested in", + options=["Worship", "Outreach", "Courses"], + value=[], + ), + SliderInput( + name="satisfaction", + display_name="Satisfaction", + info="How satisfied are you (1-5)", + value=3, + range_spec=RangeSpec(min=1, max=5, step=1), + ), + FileInput( + name="attachment", + display_name="Attachment", + info="String reference only for now (no upload yet)", + file_types=["pdf", "png", "jpg"], + ), + ] + outputs = [ + Output(display_name="Form Data", name="form_data", method="collect"), + ] + + def collect(self) -> Message: + data = { + "full_name": _text(self.full_name), + "notes": _text(self.notes), + "password": _text(self.password), + "age": self.age, + "amount": self.amount, + "newsletter": self.newsletter, + "department": _text(self.department), + "topics": self.topics, + "satisfaction": self.satisfaction, + "attachment": _text(self.attachment), + } + # Submitted values (via form_values) win over the field defaults. + # Accepts the raw dict or the run envelope {"payload": {...}}. + try: + override = json.loads(_text(self.form_values) or "{}") + except Exception: + 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)) diff --git a/backend/flows/intake_form.json b/backend/flows/intake_form.json new file mode 100644 index 0000000..0de6f33 --- /dev/null +++ b/backend/flows/intake_form.json @@ -0,0 +1,991 @@ +{ + "data": { + "nodes": [ + { + "data": { + "node": { + "template": { + "_type": "Component", + "files": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "temp_file": true, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "files", + "value": "", + "display_name": "Files", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Files to be sent with the message.", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "input_value": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Input Text", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Message to be passed as input.", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "User", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "User", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Get chat inputs from the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Chat Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatInput", + "id": "ChatInput-entry", + "showNode": true + }, + "id": "ChatInput-entry", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "attachment": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "pdf", + "png", + "jpg" + ], + "temp_file": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "attachment", + "value": "", + "display_name": "Attachment", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "String reference only for now (no upload yet)", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "age": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "age", + "value": 18, + "display_name": "Age", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your age in years", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "amount": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "amount", + "value": 0.0, + "display_name": "Amount", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Amount in euros", + "title_case": false, + "track_in_telemetry": true, + "type": "float", + "_input_type": "FloatInput" + }, + "department": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Music", + "Youth", + "Seniors", + "Admin" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "department", + "value": "Music", + "display_name": "Department", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Which team is this for", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "form_values": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": false, + "name": "form_values", + "value": "", + "display_name": "Form values (JSON envelope)", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Submitted values as JSON; merged over the defaults", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "full_name": { + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "full_name", + "value": "", + "display_name": "Full name", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your first and last name", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "newsletter": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "newsletter", + "value": false, + "display_name": "Newsletter", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Send me the parish newsletter", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "notes": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "notes", + "value": "", + "display_name": "Notes", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Anything else we should know", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "password": { + "load_from_db": true, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "password", + "value": "", + "display_name": "Password", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Optional access phrase", + "title_case": false, + "track_in_telemetry": false, + "password": true, + "type": "str", + "_input_type": "SecretStrInput" + }, + "satisfaction": { + "tool_mode": false, + "min_label": "", + "max_label": "", + "min_label_icon": "", + "max_label_icon": "", + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "range_spec": { + "step_type": "float", + "min": 1.0, + "max": 5.0, + "step": 1.0 + }, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "satisfaction", + "value": 3, + "display_name": "Satisfaction", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "How satisfied are you (1-5)", + "title_case": false, + "track_in_telemetry": false, + "type": "slider", + "_input_type": "SliderInput" + }, + "topics": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Worship", + "Outreach", + "Courses" + ], + "combobox": false, + "toggle": false, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "topics", + "value": [], + "display_name": "Topics", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "What are you interested in", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MultiselectInput" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``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" + } + }, + "description": "Collects structured form values into a single JSON message.", + "icon": "ClipboardList", + "base_classes": [], + "display_name": "CustomInput Form", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "form_data", + "display_name": "Form Data", + "method": "collect", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "CustomInputForm", + "id": "CustomInputForm-intake", + "showNode": true + }, + "id": "CustomInputForm-intake", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-collected", + "showNode": true + }, + "id": "ChatOutput-collected", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputForm-intake", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputForm-intake", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "CustomInputForm-intake", + "name": "form_data", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "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œ: [œMessageœ]}-{œ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œ]}", + "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": "Structured intake via CustomInputForm (parsed into a web form).", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "ec795b9d-f9d2-5730-b016-16e5f9b3bd3f", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "intake_form", + "tags": [], + "webhook": false +} diff --git a/backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py b/backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py new file mode 100644 index 0000000..19801a9 --- /dev/null +++ b/backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py @@ -0,0 +1,32 @@ +"""cache form schema per flow version + +Revision ID: b7e2d4a1c9f3 +Revises: a3f1c9e2b4d7 +Create Date: 2026-09-11 20:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types + + +# revision identifiers, used by Alembic. +revision: str = 'b7e2d4a1c9f3' +down_revision: Union[str, Sequence[str], None] = 'a3f1c9e2b4d7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "flow_versions", + sa.Column("input_schema", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("flow_versions", "input_schema") diff --git a/backend/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py index 3a369b6..b4b7b81 100644 --- a/backend/scripts/make_flow_fixtures.py +++ b/backend/scripts/make_flow_fixtures.py @@ -68,8 +68,7 @@ def builtin_node( return node_dict -def custom_node( - code: str, +def custom_node( code: str, node_id: str, values: dict | None = None, position: tuple[int, int] = (0, 0), @@ -87,6 +86,66 @@ def custom_node( return node_dict +def kirche_node( + node_id: str, + values: dict | None = None, + position: tuple[int, int] = (0, 0), +) -> dict: + """A node from our GUI-editable category lib (backend/components/kirche). + + Built with lfx's own frontend-node serialization, exactly like + ``Component.to_frontend_node()`` — minus its final ``Template.from_dict`` + round-trip: lfx 1.12.x cannot re-instantiate ``MultiselectInput`` (it + emits ``list``, the constructor forbids extras; their own builtins fail + the same way). The template dict before that round-trip is complete and + faithful, so we use it directly and append the ``code`` field by hand + (mirroring the shape ``build_custom_component_template`` produces). + """ + sys.path.insert(0, str(REPO_ROOT / "backend" / "components")) + from kirche.form_inputs import CustomInputForm + from lfx.template.frontend_node.custom_components import ( + ComponentFrontendNode, + ) + + comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm" + inst = CustomInputForm() + frontend_node = ComponentFrontendNode.from_inputs( + **inst.get_template_config(inst) + ) + inst._map_parameters_on_frontend_node(frontend_node) + node = frontend_node.to_dict(keep_name=False) + node = inst._update_template(node) + inst._map_parameters_on_template(node["template"]) + if not getattr(inst, "_code", None): + inst.set_class_code() + node["template"]["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": inst._code, + } + node_dict = { + "data": {"node": node, "type": comp_name, "id": node_id}, + "id": node_id, + } + node_dict = _normalize(node_dict, node_id, comp_name, *position) + for field, value in (values or {}).items(): + node_dict["data"]["node"]["template"][field]["value"] = value + return node_dict + + # -------------------------------------------------------------------------- # edges # -------------------------------------------------------------------------- @@ -433,12 +492,43 @@ def build_approval_handoff() -> dict: ) +def build_intake_form() -> dict: + entry = builtin_node( + "lfx.components.input_output.chat", + "ChatInput", + "ChatInput-entry", + position=(0, 200), + ) + form = kirche_node( + "CustomInputForm-intake", + position=(400, 200), + ) + out = builtin_node( + "lfx.components.input_output.chat_output", + "ChatOutput", + "ChatOutput-collected", + position=(800, 200), + ) + nodes = [entry, form, out] + edges = [ + edge(entry, "message", form, "form_values"), + edge(form, "form_data", out, "input_value"), + ] + return flow_doc( + "intake_form", + "Structured intake via CustomInputForm (parsed into a web form).", + nodes, + edges, + ) + + def main() -> None: FIXTURE_DIR.mkdir(parents=True, exist_ok=True) FLOW_DIR.mkdir(parents=True, exist_ok=True) flows = { "simple_rule.json": build_simple_rule(), "approval_handoff.json": build_approval_handoff(), + "intake_form.json": build_intake_form(), } for filename, doc in flows.items(): payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" diff --git a/backend/tests/fixtures/flows/intake_form.json b/backend/tests/fixtures/flows/intake_form.json new file mode 100644 index 0000000..0de6f33 --- /dev/null +++ b/backend/tests/fixtures/flows/intake_form.json @@ -0,0 +1,991 @@ +{ + "data": { + "nodes": [ + { + "data": { + "node": { + "template": { + "_type": "Component", + "files": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "temp_file": true, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "files", + "value": "", + "display_name": "Files", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Files to be sent with the message.", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "input_value": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Input Text", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Message to be passed as input.", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "User", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "User", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Get chat inputs from the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Chat Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatInput", + "id": "ChatInput-entry", + "showNode": true + }, + "id": "ChatInput-entry", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "attachment": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "pdf", + "png", + "jpg" + ], + "temp_file": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "attachment", + "value": "", + "display_name": "Attachment", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "String reference only for now (no upload yet)", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "age": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "age", + "value": 18, + "display_name": "Age", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your age in years", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "amount": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "amount", + "value": 0.0, + "display_name": "Amount", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Amount in euros", + "title_case": false, + "track_in_telemetry": true, + "type": "float", + "_input_type": "FloatInput" + }, + "department": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Music", + "Youth", + "Seniors", + "Admin" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "department", + "value": "Music", + "display_name": "Department", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Which team is this for", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "form_values": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": false, + "name": "form_values", + "value": "", + "display_name": "Form values (JSON envelope)", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Submitted values as JSON; merged over the defaults", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "full_name": { + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "full_name", + "value": "", + "display_name": "Full name", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your first and last name", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "newsletter": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "newsletter", + "value": false, + "display_name": "Newsletter", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Send me the parish newsletter", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "notes": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "notes", + "value": "", + "display_name": "Notes", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Anything else we should know", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "password": { + "load_from_db": true, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "password", + "value": "", + "display_name": "Password", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Optional access phrase", + "title_case": false, + "track_in_telemetry": false, + "password": true, + "type": "str", + "_input_type": "SecretStrInput" + }, + "satisfaction": { + "tool_mode": false, + "min_label": "", + "max_label": "", + "min_label_icon": "", + "max_label_icon": "", + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "range_spec": { + "step_type": "float", + "min": 1.0, + "max": 5.0, + "step": 1.0 + }, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "satisfaction", + "value": 3, + "display_name": "Satisfaction", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "How satisfied are you (1-5)", + "title_case": false, + "track_in_telemetry": false, + "type": "slider", + "_input_type": "SliderInput" + }, + "topics": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Worship", + "Outreach", + "Courses" + ], + "combobox": false, + "toggle": false, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "topics", + "value": [], + "display_name": "Topics", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "What are you interested in", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MultiselectInput" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``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" + } + }, + "description": "Collects structured form values into a single JSON message.", + "icon": "ClipboardList", + "base_classes": [], + "display_name": "CustomInput Form", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "form_data", + "display_name": "Form Data", + "method": "collect", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "CustomInputForm", + "id": "CustomInputForm-intake", + "showNode": true + }, + "id": "CustomInputForm-intake", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-collected", + "showNode": true + }, + "id": "ChatOutput-collected", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputForm-intake", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputForm-intake", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "CustomInputForm-intake", + "name": "form_data", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "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œ: [œMessageœ]}-{œ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œ]}", + "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": "Structured intake via CustomInputForm (parsed into a web form).", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "ec795b9d-f9d2-5730-b016-16e5f9b3bd3f", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "intake_form", + "tags": [], + "webhook": false +} diff --git a/backend/tests/test_form_components.py b/backend/tests/test_form_components.py new file mode 100644 index 0000000..91f0a20 --- /dev/null +++ b/backend/tests/test_form_components.py @@ -0,0 +1,104 @@ +"""Phase 2 tests: kirche CustomInput* library + intake fixture flow.""" + +import json +import sys +from pathlib import Path + +from lfx.custom import Component + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO_ROOT / "backend" / "components")) + +from kirche.form_inputs import CustomInputForm # noqa: E402 + +FIXTURES = Path(__file__).parent / "fixtures" / "flows" + +FORM_FIELDS = [ + "full_name", + "notes", + "password", + "age", + "amount", + "newsletter", + "department", + "topics", + "satisfaction", + "attachment", +] + + +def test_component_contract(): + assert issubclass(CustomInputForm, Component) + assert CustomInputForm.display_name.startswith("CustomInput") + inputs = {i.name: i for i in CustomInputForm.inputs} + # Ten form fields plus the hidden run-envelope plumbing input. + assert sorted(n for n in inputs if n != "form_values") == sorted(FORM_FIELDS) + assert inputs["form_values"].show is False + outputs = {o.name: o for o in CustomInputForm.outputs} + assert set(outputs) == {"form_data"} + + +def test_collect_merges_submitted_values_over_defaults(): + from lfx.schema.message import Message + + form = CustomInputForm() + default = json.loads(form.collect().text) + assert default["full_name"] == "" + assert default["department"] == "Music" + assert isinstance(form.collect(), Message) + + form.form_values = json.dumps({"full_name": "Maria", "age": 41}) + merged = json.loads(form.collect().text) + 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" + + form.form_values = "not json" + assert json.loads(form.collect().text)["full_name"] == "" + + +def test_intake_fixture_shape(): + doc = json.loads((FIXTURES / "intake_form.json").read_text()) + nodes = {n["id"]: n for n in doc["data"]["nodes"]} + form = nodes["CustomInputForm-intake"] + assert form["data"]["type"] == "CustomInputForm" + assert form["data"]["node"]["display_name"].startswith("CustomInput") + template = form["data"]["node"]["template"] + for field in FORM_FIELDS: + assert field in template, field + assert template["form_values"]["show"] is False + assert len(template["code"]["value"]) > 200 + 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 + + +async def test_intake_deploy_and_run_with_values(client, lfx_server): + doc = json.loads((FIXTURES / "intake_form.json").read_text()) + created = await client.post("/api/flows", json={"name": "intake-e2e"}) + assert created.status_code == 201, created.text + flow_id = created.json()["id"] + + version = await client.post( + f"/api/flows/{flow_id}/versions", + json={"langflow_json": doc, "author": "test"}, + ) + assert version.status_code == 201, version.text + + deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") + assert deployed.status_code == 200, deployed.text + + run = await client.post( + f"/api/flows/{flow_id}/run", + json={"input": {"full_name": "Maria Muster", "age": 41}}, + ) + assert run.status_code == 200, run.text + assert run.json()["status"] == "completed" + result = json.loads(run.json()["output"]["result"]) + assert result["full_name"] == "Maria Muster" + assert result["age"] == 41 + assert result["department"] == "Music" # default preserved diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py new file mode 100644 index 0000000..1cb7fd5 --- /dev/null +++ b/backend/tests/test_input_schema.py @@ -0,0 +1,201 @@ +"""Phase 3 tests: form-schema extraction, sync cache, input-schema endpoint.""" + +import json +from pathlib import Path +from uuid import uuid4 + +from app.flows import input_schema as parser +from app.flows import sync +from app.flows.lfx_client import LfxClient +from app.settings import settings + +FIXTURES = Path(__file__).parent / "fixtures" / "flows" + + +def _field(name, itype, **kw): + base = { + "name": name, + "display_name": name.replace("_", " ").title(), + "info": "", + "value": None, + "required": False, + "advanced": False, + "show": True, + "_input_type": itype, + } + base.update(kw) + return base + + +def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"): + template = {f["name"]: f for f in fields} + node = { + "data": { + "type": node_type, + "node": {"display_name": display, "template": template}, + } + } + return {"data": {"nodes": [node], "edges": []}} + + +def test_type_mapping_table(): + doc = _doc( + _field("a", "StrInput", value="x"), + _field("b", "MessageTextInput"), + _field("c", "MultilineInput"), + _field("d", "SecretStrInput"), + _field("e", "IntInput", value=3), + _field("f", "FloatInput", value=1.5), + _field("g", "BoolInput", value=True), + _field( + "h", + "DropdownInput", + options=["Music", "Youth"], + value="Youth", + ), + _field( + "i", + "MultiselectInput", + options=["Worship", "Courses"], + value=["Worship"], + ), + _field( + "j", + "SliderInput", + value=3, + range_spec={"min": 1, "max": 5, "step": 1}, + ), + _field("k", "FileInput"), + _field("l", "FutureFancyInput"), + ) + schema = parser.extract_form_schema(doc) + assert "fallback" not in schema + props = schema["json_schema"]["properties"] + assert props["a"] == {"title": "A", "type": "string", "default": "x"} + assert props["b"]["type"] == "string" + assert props["e"] == {"title": "E", "type": "integer", "default": 3} + assert props["f"]["type"] == "number" + assert props["g"] == {"title": "G", "type": "boolean", "default": True} + assert props["h"] == { + "title": "H", + "type": "string", + "enum": ["Music", "Youth"], + "default": "Youth", + } + assert props["i"]["type"] == "array" + assert props["i"]["items"] == {"type": "string", "enum": ["Worship", "Courses"]} + assert props["i"]["uniqueItems"] is True + assert props["j"]["minimum"] == 1 + assert props["j"]["maximum"] == 5 + assert props["j"]["multipleOf"] == 1 + assert props["k"]["type"] == "string" + assert props["l"]["type"] == "string" + ui = schema["ui_schema"] + assert ui["c"] == {"ui:widget": "textarea"} + assert ui["d"] == {"ui:widget": "password"} + assert ui["j"] == {"ui:widget": "range"} + + +def test_dropdown_default_outside_enum_omitted(): + doc = _doc(_field("h", "DropdownInput", options=["Music"], value="")) + props = parser.extract_form_schema(doc)["json_schema"]["properties"] + assert props["h"] == {"title": "H", "type": "string", "enum": ["Music"]} + + +def test_required_advanced_hidden_code(): + doc = _doc( + _field("a", "StrInput", required=True), + _field("b", "StrInput", advanced=True), + _field("c", "StrInput", show=False), + ) + doc["data"]["nodes"][0]["data"]["node"]["template"]["code"] = {"value": "x"} + schema = parser.extract_form_schema(doc) + assert schema["json_schema"]["required"] == ["a"] + assert set(schema["json_schema"]["properties"]) == {"a", "b"} + assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"] + + +def test_display_name_match_and_fallback(): + doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X") + assert set( + parser.extract_form_schema(doc)["json_schema"]["properties"] + ) == {"a"} + plain = { + "data": { + "nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}], + "edges": [], + } + } + schema = parser.extract_form_schema(plain) + assert schema["fallback"] == "textarea" + assert schema["json_schema"]["properties"] == {} + + +def test_real_intake_fixture_parses(): + doc = json.loads((FIXTURES / "intake_form.json").read_text()) + schema = parser.extract_form_schema(doc) + props = schema["json_schema"]["properties"] + assert "fallback" not in schema + assert set(props) == { + "full_name", + "notes", + "password", + "age", + "amount", + "newsletter", + "department", + "topics", + "satisfaction", + "attachment", + } + assert "full_name" in schema["json_schema"]["required"] + assert props["department"]["enum"] == ["Music", "Youth", "Seniors", "Admin"] + assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"} + + +def _lfx() -> LfxClient: + return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) + + +async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path): + (tmp_path / "intake_form.json").write_text( + (FIXTURES / "intake_form.json").read_text() + ) + report = await sync.sync_from_directory(session, _lfx(), tmp_path) + assert not report.errors, report.errors + from app.flows import store + + defs = store.list_definitions(session) + definition = next(d for d in defs if d.name == "intake_form") + versions = store.list_versions(session, definition.id) + assert len(versions) == 1 + cached = versions[0].input_schema + assert cached["json_schema"]["properties"]["full_name"]["type"] == "string" + assert "form_values" not in cached["json_schema"]["properties"] + + +async def test_input_schema_endpoint(client, lfx_server): + assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404 + + doc = json.loads((FIXTURES / "intake_form.json").read_text()) + created = await client.post("/api/flows", json={"name": "schema-e2e"}) + flow_id = created.json()["id"] + version = await client.post( + f"/api/flows/{flow_id}/versions", json={"langflow_json": doc} + ) + assert version.status_code == 201, version.text + assert version.json()["input_schema"]["json_schema"]["properties"]["age"][ + "type" + ] == "integer" + # Created but never deployed → 404. + assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 404 + + deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy") + assert deployed.status_code == 200, deployed.text + response = await client.get(f"/api/flows/{flow_id}/input-schema") + assert response.status_code == 200, response.text + body = response.json() + assert body["version_no"] == 1 + assert body["fallback"] is None + assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"} + assert body["ui_schema"]["password"] == {"ui:widget": "password"}