diff --git a/.env.example b/.env.example index 66225d5..40e3dbe 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ LANGFLOW_SUPERUSER=langflow # Long random values (check_prod.py enforces length). Never leave the # placeholders: the authoring UI is internet-facing. LANGFLOW_SUPERUSER_PASSWORD= +# MUST be a valid Fernet key (44 chars, urlsafe-base64, 32 bytes) — any +# other value breaks variable decryption ("Fernet key must be 32 +# url-safe base64-encoded bytes") and provider keys cannot be saved. +# Generate with: +# python -c "import base64,os;print(base64.urlsafe_b64encode(os.urandom(32)).decode())" LANGFLOW_SECRET_KEY= # Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. LANGFLOW_API_KEY=sk- @@ -51,4 +56,21 @@ SMTP_USERNAME= SMTP_PASSWORD= # --- LLM (optional; tests mock the provider without it) --- -# LLM_API_KEY= \ No newline at end of file +# LLM_API_KEY= + +# --- LLM provider für Langflow (Authoring-UI + LFX-Runtime) --- +# Langflow erzeugt daraus automatisch Global Variables (Credential-Typ); +# LFX liest dieselben Werte zur Laufzeit. Eine in der UI angelegte Global +# Variable mit gleichem Namen hat Vorrang, die Env-Variable ist Fallback. +# OpenAI-kompatibler Endpoint (auch selbst gehostet); ohne eigenen Provider +# leer lassen. Beispiel: https://openrouter.ai/api/v1 +OPENAI_API_KEY= +OPENAI_BASE_URL= + +# --- Langflow-Login-Sitzung --- +# Refresh-Token-Gültigkeit in Sekunden (Default 604800 = 7 Tage). +# 7776000 = 90 Tage: einmal einloggen, Container-Restarts übersteht die +# Session, solange LANGFLOW_SECRET_KEY stabil bleibt. In Prod kürzer wählen. +# LANGFLOW_REFRESH_TOKEN_EXPIRE_SECONDS=7776000 +# Erstellt Global Variables aus Env (Credential-Typ), restart-fest: +# LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=OPENAI_API_KEY,OPENAI_BASE_URL \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ffec58f..2ace98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync` + (Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle + Flows aus Langflow nach `backend/flows/*.json` und deployed sie danach + automatisch (Report mit `pulled`-Liste). Der Hintergrund-Beobachter und der + Startup-Sync bleiben dateibasiert. Die Verwaltung zeigt eine deutsche + Schritt-Anleitung direkt beim Sync-Button. +- LLM-Provider-Umgebung für Langflow: `OPENAI_API_KEY` + `OPENAI_BASE_URL` + in `.env` (OpenAI-kompatible Endpoints) erzeugen automatisch + restart-feste Global Variables in Langflow; LFX liest dieselben Werte. +- Langflow-Sitzungen halten länger: `LANGFLOW_REFRESH_TOKEN_EXPIRE_SECONDS` + (Default weiter 7 Tage, Dev-Beispiel 90) + stabile JWT-Signatur via + `LANGFLOW_SECRET_KEY` — kein Re-Login nach Container-Restarts. ### Fixed +- Flow-Sync aus Langflow: neue Flows blieben unsichtbar, weil LFX-Uploads + mit `Permission denied` scheiterten (named Volume root-owned, LFX läuft + rootless). `lfx-entrypoint.sh` chownt `/flows` per gosu vor dem Start; + zusätzlich Pull-Dedup (wiederverwendet gleichnamige/identische Dateien + statt Bindestrich-Duplikate wie `simple-rule.json`, die jede Sync-Meldung + mit Namenskonflikten füllten). +- Sync-Dateinamen behalten die Langflow-Namen (`test_flow.json` statt + `test-flow.json`; nur dateisystem-reservierte Zeichen werden ersetzt). +- Gezogene Flow-Dateien sind world-readable (0o644): `mkstemp` erzeugte + 0o600-Dateien, die Langflow beim Start abstürzen ließen + (`Permission denied` in `/app/flows`, Crash-Loop) und der API Fehler + (`unreadable`) verursachten. +- `LANGFLOW_SECRET_KEY` muss ein gültiger Fernet-Key sein (44 Zeichen, + urlsafe-base64): ein Hex-String brach die Variablen-Verschlüsselung mit + `Fernet key must be 32 url-safe base64-encoded bytes` (500 beim Anlegen + von Global Variables — Provider-Keys ließen sich nicht speichern). + `.env.example` dokumentiert die Erzeugung. - LFX runtime user gets a home directory (`useradd -m`, `HOME` set): without one every flow run failed with "Settings service not found". ### Security diff --git a/Dockerfile.lfx b/Dockerfile.lfx index c533199..34ace67 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -16,11 +16,18 @@ RUN pip install "lfx~=${LFX_VERSION}" requests # Non-root runtime: uploads persist into the mounted volume. The user needs # a home directory: lfx resolves settings/cache paths from $HOME, and # without one every flow run fails ("Settings service not found"). +# gosu drops privileges in the entrypoint after chowning /flows: the named +# volume arrives root-owned and the server user could otherwise never write +# uploads (every deploy would fail with "Permission denied"). ENV HOME=/home/lfx -RUN useradd -r -m -s /bin/false lfx && mkdir -p /flows && chown lfx /flows +RUN apt-get update \ + && apt-get install -y --no-install-recommends gosu \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -r -m -s /bin/false lfx && mkdir -p /flows && chown lfx /flows +COPY lfx-entrypoint.sh /usr/local/bin/lfx-entrypoint.sh VOLUME /flows EXPOSE 6004 -USER lfx +ENTRYPOINT ["sh", "/usr/local/bin/lfx-entrypoint.sh"] # Serve the /flows dir (uploaded flows persist there via --flow-dir). # No positional path: with one, lfx>=1.12.1 exits when the dir holds no diff --git a/README.md b/README.md index 30fde11..1c09b1c 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,11 @@ a database server. Postgres can return later by changing `DATABASE_URL` `.env` holds only ubiquitous variables (identical value in dev and prod: secrets, service URLs). Dev-only variables travel via `devcontainer.json` -(`APP_ENV=dev`); paths resolve from the repo root in code. +(`APP_ENV=dev`); paths resolve from the repo root in code. LLM provider +keys for Langflow are plain env vars (`OPENAI_API_KEY`, +`OPENAI_BASE_URL`) — Langflow turns them into restart-proof global +variables, LFX reads them at runtime. `LANGFLOW_SECRET_KEY` must be a +valid Fernet key (see `.env.example`). ## Getting Started @@ -122,9 +126,12 @@ Frontend (node lives in the `frontend` container): - `docker compose exec frontend npm run build` — typecheck + build. - Without a docker socket: VS Code "Attach to Running Container" → frontend. -Flow workflow: design in Langflow → export into `backend/flows/.json` -(`--pull`, or UI download) → commit → the watcher deploys it automatically. -Deleting a file removes the flow including its runs (git keeps the JSON). +Flow workflow: design in Langflow → press "Aus Langflow synchronisieren" +in Verwaltung (manual full pass: Langflow → `backend/flows/.json` → +DB + LFX deploy) → commit. File-only alternatives: `scripts/sync_flows.py +--pull --sync`, UI download, or the single-flow import by Langflow ID. The +watcher deploys file edits automatically. Deleting a file removes the flow +including its runs (git keeps the JSON). ## Production (self-hosted — TLS/reverse proxy required, not included) diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index f47ee17..eaca507 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -29,7 +29,7 @@ from app.settings import settings from . import input_schema as form_schema from . import runner, store -from .deploy import import_from_langflow +from .deploy import import_from_langflow, pull_all_from_langflow from .langflow_client import ( LangflowClient, LangflowError, @@ -160,13 +160,25 @@ def list_flows(session: Annotated[Session, Depends(get_db)]): async def sync_flows( session: Annotated[Session, Depends(get_db)], lfx: Annotated[LfxClient, Depends(get_lfx)], + langflow: Annotated[LangflowClient, Depends(get_langflow)], _admin: Annotated[User, Depends(require_admin)], ): - """Reconcile backend/flows/*.json into the DB + LFX (add/update/delete).""" + """Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX.""" try: - return await sync_from_directory(session, lfx, Path(settings.flows_dir)) + pulled_result = await pull_all_from_langflow(langflow, Path(settings.flows_dir)) + except LangflowUnavailable as exc: + logger.warning("langflow unavailable: %s", exc) + raise HTTPException(status_code=503, detail="authoring server unavailable") from exc + except LangflowError as exc: + logger.warning("langflow error: %s", exc) + raise HTTPException(status_code=502, detail="authoring server error") from exc + try: + report = await sync_from_directory(session, lfx, Path(settings.flows_dir)) except FileNotFoundError as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc + report.pulled = pulled_result["pulled"] + report.errors = {**pulled_result["errors"], **report.errors} + return report @flows_router.get("/{flow_id}", response_model=FlowDefinitionRead) diff --git a/backend/app/flows/deploy.py b/backend/app/flows/deploy.py index 535d436..6f017cb 100644 --- a/backend/app/flows/deploy.py +++ b/backend/app/flows/deploy.py @@ -2,16 +2,65 @@ from __future__ import annotations +from pathlib import Path + from sqlmodel import Session from app.models import FlowDefinition, FlowVersion from app.settings import settings from . import export, sync -from .langflow_client import LangflowClient +from .langflow_client import LangflowClient, LangflowError from .lfx_client import LfxClient +def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict: + """Reduce a Langflow FlowRead to the canonical fields we persist.""" + flow_name = name or exported.get("name") or f"langflow-{langflow_flow_id}" + return { + "id": exported.get("id", langflow_flow_id), + "name": flow_name, + "description": exported.get("description") or "", + "endpoint_name": exported.get("endpoint_name"), + "data": exported.get("data", {}), + "tags": exported.get("tags", []), + "webhook": exported.get("webhook", False), + "is_component": exported.get("is_component", False), + } + + +async def pull_all_from_langflow( + langflow: LangflowClient, flows_dir: str | Path +) -> dict[str, list[str] | dict[str, str]]: + """Export every Langflow flow to its canonical file. + + Returns {"pulled": [...stems], "errors": {langflow_id: reason}}. + Per-flow failures are collected, never raised; listing failures raise + (caller maps them to 503/502). + """ + flows_dir = Path(flows_dir) + headers = await langflow.list_flows() + pulled: list[str] = [] + errors: dict[str, str] = {} + for header in headers: + flow_id = str(header.get("id", "")) + if not flow_id: + continue + try: + exported = await langflow.export_flow(flow_id) + doc = normalize_exported_flow(exported, flow_id) + reuse, identical = export.find_reuse_path(flows_dir, doc) + if identical and reuse is not None: + pulled.append(reuse.stem) + continue + target = reuse or export.flow_path(flows_dir, export.filename_stem(doc["name"])) + path = export.write_flow_bytes(target, doc) + pulled.append(path.stem) + except (LangflowError, OSError, ValueError) as exc: + errors[flow_id] = str(exc) + return {"pulled": sorted(pulled), "errors": errors} + + async def import_from_langflow( session: Session, langflow: LangflowClient, @@ -28,18 +77,8 @@ async def import_from_langflow( truth; sync_document() derives the definition + version from it. """ exported = await langflow.export_flow(langflow_flow_id) - flow_name = name or exported.get("name") or f"langflow-{langflow_flow_id}" - langflow_json = { - "id": exported.get("id", langflow_flow_id), - "name": flow_name, - "description": exported.get("description") or "", - "endpoint_name": exported.get("endpoint_name"), - "data": exported.get("data", {}), - "tags": exported.get("tags", []), - "webhook": exported.get("webhook", False), - "is_component": exported.get("is_component", False), - } - path = export.write_flow_file(settings.flows_dir, langflow_json, name=flow_name) + langflow_json = normalize_exported_flow(exported, langflow_flow_id, name=name) + path = export.write_flow_file(settings.flows_dir, langflow_json, name=langflow_json["name"]) definition, version, _action = await sync.sync_document( session, lfx, diff --git a/backend/app/flows/export.py b/backend/app/flows/export.py index 0fac6c0..0681b2f 100644 --- a/backend/app/flows/export.py +++ b/backend/app/flows/export.py @@ -16,10 +16,17 @@ from pathlib import Path FLOWS_GLOB = "*.json" -def slugify(name: str) -> str: - """Map a flow name to a safe filename stem (lowercase, [a-z0-9-]).""" - slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - return slug or "flow" +def filename_stem(name: str) -> str: + """Map a flow name to a filename stem, keeping the original name. + + Only filesystem-reserved characters (``/ \\ : * ? " < > |`` and control + chars) become ``-``; leading/trailing whitespace and trailing dots are + stripped (Windows). Case, spaces, underscores and umlauts are preserved: + ``test_flow`` stays ``test_flow``. + """ + stem = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "-", name).strip() + stem = stem.rstrip(".") + return stem or "flow" def canonical_bytes(doc: dict) -> bytes: @@ -36,21 +43,55 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path: return Path(flows_dir) / f"{stem}.json" -def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path: - """Write a flow document to backend/flows atomically (tmp + rename). +def _read_doc(path: Path) -> dict | None: + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return doc if isinstance(doc, dict) else None - The filename derives from `name` (or the document's own name). Returns - the written path. + +def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool]: + """Existing file to reuse for `doc`: (path, identical). + + Matches identical content first (any filename), then the same flow name. + Returns (None, False) when no candidate exists (caller writes a slug file). + This keeps pulls idempotent: a flow seeded from `simple_rule.json` is + updated in place instead of duplicated as `simple-rule.json`. """ flows_dir = Path(flows_dir) - flows_dir.mkdir(parents=True, exist_ok=True) - stem = slugify(name or doc.get("name") or "flow") - target = flow_path(flows_dir, stem) - fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp") + if not flows_dir.is_dir(): + return None, False + wanted_hash = content_hash(doc) + wanted_name = doc.get("name") + named: Path | None = None + for path in iter_flow_files(flows_dir): + existing = _read_doc(path) + if existing is None: + continue + if content_hash(existing) == wanted_hash: + return path, True + if named is None and wanted_name and existing.get("name") == wanted_name: + named = path + return (named, False) if named is not None else (None, False) + + +def write_flow_bytes(target: Path, doc: dict) -> Path: + """Write a flow document to an explicit path atomically (tmp + rename). + + The file ends up world-readable (0o644): mkstemp creates 0o600, which + would lock out the other containers (Langflow/LFX run as different + users and crash on unreadable seed files). + """ + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp" + ) try: with os.fdopen(fd, "wb") as tmp: tmp.write(canonical_bytes(doc)) os.replace(tmp_name, target) + os.chmod(target, 0o644) except BaseException: try: os.unlink(tmp_name) @@ -60,6 +101,18 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None return target +def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path: + """Write a flow document to backend/flows atomically (tmp + rename). + + The filename derives from `name` (or the document's own name). Returns + the written path. + """ + flows_dir = Path(flows_dir) + flows_dir.mkdir(parents=True, exist_ok=True) + stem = filename_stem(name or doc.get("name") or "flow") + return write_flow_bytes(flow_path(flows_dir, stem), doc) + + def iter_flow_files(flows_dir: str | Path) -> list[Path]: """Sorted list of candidate flow files (top level only).""" return sorted(Path(flows_dir).glob(FLOWS_GLOB)) diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py index d32dfcb..f2616a4 100644 --- a/backend/app/flows/schemas.py +++ b/backend/app/flows/schemas.py @@ -127,13 +127,15 @@ class DecisionRead(BaseModel): class SyncReport(BaseModel): - """Result of reconciling backend/flows/*.json into DB + LFX. + """Result of the manual full pass: Langflow -> files -> DB + LFX. - Lists hold filename stems; errors maps filename to reason. + Lists hold filename stems; errors maps filename (or Langflow id on + pull failures) to reason. """ added: list[str] = Field(default_factory=list) updated: list[str] = Field(default_factory=list) deleted: list[str] = Field(default_factory=list) skipped: list[str] = Field(default_factory=list) + pulled: list[str] = Field(default_factory=list) errors: dict[str, str] = Field(default_factory=dict) diff --git a/backend/flows/approval_handoff.json b/backend/flows/approval_handoff.json index fbc7091..387ace3 100644 --- a/backend/flows/approval_handoff.json +++ b/backend/flows/approval_handoff.json @@ -1,1681 +1,5 @@ { "data": { - "nodes": [ - { - "data": { - "node": { - "template": { - "_type": "Component", - "files": { - "tool_mode": false, - "trace_as_metadata": true, - "file_path": "", - "fileTypes": [ - "csv", - "json", - "pdf", - "txt", - "md", - "mdx", - "yaml", - "yml", - "xml", - "html", - "htm", - "docx", - "py", - "sh", - "sql", - "js", - "ts", - "tsx", - "jpg", - "jpeg", - "png", - "bmp", - "image" - ], - "temp_file": true, - "list": true, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "files", - "value": "", - "display_name": "Files", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Files to be sent with the message.", - "title_case": false, - "track_in_telemetry": false, - "type": "file", - "_input_type": "FileInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "context_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "context_id", - "value": "", - "display_name": "Context ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "input_value": { - "tool_mode": false, - "trace_as_input": true, - "multiline": true, - "ai_enabled": false, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "input_value", - "value": "", - "display_name": "Input Text", - "advanced": false, - "api_editable": false, - "input_types": [], - "dynamic": false, - "info": "Message to be passed as input.", - "title_case": false, - "track_in_telemetry": false, - "copy_field": false, - "password": false, - "type": "str", - "_input_type": "MultilineInput" - }, - "sender": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender", - "value": "User", - "display_name": "Sender Type", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Type of sender.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "sender_name": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender_name", - "value": "User", - "display_name": "Sender Name", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Name of the sender.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "session_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "session_id", - "value": "", - "display_name": "Session ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "should_store_message": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "should_store_message", - "value": true, - "display_name": "Store Messages", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Store the message in the history.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - } - }, - "description": "Get chat inputs from the Playground.", - "icon": "MessagesSquare", - "base_classes": [ - "Message" - ], - "display_name": "Chat Input", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "minimized": true, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "message", - "display_name": "Chat Message", - "method": "message_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ChatInput", - "id": "ChatInput-entry", - "showNode": true - }, - "id": "ChatInput-entry", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 0, - "y": 300 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "raw": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "raw", - "value": "", - "display_name": "Raw Input", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - } - }, - "description": "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.", - "base_classes": [ - "Message" - ], - "display_name": "Envelope", - "documentation": "", - "minimized": false, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "payload", - "display_name": "Payload", - "method": "get_payload", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - }, - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "run_id", - "display_name": "Run ID", - "method": "get_run_id", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - }, - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "callback_url", - "display_name": "Callback URL", - "method": "get_callback_url", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - }, - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "continuation", - "display_name": "Continuation", - "method": "get_continuation", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - }, - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "callback_token", - "display_name": "Callback Token", - "method": "get_callback_token", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [ - "raw" - ], - "beta": false, - "legacy": false, - "edited": false, - "metadata": { - "module": "custom_components.envelope", - "code_hash": "02e69613e210", - "dependencies": { - "total_dependencies": 1, - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - } - ] - } - }, - "tool_mode": false - }, - "type": "CustomComponent", - "id": "CustomComponent-envelope", - "showNode": true - }, - "id": "CustomComponent-envelope", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 400, - "y": 300 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass RuleCheckComponent(Component):\n display_name = \"Rule Check\"\n description = \"Bewertet Betrag gegen Schwelle aus JSON-Eingabe.\"\n\n inputs = [\n MessageTextInput(name=\"payload\", display_name=\"Payload\"),\n ]\n outputs = [\n Output(display_name=\"Verdict\", name=\"verdict\", method=\"check\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def check(self) -> Message:\n try:\n data = json.loads(self._text(self.payload) or \"{}\")\n except Exception:\n data = {}\n if not isinstance(data, dict):\n data = {}\n # Die API ruft Flows immer mit Huelle auf\n # {\"payload\": {...}, \"run_id\": ..., ...}; rohe Payloads gehen auch.\n if isinstance(data.get(\"payload\"), dict):\n data = data[\"payload\"]\n try:\n betrag = float(data.get(\"betrag\", 0) or 0)\n except (TypeError, ValueError):\n betrag = 0.0\n try:\n schwelle = float(data.get(\"schwelle\", 200000) or 200000)\n except (TypeError, ValueError):\n schwelle = 200000.0\n return Message(text=\"PRUEFUNG\" if betrag > schwelle else \"OK\")\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "payload": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "payload", - "value": "", - "display_name": "Payload", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - } - }, - "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.", - "base_classes": [ - "Message" - ], - "display_name": "Rule Check", - "documentation": "", - "minimized": false, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "verdict", - "display_name": "Verdict", - "method": "check", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [ - "payload" - ], - "beta": false, - "legacy": false, - "edited": false, - "metadata": { - "module": "custom_components.rule_check", - "code_hash": "0b9f8a009fcf", - "dependencies": { - "total_dependencies": 1, - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - } - ] - } - }, - "tool_mode": false - }, - "type": "CustomComponent", - "id": "CustomComponent-rule", - "showNode": true - }, - "id": "CustomComponent-rule", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 800, - "y": 300 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "case_sensitive": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "case_sensitive", - "value": true, - "display_name": "Case Sensitive", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "If true, the comparison will be case sensitive.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "import re\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n documentation: str = \"https://docs.langflow.org/if-else\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n \"\"\"Handles cycle iteration counting and branch exclusion.\n\n Uses two complementary mechanisms:\n 1. stop() - ACTIVE/INACTIVE state for cycle management (gets reset each iteration)\n 2. exclude_branch_conditionally() - Persistent exclusion for conditional routing\n\n When max_iterations is reached, breaks the cycle by allowing the default_route to execute.\n \"\"\"\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n\n # Check if max iterations reached and we're trying to stop the default route\n if current_iteration >= self.max_iterations and route_to_stop == self.default_route:\n # Clear ALL conditional exclusions to allow default route to execute\n if self._id in self.graph.conditional_exclusion_sources:\n previous_exclusions = self.graph.conditional_exclusion_sources[self._id]\n self.graph.conditionally_excluded_vertices -= previous_exclusions\n del self.graph.conditional_exclusion_sources[self._id]\n\n # Switch which route to stop - stop the NON-default route to break the cycle\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n\n # Call stop to break the cycle\n self.stop(route_to_stop)\n # Don't apply conditional exclusion when breaking cycle\n return\n\n # Normal case: Use BOTH mechanisms\n # 1. stop() for cycle management (marks INACTIVE, updates run manager, gets reset)\n self.stop(route_to_stop)\n\n # 2. Conditional exclusion for persistent routing (doesn't get reset except by this router)\n self.graph.exclude_branch_conditionally(self._id, output_name=route_to_stop)\n\n def _resolve_case_message(self, case_message: Message | str | None) -> Message:\n \"\"\"Return the override case message, falling back to the input text when it is blank.\n\n ``Case True`` and ``Case False`` are optional overrides. When left blank the\n ``MessageInput`` resolves to an empty ``Message``, so the original ``Text Input``\n is routed through instead of an empty message.\n\n A ``Message`` is only treated as blank when it carries no payload at all. An\n override with empty text but meaningful content (files or content blocks) is\n preserved as-is so that payload is not dropped.\n\n Blankness is judged by payload only (``text``, ``files``, ``content_blocks``).\n A ``Message`` carrying just metadata (``sender``, ``properties``, ``error`` ...)\n and no payload is intentionally treated as blank: every empty ``Message`` also\n defaults ``timestamp`` / ``category`` / ``properties``, so keying on metadata\n would defeat the blank-field fallback. With no payload there is nothing to\n route, so the ``Text Input`` is used.\n\n ``case_message`` is typed defensively as ``Message | str | None``. ``MessageInput``\n wraps user input into a ``Message`` on the validated graph path, but a raw ``str``\n can still arrive via direct/programmatic assignment (``.set(...)`` stores the raw\n value), so the ``str`` branch is retained rather than dropping such an override.\n \"\"\"\n if isinstance(case_message, Message):\n if case_message.text or case_message.files or case_message.content_blocks:\n return case_message\n return Message(text=self.input_text)\n if isinstance(case_message, str) and case_message:\n return Message(text=case_message)\n return Message(text=self.input_text)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n # Check if we should force output due to max_iterations on default route\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n force_output = current_iteration >= self.max_iterations and self.default_route == \"true_result\"\n\n if result or force_output:\n true_message = self._resolve_case_message(self.true_case_message)\n self.status = true_message\n if not force_output: # Only stop the other branch if not forcing due to max iterations\n self.iterate_and_stop_once(\"false_result\")\n return true_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(text=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n if not result:\n false_message = self._resolve_case_message(self.false_case_message)\n self.status = false_message\n self.iterate_and_stop_once(\"true_result\")\n return false_message\n\n self.iterate_and_stop_once(\"false_result\")\n return Message(text=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "default_route": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "true_result", - "false_result" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "default_route", - "value": "false_result", - "display_name": "Default Route", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "The default route to take when max iterations are reached.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "false_case_message": { - "trace_as_input": true, - "tool_mode": false, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "false_case_message", - "value": "Standardfall: automatisch freigegeben", - "display_name": "Case False", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The message to pass if the condition is False.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageInput" - }, - "input_text": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "input_text", - "value": "", - "display_name": "Text Input", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The primary text input for the operation.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "match_text": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "match_text", - "value": "PRUEFUNG", - "display_name": "Match Text", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The text input to compare against.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "max_iterations": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "max_iterations", - "value": 10, - "display_name": "Max Iterations", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "The maximum number of iterations for the conditional router.", - "title_case": false, - "track_in_telemetry": true, - "type": "int", - "_input_type": "IntInput" - }, - "operator": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "equals", - "not equals", - "contains", - "starts with", - "ends with", - "regex", - "less than", - "less than or equal", - "greater than", - "greater than or equal" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "operator", - "value": "equals", - "display_name": "Operator", - "advanced": false, - "api_editable": false, - "dynamic": false, - "info": "The operator to apply for comparing the texts.", - "real_time_refresh": true, - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "true_case_message": { - "trace_as_input": true, - "tool_mode": false, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "true_case_message", - "value": "Sonderfall: menschliche Pruefung erforderlich", - "display_name": "Case True", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The message to pass if the condition is True.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageInput" - } - }, - "description": "Routes an input message to a corresponding output based on text comparison.", - "icon": "split", - "base_classes": [ - "Message" - ], - "display_name": "If-Else", - "documentation": "https://docs.langflow.org/if-else", - "minimized": false, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "true_result", - "display_name": "True", - "method": "true_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": true, - "tool_mode": true - }, - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "false_result", - "display_name": "False", - "method": "false_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": true, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ConditionalRouter", - "id": "ConditionalRouter-router", - "showNode": true - }, - "id": "ConditionalRouter-router", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 1200, - "y": 300 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "callback_token": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "callback_token", - "value": "", - "display_name": "Callback Token", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "callback_url": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "callback_url", - "value": "", - "display_name": "Callback URL", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "continuation_flow_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "continuation_flow_id", - "value": "", - "display_name": "Continuation Flow ID", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "facts": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "facts", - "value": "", - "display_name": "Facts", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "run_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "run_id", - "value": "", - "display_name": "Run ID", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "trigger": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "trigger", - "value": "", - "display_name": "Trigger", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - } - }, - "description": "Meldet einen Sonderfall per HTTP an unsere API und terminiert.", - "base_classes": [ - "Message" - ], - "display_name": "Approval Callback", - "documentation": "", - "minimized": false, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "result", - "display_name": "Result", - "method": "notify", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [ - "trigger", - "run_id", - "callback_url", - "callback_token", - "continuation_flow_id", - "facts" - ], - "beta": false, - "legacy": false, - "edited": false, - "metadata": { - "module": "custom_components.approval_callback", - "code_hash": "28b2f5894566", - "dependencies": { - "total_dependencies": 2, - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - }, - { - "name": "httpx", - "version": "0.28.1" - } - ] - } - }, - "tool_mode": false - }, - "type": "CustomComponent", - "id": "CustomComponent-callback", - "showNode": true - }, - "id": "CustomComponent-callback", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 1600, - "y": 160 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "input_value": { - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "input_value", - "value": "", - "display_name": "Inputs", - "advanced": false, - "api_editable": false, - "input_types": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "dynamic": false, - "info": "Message to be passed as output.", - "title_case": false, - "track_in_telemetry": false, - "type": "other", - "_input_type": "HandleInput" - }, - "clean_data": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "clean_data", - "value": true, - "display_name": "Basic Clean Data", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Whether to clean data before converting to string.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "context_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "context_id", - "value": "", - "display_name": "Context ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "data_template": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "data_template", - "value": "{text}", - "display_name": "Data Template", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "sender": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender", - "value": "Machine", - "display_name": "Sender Type", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Type of sender.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "sender_name": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender_name", - "value": "AI", - "display_name": "Sender Name", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Name of the sender.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "session_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "session_id", - "value": "", - "display_name": "Session ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "should_store_message": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "should_store_message", - "value": true, - "display_name": "Store Messages", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Store the message in the history.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - } - }, - "description": "Display a chat message in the Playground.", - "icon": "MessagesSquare", - "base_classes": [ - "Message" - ], - "display_name": "Chat Output", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "minimized": true, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "message", - "display_name": "Output Message", - "method": "message_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ChatOutput", - "id": "ChatOutput-pending", - "showNode": true - }, - "id": "ChatOutput-pending", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 2000, - "y": 160 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "input_value": { - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "input_value", - "value": "", - "display_name": "Inputs", - "advanced": false, - "api_editable": false, - "input_types": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "dynamic": false, - "info": "Message to be passed as output.", - "title_case": false, - "track_in_telemetry": false, - "type": "other", - "_input_type": "HandleInput" - }, - "clean_data": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "clean_data", - "value": true, - "display_name": "Basic Clean Data", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Whether to clean data before converting to string.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "context_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "context_id", - "value": "", - "display_name": "Context ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "data_template": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "data_template", - "value": "{text}", - "display_name": "Data Template", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "sender": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender", - "value": "Machine", - "display_name": "Sender Type", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Type of sender.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "sender_name": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender_name", - "value": "AI", - "display_name": "Sender Name", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Name of the sender.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "session_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "session_id", - "value": "", - "display_name": "Session ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "should_store_message": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "should_store_message", - "value": true, - "display_name": "Store Messages", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Store the message in the history.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - } - }, - "description": "Display a chat message in the Playground.", - "icon": "MessagesSquare", - "base_classes": [ - "Message" - ], - "display_name": "Chat Output", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "minimized": true, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "message", - "display_name": "Output Message", - "method": "message_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ChatOutput", - "id": "ChatOutput-ok", - "showNode": true - }, - "id": "ChatOutput-ok", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 2000, - "y": 440 - }, - "type": "genericNode" - } - ], "edges": [ { "animated": false, @@ -1994,6 +318,1682 @@ "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" } ], + "nodes": [ + { + "data": { + "id": "ChatInput-entry", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Get chat inputs from the Playground.", + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Chat Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "files": { + "_input_type": "FileInput", + "advanced": true, + "api_editable": false, + "display_name": "Files", + "dynamic": false, + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "file_path": "", + "info": "Files to be sent with the message.", + "list": true, + "list_add_label": "Add More", + "name": "files", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "temp_file": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "file", + "value": "" + }, + "input_value": { + "_input_type": "MultilineInput", + "advanced": false, + "ai_enabled": false, + "api_editable": false, + "copy_field": false, + "display_name": "Input Text", + "dynamic": false, + "info": "Message to be passed as input.", + "input_types": [], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "multiline": true, + "name": "input_value", + "override_skip": false, + "password": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "User" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "User" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatInput" + }, + "id": "ChatInput-entry", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 0, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "id": "CustomComponent-envelope", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.", + "display_name": "Envelope", + "documentation": "", + "edited": false, + "field_order": [ + "raw" + ], + "frozen": false, + "legacy": false, + "metadata": { + "code_hash": "02e69613e210", + "dependencies": { + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ], + "total_dependencies": 1 + }, + "module": "custom_components.envelope" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Payload", + "group_outputs": false, + "method": "get_payload", + "name": "payload", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "Run ID", + "group_outputs": false, + "method": "get_run_id", + "name": "run_id", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "Callback URL", + "group_outputs": false, + "method": "get_callback_url", + "name": "callback_url", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "Continuation", + "group_outputs": false, + "method": "get_continuation", + "name": "continuation", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "Callback Token", + "group_outputs": false, + "method": "get_callback_token", + "name": "callback_token", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n" + }, + "raw": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Raw Input", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "raw", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "CustomComponent" + }, + "id": "CustomComponent-envelope", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 400, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "id": "CustomComponent-rule", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.", + "display_name": "Rule Check", + "documentation": "", + "edited": false, + "field_order": [ + "payload" + ], + "frozen": false, + "legacy": false, + "metadata": { + "code_hash": "0b9f8a009fcf", + "dependencies": { + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ], + "total_dependencies": 1 + }, + "module": "custom_components.rule_check" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Verdict", + "group_outputs": false, + "method": "check", + "name": "verdict", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass RuleCheckComponent(Component):\n display_name = \"Rule Check\"\n description = \"Bewertet Betrag gegen Schwelle aus JSON-Eingabe.\"\n\n inputs = [\n MessageTextInput(name=\"payload\", display_name=\"Payload\"),\n ]\n outputs = [\n Output(display_name=\"Verdict\", name=\"verdict\", method=\"check\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def check(self) -> Message:\n try:\n data = json.loads(self._text(self.payload) or \"{}\")\n except Exception:\n data = {}\n if not isinstance(data, dict):\n data = {}\n # Die API ruft Flows immer mit Huelle auf\n # {\"payload\": {...}, \"run_id\": ..., ...}; rohe Payloads gehen auch.\n if isinstance(data.get(\"payload\"), dict):\n data = data[\"payload\"]\n try:\n betrag = float(data.get(\"betrag\", 0) or 0)\n except (TypeError, ValueError):\n betrag = 0.0\n try:\n schwelle = float(data.get(\"schwelle\", 200000) or 200000)\n except (TypeError, ValueError):\n schwelle = 200000.0\n return Message(text=\"PRUEFUNG\" if betrag > schwelle else \"OK\")\n" + }, + "payload": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Payload", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "payload", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "CustomComponent" + }, + "id": "CustomComponent-rule", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 800, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ConditionalRouter-router", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Routes an input message to a corresponding output based on text comparison.", + "display_name": "If-Else", + "documentation": "https://docs.langflow.org/if-else", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "split", + "legacy": false, + "metadata": {}, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "True", + "group_outputs": true, + "method": "true_response", + "name": "true_result", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "False", + "group_outputs": true, + "method": "false_response", + "name": "false_result", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "case_sensitive": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Case Sensitive", + "dynamic": false, + "info": "If true, the comparison will be case sensitive.", + "list": false, + "list_add_label": "Add More", + "name": "case_sensitive", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "import re\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n documentation: str = \"https://docs.langflow.org/if-else\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n \"\"\"Handles cycle iteration counting and branch exclusion.\n\n Uses two complementary mechanisms:\n 1. stop() - ACTIVE/INACTIVE state for cycle management (gets reset each iteration)\n 2. exclude_branch_conditionally() - Persistent exclusion for conditional routing\n\n When max_iterations is reached, breaks the cycle by allowing the default_route to execute.\n \"\"\"\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n\n # Check if max iterations reached and we're trying to stop the default route\n if current_iteration >= self.max_iterations and route_to_stop == self.default_route:\n # Clear ALL conditional exclusions to allow default route to execute\n if self._id in self.graph.conditional_exclusion_sources:\n previous_exclusions = self.graph.conditional_exclusion_sources[self._id]\n self.graph.conditionally_excluded_vertices -= previous_exclusions\n del self.graph.conditional_exclusion_sources[self._id]\n\n # Switch which route to stop - stop the NON-default route to break the cycle\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n\n # Call stop to break the cycle\n self.stop(route_to_stop)\n # Don't apply conditional exclusion when breaking cycle\n return\n\n # Normal case: Use BOTH mechanisms\n # 1. stop() for cycle management (marks INACTIVE, updates run manager, gets reset)\n self.stop(route_to_stop)\n\n # 2. Conditional exclusion for persistent routing (doesn't get reset except by this router)\n self.graph.exclude_branch_conditionally(self._id, output_name=route_to_stop)\n\n def _resolve_case_message(self, case_message: Message | str | None) -> Message:\n \"\"\"Return the override case message, falling back to the input text when it is blank.\n\n ``Case True`` and ``Case False`` are optional overrides. When left blank the\n ``MessageInput`` resolves to an empty ``Message``, so the original ``Text Input``\n is routed through instead of an empty message.\n\n A ``Message`` is only treated as blank when it carries no payload at all. An\n override with empty text but meaningful content (files or content blocks) is\n preserved as-is so that payload is not dropped.\n\n Blankness is judged by payload only (``text``, ``files``, ``content_blocks``).\n A ``Message`` carrying just metadata (``sender``, ``properties``, ``error`` ...)\n and no payload is intentionally treated as blank: every empty ``Message`` also\n defaults ``timestamp`` / ``category`` / ``properties``, so keying on metadata\n would defeat the blank-field fallback. With no payload there is nothing to\n route, so the ``Text Input`` is used.\n\n ``case_message`` is typed defensively as ``Message | str | None``. ``MessageInput``\n wraps user input into a ``Message`` on the validated graph path, but a raw ``str``\n can still arrive via direct/programmatic assignment (``.set(...)`` stores the raw\n value), so the ``str`` branch is retained rather than dropping such an override.\n \"\"\"\n if isinstance(case_message, Message):\n if case_message.text or case_message.files or case_message.content_blocks:\n return case_message\n return Message(text=self.input_text)\n if isinstance(case_message, str) and case_message:\n return Message(text=case_message)\n return Message(text=self.input_text)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n # Check if we should force output due to max_iterations on default route\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n force_output = current_iteration >= self.max_iterations and self.default_route == \"true_result\"\n\n if result or force_output:\n true_message = self._resolve_case_message(self.true_case_message)\n self.status = true_message\n if not force_output: # Only stop the other branch if not forcing due to max iterations\n self.iterate_and_stop_once(\"false_result\")\n return true_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(text=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n if not result:\n false_message = self._resolve_case_message(self.false_case_message)\n self.status = false_message\n self.iterate_and_stop_once(\"true_result\")\n return false_message\n\n self.iterate_and_stop_once(\"false_result\")\n return Message(text=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n" + }, + "default_route": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Default Route", + "dynamic": false, + "external_options": {}, + "info": "The default route to take when max iterations are reached.", + "name": "default_route", + "options": [ + "true_result", + "false_result" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "false_result" + }, + "false_case_message": { + "_input_type": "MessageInput", + "advanced": true, + "api_editable": false, + "display_name": "Case False", + "dynamic": false, + "info": "The message to pass if the condition is False.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "false_case_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "Standardfall: automatisch freigegeben" + }, + "input_text": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Text Input", + "dynamic": false, + "info": "The primary text input for the operation.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "input_text", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "match_text": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Match Text", + "dynamic": false, + "info": "The text input to compare against.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "match_text", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "PRUEFUNG" + }, + "max_iterations": { + "_input_type": "IntInput", + "advanced": true, + "api_editable": false, + "display_name": "Max Iterations", + "dynamic": false, + "info": "The maximum number of iterations for the conditional router.", + "list": false, + "list_add_label": "Add More", + "name": "max_iterations", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "int", + "value": 10 + }, + "operator": { + "_input_type": "DropdownInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Operator", + "dynamic": false, + "external_options": {}, + "info": "The operator to apply for comparing the texts.", + "name": "operator", + "options": [ + "equals", + "not equals", + "contains", + "starts with", + "ends with", + "regex", + "less than", + "less than or equal", + "greater than", + "greater than or equal" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "equals" + }, + "true_case_message": { + "_input_type": "MessageInput", + "advanced": true, + "api_editable": false, + "display_name": "Case True", + "dynamic": false, + "info": "The message to pass if the condition is True.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "true_case_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "Sonderfall: menschliche Pruefung erforderlich" + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ConditionalRouter" + }, + "id": "ConditionalRouter-router", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 1200, + "y": 300 + }, + "type": "genericNode" + }, + { + "data": { + "id": "CustomComponent-callback", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Meldet einen Sonderfall per HTTP an unsere API und terminiert.", + "display_name": "Approval Callback", + "documentation": "", + "edited": false, + "field_order": [ + "trigger", + "run_id", + "callback_url", + "callback_token", + "continuation_flow_id", + "facts" + ], + "frozen": false, + "legacy": false, + "metadata": { + "code_hash": "28b2f5894566", + "dependencies": { + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + }, + { + "name": "httpx", + "version": "0.28.1" + } + ], + "total_dependencies": 2 + }, + "module": "custom_components.approval_callback" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Result", + "group_outputs": false, + "method": "notify", + "name": "result", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "callback_token": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Callback Token", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "callback_token", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "callback_url": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Callback URL", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "callback_url", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n" + }, + "continuation_flow_id": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Continuation Flow ID", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "continuation_flow_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "facts": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Facts", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "facts", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "run_id": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Run ID", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "run_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "trigger": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Trigger", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "trigger", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "CustomComponent" + }, + "id": "CustomComponent-callback", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 1600, + "y": 160 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ChatOutput-pending", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Display a chat message in the Playground.", + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Output Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "clean_data": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Basic Clean Data", + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "list": false, + "list_add_label": "Add More", + "name": "clean_data", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "data_template": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Data Template", + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "data_template", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "{text}" + }, + "input_value": { + "_input_type": "HandleInput", + "advanced": false, + "api_editable": false, + "display_name": "Inputs", + "dynamic": false, + "info": "Message to be passed as output.", + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "list": false, + "list_add_label": "Add More", + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Machine" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "AI" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatOutput" + }, + "id": "ChatOutput-pending", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 2000, + "y": 160 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ChatOutput-ok", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Display a chat message in the Playground.", + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Output Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "clean_data": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Basic Clean Data", + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "list": false, + "list_add_label": "Add More", + "name": "clean_data", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "data_template": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Data Template", + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "data_template", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "{text}" + }, + "input_value": { + "_input_type": "HandleInput", + "advanced": false, + "api_editable": false, + "display_name": "Inputs", + "dynamic": false, + "info": "Message to be passed as output.", + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "list": false, + "list_add_label": "Add More", + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Machine" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "AI" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatOutput" + }, + "id": "ChatOutput-ok", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 2000, + "y": 440 + }, + "type": "genericNode" + } + ], "viewport": { "x": 0, "y": 0, @@ -2002,12 +2002,8 @@ }, "description": "Sonderfall meldet sich per HTTP an unsere API und terminiert (Spike).", "endpoint_name": null, - "icon": null, - "icon_bg_color": null, "id": "7a0a0ba8-cac3-58c4-ac65-dd6f973ab546", "is_component": false, - "locked": false, - "mcp_enabled": false, "name": "approval_handoff", "tags": [], "webhook": false diff --git a/backend/flows/intake_form.json b/backend/flows/intake_form.json index efae03f..3c64649 100644 --- a/backend/flows/intake_form.json +++ b/backend/flows/intake_form.json @@ -1,914 +1,5 @@ { "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, @@ -971,6 +62,915 @@ "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" } ], + "nodes": [ + { + "data": { + "id": "ChatInput-entry", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Get chat inputs from the Playground.", + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Chat Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "files": { + "_input_type": "FileInput", + "advanced": true, + "api_editable": false, + "display_name": "Files", + "dynamic": false, + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "file_path": "", + "info": "Files to be sent with the message.", + "list": true, + "list_add_label": "Add More", + "name": "files", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "temp_file": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "file", + "value": "" + }, + "input_value": { + "_input_type": "MultilineInput", + "advanced": false, + "ai_enabled": false, + "api_editable": false, + "copy_field": false, + "display_name": "Input Text", + "dynamic": false, + "info": "Message to be passed as input.", + "input_types": [], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "multiline": true, + "name": "input_value", + "override_skip": false, + "password": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "User" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "User" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatInput" + }, + "id": "ChatInput-entry", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "id": "CustomInputForm-intake", + "node": { + "base_classes": [], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Collects structured form values into a single JSON message.", + "display_name": "CustomInput Form", + "documentation": "", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "ClipboardList", + "legacy": false, + "metadata": {}, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Form Data", + "group_outputs": false, + "method": "collect", + "name": "form_data", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "age": { + "_input_type": "IntInput", + "advanced": false, + "api_editable": false, + "display_name": "Age", + "dynamic": false, + "info": "Your age in years", + "list": false, + "list_add_label": "Add More", + "name": "age", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "int", + "value": 18 + }, + "amount": { + "_input_type": "FloatInput", + "advanced": false, + "api_editable": false, + "display_name": "Amount", + "dynamic": false, + "info": "Amount in euros", + "list": false, + "list_add_label": "Add More", + "name": "amount", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "float", + "value": 0.0 + }, + "attachment": { + "_input_type": "FileInput", + "advanced": false, + "api_editable": false, + "display_name": "Attachment", + "dynamic": false, + "fileTypes": [ + "pdf", + "png", + "jpg" + ], + "file_path": "", + "info": "String reference only for now (no upload yet)", + "list": false, + "list_add_label": "Add More", + "name": "attachment", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "temp_file": false, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "file", + "value": "" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.message import Message\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except Exception:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\n" + }, + "department": { + "_input_type": "DropdownInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Department", + "dynamic": false, + "external_options": {}, + "info": "Which team is this for", + "name": "department", + "options": [ + "Music", + "Youth", + "Seniors", + "Admin" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Music" + }, + "form_values": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Form values (JSON envelope)", + "dynamic": false, + "info": "Submitted values as JSON; merged over the defaults", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "form_values", + "override_skip": false, + "placeholder": "", + "required": false, + "show": false, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "full_name": { + "_input_type": "StrInput", + "advanced": false, + "api_editable": false, + "display_name": "Full name", + "dynamic": false, + "info": "Your first and last name", + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "full_name", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": " " + }, + "newsletter": { + "_input_type": "BoolInput", + "advanced": false, + "api_editable": false, + "display_name": "Newsletter", + "dynamic": false, + "info": "Send me the parish newsletter", + "list": false, + "list_add_label": "Add More", + "name": "newsletter", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": false + }, + "notes": { + "_input_type": "MultilineInput", + "advanced": false, + "ai_enabled": false, + "api_editable": false, + "copy_field": false, + "display_name": "Notes", + "dynamic": false, + "info": "Anything else we should know", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "multiline": true, + "name": "notes", + "override_skip": false, + "password": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "password": { + "_input_type": "SecretStrInput", + "advanced": false, + "api_editable": false, + "display_name": "Password", + "dynamic": false, + "info": "Optional access phrase", + "input_types": [], + "load_from_db": true, + "name": "password", + "override_skip": false, + "password": true, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "satisfaction": { + "_input_type": "SliderInput", + "advanced": false, + "api_editable": false, + "display_name": "Satisfaction", + "dynamic": false, + "info": "How satisfied are you (1-5)", + "max_label": "", + "max_label_icon": "", + "min_label": "", + "min_label_icon": "", + "name": "satisfaction", + "override_skip": false, + "placeholder": "", + "range_spec": { + "max": 5.0, + "min": 1.0, + "step": 1.0, + "step_type": "float" + }, + "required": false, + "show": true, + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "title_case": false, + "tool_mode": false, + "track_in_telemetry": false, + "type": "slider", + "value": 3 + }, + "topics": { + "_input_type": "MultiselectInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "display_name": "Topics", + "dynamic": false, + "info": "What are you interested in", + "list": true, + "list_add_label": "Add More", + "name": "topics", + "options": [ + "Worship", + "Outreach", + "Courses" + ], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": [] + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "CustomInputForm" + }, + "id": "CustomInputForm-intake", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ChatOutput-collected", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Display a chat message in the Playground.", + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Output Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "clean_data": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Basic Clean Data", + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "list": false, + "list_add_label": "Add More", + "name": "clean_data", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "data_template": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Data Template", + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "data_template", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "{text}" + }, + "input_value": { + "_input_type": "HandleInput", + "advanced": false, + "api_editable": false, + "display_name": "Inputs", + "dynamic": false, + "info": "Message to be passed as output.", + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "list": false, + "list_add_label": "Add More", + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Machine" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "AI" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatOutput" + }, + "id": "ChatOutput-collected", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + } + ], "viewport": { "x": 0, "y": 0, @@ -979,12 +979,8 @@ }, "description": "Structured intake via CustomInputForm (parsed into a web form).", "endpoint_name": null, - "icon": null, - "icon_bg_color": null, "id": "ec795b9d-f9d2-5730-b016-16e5f9b3bd3f", "is_component": false, - "locked": false, - "mcp_enabled": false, "name": "intake_form", "tags": [], "webhook": false diff --git a/backend/flows/simple_rule.json b/backend/flows/simple_rule.json index aba77ad..4d96d4f 100644 --- a/backend/flows/simple_rule.json +++ b/backend/flows/simple_rule.json @@ -1,1256 +1,5 @@ { "data": { - "nodes": [ - { - "data": { - "node": { - "template": { - "_type": "Component", - "files": { - "tool_mode": false, - "trace_as_metadata": true, - "file_path": "", - "fileTypes": [ - "csv", - "json", - "pdf", - "txt", - "md", - "mdx", - "yaml", - "yml", - "xml", - "html", - "htm", - "docx", - "py", - "sh", - "sql", - "js", - "ts", - "tsx", - "jpg", - "jpeg", - "png", - "bmp", - "image" - ], - "temp_file": true, - "list": true, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "files", - "value": "", - "display_name": "Files", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Files to be sent with the message.", - "title_case": false, - "track_in_telemetry": false, - "type": "file", - "_input_type": "FileInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "context_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "context_id", - "value": "", - "display_name": "Context ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "input_value": { - "tool_mode": false, - "trace_as_input": true, - "multiline": true, - "ai_enabled": false, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "input_value", - "value": "", - "display_name": "Input Text", - "advanced": false, - "api_editable": false, - "input_types": [], - "dynamic": false, - "info": "Message to be passed as input.", - "title_case": false, - "track_in_telemetry": false, - "copy_field": false, - "password": false, - "type": "str", - "_input_type": "MultilineInput" - }, - "sender": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender", - "value": "User", - "display_name": "Sender Type", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Type of sender.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "sender_name": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender_name", - "value": "User", - "display_name": "Sender Name", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Name of the sender.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "session_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "session_id", - "value": "", - "display_name": "Session ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "should_store_message": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "should_store_message", - "value": true, - "display_name": "Store Messages", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Store the message in the history.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - } - }, - "description": "Get chat inputs from the Playground.", - "icon": "MessagesSquare", - "base_classes": [ - "Message" - ], - "display_name": "Chat Input", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "minimized": true, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "message", - "display_name": "Chat Message", - "method": "message_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ChatInput", - "id": "ChatInput-entry", - "showNode": true - }, - "id": "ChatInput-entry", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 0, - "y": 200 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass RuleCheckComponent(Component):\n display_name = \"Rule Check\"\n description = \"Bewertet Betrag gegen Schwelle aus JSON-Eingabe.\"\n\n inputs = [\n MessageTextInput(name=\"payload\", display_name=\"Payload\"),\n ]\n outputs = [\n Output(display_name=\"Verdict\", name=\"verdict\", method=\"check\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def check(self) -> Message:\n try:\n data = json.loads(self._text(self.payload) or \"{}\")\n except Exception:\n data = {}\n if not isinstance(data, dict):\n data = {}\n # Die API ruft Flows immer mit Huelle auf\n # {\"payload\": {...}, \"run_id\": ..., ...}; rohe Payloads gehen auch.\n if isinstance(data.get(\"payload\"), dict):\n data = data[\"payload\"]\n try:\n betrag = float(data.get(\"betrag\", 0) or 0)\n except (TypeError, ValueError):\n betrag = 0.0\n try:\n schwelle = float(data.get(\"schwelle\", 200000) or 200000)\n except (TypeError, ValueError):\n schwelle = 200000.0\n return Message(text=\"PRUEFUNG\" if betrag > schwelle else \"OK\")\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "payload": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "payload", - "value": "", - "display_name": "Payload", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - } - }, - "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.", - "base_classes": [ - "Message" - ], - "display_name": "Rule Check", - "documentation": "", - "minimized": false, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "verdict", - "display_name": "Verdict", - "method": "check", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [ - "payload" - ], - "beta": false, - "legacy": false, - "edited": false, - "metadata": { - "module": "custom_components.rule_check", - "code_hash": "0b9f8a009fcf", - "dependencies": { - "total_dependencies": 1, - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - } - ] - } - }, - "tool_mode": false - }, - "type": "CustomComponent", - "id": "CustomComponent-rule", - "showNode": true - }, - "id": "CustomComponent-rule", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 400, - "y": 200 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "case_sensitive": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "case_sensitive", - "value": true, - "display_name": "Case Sensitive", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "If true, the comparison will be case sensitive.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "import re\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n documentation: str = \"https://docs.langflow.org/if-else\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n \"\"\"Handles cycle iteration counting and branch exclusion.\n\n Uses two complementary mechanisms:\n 1. stop() - ACTIVE/INACTIVE state for cycle management (gets reset each iteration)\n 2. exclude_branch_conditionally() - Persistent exclusion for conditional routing\n\n When max_iterations is reached, breaks the cycle by allowing the default_route to execute.\n \"\"\"\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n\n # Check if max iterations reached and we're trying to stop the default route\n if current_iteration >= self.max_iterations and route_to_stop == self.default_route:\n # Clear ALL conditional exclusions to allow default route to execute\n if self._id in self.graph.conditional_exclusion_sources:\n previous_exclusions = self.graph.conditional_exclusion_sources[self._id]\n self.graph.conditionally_excluded_vertices -= previous_exclusions\n del self.graph.conditional_exclusion_sources[self._id]\n\n # Switch which route to stop - stop the NON-default route to break the cycle\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n\n # Call stop to break the cycle\n self.stop(route_to_stop)\n # Don't apply conditional exclusion when breaking cycle\n return\n\n # Normal case: Use BOTH mechanisms\n # 1. stop() for cycle management (marks INACTIVE, updates run manager, gets reset)\n self.stop(route_to_stop)\n\n # 2. Conditional exclusion for persistent routing (doesn't get reset except by this router)\n self.graph.exclude_branch_conditionally(self._id, output_name=route_to_stop)\n\n def _resolve_case_message(self, case_message: Message | str | None) -> Message:\n \"\"\"Return the override case message, falling back to the input text when it is blank.\n\n ``Case True`` and ``Case False`` are optional overrides. When left blank the\n ``MessageInput`` resolves to an empty ``Message``, so the original ``Text Input``\n is routed through instead of an empty message.\n\n A ``Message`` is only treated as blank when it carries no payload at all. An\n override with empty text but meaningful content (files or content blocks) is\n preserved as-is so that payload is not dropped.\n\n Blankness is judged by payload only (``text``, ``files``, ``content_blocks``).\n A ``Message`` carrying just metadata (``sender``, ``properties``, ``error`` ...)\n and no payload is intentionally treated as blank: every empty ``Message`` also\n defaults ``timestamp`` / ``category`` / ``properties``, so keying on metadata\n would defeat the blank-field fallback. With no payload there is nothing to\n route, so the ``Text Input`` is used.\n\n ``case_message`` is typed defensively as ``Message | str | None``. ``MessageInput``\n wraps user input into a ``Message`` on the validated graph path, but a raw ``str``\n can still arrive via direct/programmatic assignment (``.set(...)`` stores the raw\n value), so the ``str`` branch is retained rather than dropping such an override.\n \"\"\"\n if isinstance(case_message, Message):\n if case_message.text or case_message.files or case_message.content_blocks:\n return case_message\n return Message(text=self.input_text)\n if isinstance(case_message, str) and case_message:\n return Message(text=case_message)\n return Message(text=self.input_text)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n # Check if we should force output due to max_iterations on default route\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n force_output = current_iteration >= self.max_iterations and self.default_route == \"true_result\"\n\n if result or force_output:\n true_message = self._resolve_case_message(self.true_case_message)\n self.status = true_message\n if not force_output: # Only stop the other branch if not forcing due to max iterations\n self.iterate_and_stop_once(\"false_result\")\n return true_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(text=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n if not result:\n false_message = self._resolve_case_message(self.false_case_message)\n self.status = false_message\n self.iterate_and_stop_once(\"true_result\")\n return false_message\n\n self.iterate_and_stop_once(\"false_result\")\n return Message(text=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "default_route": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "true_result", - "false_result" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "default_route", - "value": "false_result", - "display_name": "Default Route", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "The default route to take when max iterations are reached.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "false_case_message": { - "trace_as_input": true, - "tool_mode": false, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "false_case_message", - "value": "Standardfall: automatisch freigegeben", - "display_name": "Case False", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The message to pass if the condition is False.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageInput" - }, - "input_text": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "input_text", - "value": "", - "display_name": "Text Input", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The primary text input for the operation.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "match_text": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "match_text", - "value": "PRUEFUNG", - "display_name": "Match Text", - "advanced": false, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The text input to compare against.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "max_iterations": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "max_iterations", - "value": 10, - "display_name": "Max Iterations", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "The maximum number of iterations for the conditional router.", - "title_case": false, - "track_in_telemetry": true, - "type": "int", - "_input_type": "IntInput" - }, - "operator": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "equals", - "not equals", - "contains", - "starts with", - "ends with", - "regex", - "less than", - "less than or equal", - "greater than", - "greater than or equal" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "operator", - "value": "equals", - "display_name": "Operator", - "advanced": false, - "api_editable": false, - "dynamic": false, - "info": "The operator to apply for comparing the texts.", - "real_time_refresh": true, - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "true_case_message": { - "trace_as_input": true, - "tool_mode": false, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "true_case_message", - "value": "Sonderfall: menschliche Pruefung erforderlich", - "display_name": "Case True", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The message to pass if the condition is True.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageInput" - } - }, - "description": "Routes an input message to a corresponding output based on text comparison.", - "icon": "split", - "base_classes": [ - "Message" - ], - "display_name": "If-Else", - "documentation": "https://docs.langflow.org/if-else", - "minimized": false, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "true_result", - "display_name": "True", - "method": "true_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": true, - "tool_mode": true - }, - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "false_result", - "display_name": "False", - "method": "false_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": true, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ConditionalRouter", - "id": "ConditionalRouter-router", - "showNode": true - }, - "id": "ConditionalRouter-router", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 800, - "y": 200 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "input_value": { - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "input_value", - "value": "", - "display_name": "Inputs", - "advanced": false, - "api_editable": false, - "input_types": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "dynamic": false, - "info": "Message to be passed as output.", - "title_case": false, - "track_in_telemetry": false, - "type": "other", - "_input_type": "HandleInput" - }, - "clean_data": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "clean_data", - "value": true, - "display_name": "Basic Clean Data", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Whether to clean data before converting to string.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "context_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "context_id", - "value": "", - "display_name": "Context ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "data_template": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "data_template", - "value": "{text}", - "display_name": "Data Template", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "sender": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender", - "value": "Machine", - "display_name": "Sender Type", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Type of sender.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "sender_name": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender_name", - "value": "AI", - "display_name": "Sender Name", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Name of the sender.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "session_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "session_id", - "value": "", - "display_name": "Session ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "should_store_message": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "should_store_message", - "value": true, - "display_name": "Store Messages", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Store the message in the history.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - } - }, - "description": "Display a chat message in the Playground.", - "icon": "MessagesSquare", - "base_classes": [ - "Message" - ], - "display_name": "Chat Output", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "minimized": true, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "message", - "display_name": "Output Message", - "method": "message_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ChatOutput", - "id": "ChatOutput-ok", - "showNode": true - }, - "id": "ChatOutput-ok", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 1200, - "y": 60 - }, - "type": "genericNode" - }, - { - "data": { - "node": { - "template": { - "_type": "Component", - "input_value": { - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": true, - "placeholder": "", - "show": true, - "name": "input_value", - "value": "", - "display_name": "Inputs", - "advanced": false, - "api_editable": false, - "input_types": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "dynamic": false, - "info": "Message to be passed as output.", - "title_case": false, - "track_in_telemetry": false, - "type": "other", - "_input_type": "HandleInput" - }, - "clean_data": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "clean_data", - "value": true, - "display_name": "Basic Clean Data", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Whether to clean data before converting to string.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - }, - "code": { - "type": "code", - "required": true, - "placeholder": "", - "list": false, - "show": true, - "multiline": true, - "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", - "fileTypes": [], - "file_path": "", - "password": false, - "name": "code", - "advanced": true, - "api_editable": false, - "dynamic": true, - "info": "", - "load_from_db": false, - "title_case": false - }, - "context_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "context_id", - "value": "", - "display_name": "Context ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "data_template": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "data_template", - "value": "{text}", - "display_name": "Data Template", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "sender": { - "tool_mode": false, - "trace_as_metadata": true, - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "combobox": false, - "dialog_inputs": {}, - "toggle": false, - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender", - "value": "Machine", - "display_name": "Sender Type", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Type of sender.", - "title_case": false, - "track_in_telemetry": true, - "external_options": {}, - "type": "str", - "_input_type": "DropdownInput" - }, - "sender_name": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "sender_name", - "value": "AI", - "display_name": "Sender Name", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "Name of the sender.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "session_id": { - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "load_from_db": false, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "session_id", - "value": "", - "display_name": "Session ID", - "advanced": true, - "api_editable": false, - "input_types": [ - "Message" - ], - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "_input_type": "MessageTextInput" - }, - "should_store_message": { - "tool_mode": false, - "trace_as_metadata": true, - "list": false, - "list_add_label": "Add More", - "override_skip": false, - "required": false, - "placeholder": "", - "show": true, - "name": "should_store_message", - "value": true, - "display_name": "Store Messages", - "advanced": true, - "api_editable": false, - "dynamic": false, - "info": "Store the message in the history.", - "title_case": false, - "track_in_telemetry": true, - "type": "bool", - "_input_type": "BoolInput" - } - }, - "description": "Display a chat message in the Playground.", - "icon": "MessagesSquare", - "base_classes": [ - "Message" - ], - "display_name": "Chat Output", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "minimized": true, - "custom_fields": {}, - "output_types": [], - "pinned": false, - "conditional_paths": [], - "frozen": false, - "outputs": [ - { - "types": [ - "Message" - ], - "selected": "Message", - "name": "message", - "display_name": "Output Message", - "method": "message_response", - "value": "__UNDEFINED__", - "cache": true, - "allows_loop": false, - "group_outputs": false, - "tool_mode": true - } - ], - "field_order": [], - "beta": false, - "legacy": false, - "edited": false, - "metadata": {}, - "tool_mode": false - }, - "type": "ChatOutput", - "id": "ChatOutput-sonder", - "showNode": true - }, - "id": "ChatOutput-sonder", - "measured": { - "width": 320, - "height": 200 - }, - "position": { - "x": 1200, - "y": 340 - }, - "type": "genericNode" - } - ], "edges": [ { "animated": false, @@ -1373,6 +122,1257 @@ "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-sonderœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" } ], + "nodes": [ + { + "data": { + "id": "ChatInput-entry", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Get chat inputs from the Playground.", + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Chat Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "files": { + "_input_type": "FileInput", + "advanced": true, + "api_editable": false, + "display_name": "Files", + "dynamic": false, + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "file_path": "", + "info": "Files to be sent with the message.", + "list": true, + "list_add_label": "Add More", + "name": "files", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "temp_file": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "file", + "value": "" + }, + "input_value": { + "_input_type": "MultilineInput", + "advanced": false, + "ai_enabled": false, + "api_editable": false, + "copy_field": false, + "display_name": "Input Text", + "dynamic": false, + "info": "Message to be passed as input.", + "input_types": [], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "multiline": true, + "name": "input_value", + "override_skip": false, + "password": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "User" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "User" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatInput" + }, + "id": "ChatInput-entry", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "id": "CustomComponent-rule", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Bewertet Betrag gegen Schwelle aus JSON-Eingabe.", + "display_name": "Rule Check", + "documentation": "", + "edited": false, + "field_order": [ + "payload" + ], + "frozen": false, + "legacy": false, + "metadata": { + "code_hash": "0b9f8a009fcf", + "dependencies": { + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ], + "total_dependencies": 1 + }, + "module": "custom_components.rule_check" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Verdict", + "group_outputs": false, + "method": "check", + "name": "verdict", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass RuleCheckComponent(Component):\n display_name = \"Rule Check\"\n description = \"Bewertet Betrag gegen Schwelle aus JSON-Eingabe.\"\n\n inputs = [\n MessageTextInput(name=\"payload\", display_name=\"Payload\"),\n ]\n outputs = [\n Output(display_name=\"Verdict\", name=\"verdict\", method=\"check\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def check(self) -> Message:\n try:\n data = json.loads(self._text(self.payload) or \"{}\")\n except Exception:\n data = {}\n if not isinstance(data, dict):\n data = {}\n # Die API ruft Flows immer mit Huelle auf\n # {\"payload\": {...}, \"run_id\": ..., ...}; rohe Payloads gehen auch.\n if isinstance(data.get(\"payload\"), dict):\n data = data[\"payload\"]\n try:\n betrag = float(data.get(\"betrag\", 0) or 0)\n except (TypeError, ValueError):\n betrag = 0.0\n try:\n schwelle = float(data.get(\"schwelle\", 200000) or 200000)\n except (TypeError, ValueError):\n schwelle = 200000.0\n return Message(text=\"PRUEFUNG\" if betrag > schwelle else \"OK\")\n" + }, + "payload": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Payload", + "dynamic": false, + "info": "", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "payload", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "CustomComponent" + }, + "id": "CustomComponent-rule", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ConditionalRouter-router", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Routes an input message to a corresponding output based on text comparison.", + "display_name": "If-Else", + "documentation": "https://docs.langflow.org/if-else", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "split", + "legacy": false, + "metadata": {}, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "True", + "group_outputs": true, + "method": "true_response", + "name": "true_result", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + }, + { + "allows_loop": false, + "cache": true, + "display_name": "False", + "group_outputs": true, + "method": "false_response", + "name": "false_result", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "case_sensitive": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Case Sensitive", + "dynamic": false, + "info": "If true, the comparison will be case sensitive.", + "list": false, + "list_add_label": "Add More", + "name": "case_sensitive", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "import re\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MessageInput, MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ConditionalRouterComponent(Component):\n display_name = \"If-Else\"\n description = \"Routes an input message to a corresponding output based on text comparison.\"\n documentation: str = \"https://docs.langflow.org/if-else\"\n icon = \"split\"\n name = \"ConditionalRouter\"\n\n def __init__(self, *args, **kwargs):\n super().__init__(*args, **kwargs)\n self.__iteration_updated = False\n\n inputs = [\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Text Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n DropdownInput(\n name=\"operator\",\n display_name=\"Operator\",\n options=[\n \"equals\",\n \"not equals\",\n \"contains\",\n \"starts with\",\n \"ends with\",\n \"regex\",\n \"less than\",\n \"less than or equal\",\n \"greater than\",\n \"greater than or equal\",\n ],\n info=\"The operator to apply for comparing the texts.\",\n value=\"equals\",\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"match_text\",\n display_name=\"Match Text\",\n info=\"The text input to compare against.\",\n required=True,\n ),\n BoolInput(\n name=\"case_sensitive\",\n display_name=\"Case Sensitive\",\n info=\"If true, the comparison will be case sensitive.\",\n value=True,\n advanced=True,\n ),\n MessageInput(\n name=\"true_case_message\",\n display_name=\"Case True\",\n info=\"The message to pass if the condition is True.\",\n advanced=True,\n ),\n MessageInput(\n name=\"false_case_message\",\n display_name=\"Case False\",\n info=\"The message to pass if the condition is False.\",\n advanced=True,\n ),\n IntInput(\n name=\"max_iterations\",\n display_name=\"Max Iterations\",\n info=\"The maximum number of iterations for the conditional router.\",\n value=10,\n advanced=True,\n ),\n DropdownInput(\n name=\"default_route\",\n display_name=\"Default Route\",\n options=[\"true_result\", \"false_result\"],\n info=\"The default route to take when max iterations are reached.\",\n value=\"false_result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"True\", name=\"true_result\", method=\"true_response\", group_outputs=True),\n Output(display_name=\"False\", name=\"false_result\", method=\"false_response\", group_outputs=True),\n ]\n\n def _pre_run_setup(self):\n self.__iteration_updated = False\n\n def evaluate_condition(self, input_text: str, match_text: str, operator: str, *, case_sensitive: bool) -> bool:\n if not case_sensitive and operator != \"regex\":\n input_text = input_text.lower()\n match_text = match_text.lower()\n\n if operator == \"equals\":\n return input_text == match_text\n if operator == \"not equals\":\n return input_text != match_text\n if operator == \"contains\":\n return match_text in input_text\n if operator == \"starts with\":\n return input_text.startswith(match_text)\n if operator == \"ends with\":\n return input_text.endswith(match_text)\n if operator == \"regex\":\n try:\n return bool(re.match(match_text, input_text))\n except re.error:\n return False # Return False if the regex is invalid\n if operator in [\"less than\", \"less than or equal\", \"greater than\", \"greater than or equal\"]:\n try:\n input_num = float(input_text)\n match_num = float(match_text)\n if operator == \"less than\":\n return input_num < match_num\n if operator == \"less than or equal\":\n return input_num <= match_num\n if operator == \"greater than\":\n return input_num > match_num\n if operator == \"greater than or equal\":\n return input_num >= match_num\n except ValueError:\n return False # Invalid number format for comparison\n return False\n\n def iterate_and_stop_once(self, route_to_stop: str):\n \"\"\"Handles cycle iteration counting and branch exclusion.\n\n Uses two complementary mechanisms:\n 1. stop() - ACTIVE/INACTIVE state for cycle management (gets reset each iteration)\n 2. exclude_branch_conditionally() - Persistent exclusion for conditional routing\n\n When max_iterations is reached, breaks the cycle by allowing the default_route to execute.\n \"\"\"\n if not self.__iteration_updated:\n self.update_ctx({f\"{self._id}_iteration\": self.ctx.get(f\"{self._id}_iteration\", 0) + 1})\n self.__iteration_updated = True\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n\n # Check if max iterations reached and we're trying to stop the default route\n if current_iteration >= self.max_iterations and route_to_stop == self.default_route:\n # Clear ALL conditional exclusions to allow default route to execute\n if self._id in self.graph.conditional_exclusion_sources:\n previous_exclusions = self.graph.conditional_exclusion_sources[self._id]\n self.graph.conditionally_excluded_vertices -= previous_exclusions\n del self.graph.conditional_exclusion_sources[self._id]\n\n # Switch which route to stop - stop the NON-default route to break the cycle\n route_to_stop = \"true_result\" if route_to_stop == \"false_result\" else \"false_result\"\n\n # Call stop to break the cycle\n self.stop(route_to_stop)\n # Don't apply conditional exclusion when breaking cycle\n return\n\n # Normal case: Use BOTH mechanisms\n # 1. stop() for cycle management (marks INACTIVE, updates run manager, gets reset)\n self.stop(route_to_stop)\n\n # 2. Conditional exclusion for persistent routing (doesn't get reset except by this router)\n self.graph.exclude_branch_conditionally(self._id, output_name=route_to_stop)\n\n def _resolve_case_message(self, case_message: Message | str | None) -> Message:\n \"\"\"Return the override case message, falling back to the input text when it is blank.\n\n ``Case True`` and ``Case False`` are optional overrides. When left blank the\n ``MessageInput`` resolves to an empty ``Message``, so the original ``Text Input``\n is routed through instead of an empty message.\n\n A ``Message`` is only treated as blank when it carries no payload at all. An\n override with empty text but meaningful content (files or content blocks) is\n preserved as-is so that payload is not dropped.\n\n Blankness is judged by payload only (``text``, ``files``, ``content_blocks``).\n A ``Message`` carrying just metadata (``sender``, ``properties``, ``error`` ...)\n and no payload is intentionally treated as blank: every empty ``Message`` also\n defaults ``timestamp`` / ``category`` / ``properties``, so keying on metadata\n would defeat the blank-field fallback. With no payload there is nothing to\n route, so the ``Text Input`` is used.\n\n ``case_message`` is typed defensively as ``Message | str | None``. ``MessageInput``\n wraps user input into a ``Message`` on the validated graph path, but a raw ``str``\n can still arrive via direct/programmatic assignment (``.set(...)`` stores the raw\n value), so the ``str`` branch is retained rather than dropping such an override.\n \"\"\"\n if isinstance(case_message, Message):\n if case_message.text or case_message.files or case_message.content_blocks:\n return case_message\n return Message(text=self.input_text)\n if isinstance(case_message, str) and case_message:\n return Message(text=case_message)\n return Message(text=self.input_text)\n\n def true_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n # Check if we should force output due to max_iterations on default route\n current_iteration = self.ctx.get(f\"{self._id}_iteration\", 0)\n force_output = current_iteration >= self.max_iterations and self.default_route == \"true_result\"\n\n if result or force_output:\n true_message = self._resolve_case_message(self.true_case_message)\n self.status = true_message\n if not force_output: # Only stop the other branch if not forcing due to max iterations\n self.iterate_and_stop_once(\"false_result\")\n return true_message\n self.iterate_and_stop_once(\"true_result\")\n return Message(text=\"\")\n\n def false_response(self) -> Message:\n result = self.evaluate_condition(\n self.input_text, self.match_text, self.operator, case_sensitive=self.case_sensitive\n )\n\n if not result:\n false_message = self._resolve_case_message(self.false_case_message)\n self.status = false_message\n self.iterate_and_stop_once(\"true_result\")\n return false_message\n\n self.iterate_and_stop_once(\"false_result\")\n return Message(text=\"\")\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n if field_name == \"operator\":\n if field_value == \"regex\":\n build_config.pop(\"case_sensitive\", None)\n elif \"case_sensitive\" not in build_config:\n case_sensitive_input = next(\n (input_field for input_field in self.inputs if input_field.name == \"case_sensitive\"), None\n )\n if case_sensitive_input:\n build_config[\"case_sensitive\"] = case_sensitive_input.to_dict()\n return build_config\n" + }, + "default_route": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Default Route", + "dynamic": false, + "external_options": {}, + "info": "The default route to take when max iterations are reached.", + "name": "default_route", + "options": [ + "true_result", + "false_result" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "false_result" + }, + "false_case_message": { + "_input_type": "MessageInput", + "advanced": true, + "api_editable": false, + "display_name": "Case False", + "dynamic": false, + "info": "The message to pass if the condition is False.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "false_case_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "Standardfall: automatisch freigegeben" + }, + "input_text": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Text Input", + "dynamic": false, + "info": "The primary text input for the operation.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "input_text", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "match_text": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Match Text", + "dynamic": false, + "info": "The text input to compare against.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "match_text", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "PRUEFUNG" + }, + "max_iterations": { + "_input_type": "IntInput", + "advanced": true, + "api_editable": false, + "display_name": "Max Iterations", + "dynamic": false, + "info": "The maximum number of iterations for the conditional router.", + "list": false, + "list_add_label": "Add More", + "name": "max_iterations", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "int", + "value": 10 + }, + "operator": { + "_input_type": "DropdownInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Operator", + "dynamic": false, + "external_options": {}, + "info": "The operator to apply for comparing the texts.", + "name": "operator", + "options": [ + "equals", + "not equals", + "contains", + "starts with", + "ends with", + "regex", + "less than", + "less than or equal", + "greater than", + "greater than or equal" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "equals" + }, + "true_case_message": { + "_input_type": "MessageInput", + "advanced": true, + "api_editable": false, + "display_name": "Case True", + "dynamic": false, + "info": "The message to pass if the condition is True.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "true_case_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "Sonderfall: menschliche Pruefung erforderlich" + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ConditionalRouter" + }, + "id": "ConditionalRouter-router", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ChatOutput-ok", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Display a chat message in the Playground.", + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Output Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "clean_data": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Basic Clean Data", + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "list": false, + "list_add_label": "Add More", + "name": "clean_data", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "data_template": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Data Template", + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "data_template", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "{text}" + }, + "input_value": { + "_input_type": "HandleInput", + "advanced": false, + "api_editable": false, + "display_name": "Inputs", + "dynamic": false, + "info": "Message to be passed as output.", + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "list": false, + "list_add_label": "Add More", + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Machine" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "AI" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatOutput" + }, + "id": "ChatOutput-ok", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 1200, + "y": 60 + }, + "type": "genericNode" + }, + { + "data": { + "id": "ChatOutput-sonder", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Display a chat message in the Playground.", + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "metadata": {}, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Output Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "clean_data": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Basic Clean Data", + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "list": false, + "list_add_label": "Add More", + "name": "clean_data", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "data_template": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Data Template", + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "data_template", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "{text}" + }, + "input_value": { + "_input_type": "HandleInput", + "advanced": false, + "api_editable": false, + "display_name": "Inputs", + "dynamic": false, + "info": "Message to be passed as output.", + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "list": false, + "list_add_label": "Add More", + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Machine" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "AI" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatOutput" + }, + "id": "ChatOutput-sonder", + "measured": { + "height": 200, + "width": 320 + }, + "position": { + "x": 1200, + "y": 340 + }, + "type": "genericNode" + } + ], "viewport": { "x": 0, "y": 0, @@ -1381,12 +1381,8 @@ }, "description": "Regelpruefung ohne Seiteneffekt (Spike-Fixture, kein LLM).", "endpoint_name": null, - "icon": null, - "icon_bg_color": null, "id": "5adedb2b-d50b-5c3f-9951-2e76b8b94869", "is_component": false, - "locked": false, - "mcp_enabled": false, "name": "simple_rule", "tags": [], "webhook": false diff --git a/backend/flows/test_flow.json b/backend/flows/test_flow.json new file mode 100644 index 0000000..d11759b --- /dev/null +++ b/backend/flows/test_flow.json @@ -0,0 +1,747 @@ +{ + "data": { + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "ext:kirche:CustomInputForm@extra-BhL6Q", + "name": "form_data", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-HkK3i", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__ext:kirche:CustomInputForm@extra-BhL6Q{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-HkK3i{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}", + "selected": false, + "source": "ext:kirche:CustomInputForm@extra-BhL6Q", + "sourceHandle": "{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}", + "target": "ChatOutput-HkK3i", + "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}" + } + ], + "nodes": [ + { + "data": { + "id": "ext:kirche:CustomInputForm@extra-BhL6Q", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Collects structured form values into a single JSON message.", + "display_name": "CustomInput Form", + "documentation": "", + "edited": false, + "field_order": [ + "form_values", + "full_name", + "notes", + "password", + "age", + "amount", + "newsletter", + "department", + "topics", + "satisfaction", + "attachment" + ], + "frozen": false, + "icon": "ClipboardList", + "legacy": false, + "lf_version": "1.12.0", + "metadata": { + "code_hash": "6d186fa88d37", + "dependencies": { + "dependencies": [ + { + "name": "lfx", + "version": "1.12.0" + } + ], + "total_dependencies": 1 + }, + "module": "custom_components.custominput_form" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Form Data", + "group_outputs": false, + "loop_types": null, + "method": "collect", + "name": "form_data", + "options": null, + "required_inputs": null, + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "age": { + "_input_type": "IntInput", + "advanced": false, + "api_editable": false, + "display_name": "Age", + "dynamic": false, + "info": "Your age in years", + "list": false, + "list_add_label": "Add More", + "name": "age", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "int", + "value": 18 + }, + "amount": { + "_input_type": "FloatInput", + "advanced": false, + "api_editable": false, + "display_name": "Amount", + "dynamic": false, + "info": "Amount in euros", + "list": false, + "list_add_label": "Add More", + "name": "amount", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "float", + "value": 0 + }, + "attachment": { + "_input_type": "FileInput", + "advanced": false, + "api_editable": false, + "display_name": "Attachment", + "dynamic": false, + "fileTypes": [ + "pdf", + "png", + "jpg" + ], + "file_path": "", + "info": "String reference only for now (no upload yet)", + "list": false, + "list_add_label": "Add More", + "name": "attachment", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "temp_file": false, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "file", + "value": "" + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.message import Message\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except json.JSONDecodeError:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\n" + }, + "department": { + "_input_type": "DropdownInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Department", + "dynamic": false, + "external_options": {}, + "info": "Which team is this for", + "name": "department", + "options": [ + "Music", + "Youth", + "Seniors", + "Admin" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Youth" + }, + "form_values": { + "_input_type": "MessageTextInput", + "advanced": false, + "api_editable": false, + "display_name": "Form values (JSON envelope)", + "dynamic": false, + "info": "Submitted values as JSON; merged over the defaults", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "form_values", + "override_skip": false, + "placeholder": "", + "required": false, + "show": false, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "full_name": { + "_input_type": "StrInput", + "advanced": false, + "api_editable": false, + "display_name": "Full name", + "dynamic": false, + "info": "Your first and last name", + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "full_name", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "Hi there" + }, + "newsletter": { + "_input_type": "BoolInput", + "advanced": false, + "api_editable": false, + "display_name": "Newsletter", + "dynamic": false, + "info": "Send me the parish newsletter", + "list": false, + "list_add_label": "Add More", + "name": "newsletter", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": false + }, + "notes": { + "_input_type": "MultilineInput", + "advanced": false, + "ai_enabled": false, + "api_editable": false, + "copy_field": false, + "display_name": "Notes", + "dynamic": false, + "info": "Anything else we should know", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "multiline": true, + "name": "notes", + "override_skip": false, + "password": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "Blabla" + }, + "password": { + "_input_type": "SecretStrInput", + "advanced": false, + "api_editable": false, + "display_name": "Password", + "dynamic": false, + "info": "Optional access phrase", + "input_types": [], + "load_from_db": false, + "name": "password", + "override_skip": false, + "password": true, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "value": "hidden" + }, + "satisfaction": { + "_input_type": "SliderInput", + "advanced": false, + "api_editable": false, + "display_name": "Satisfaction", + "dynamic": false, + "info": "How satisfied are you (1-5)", + "max_label": "", + "max_label_icon": "", + "min_label": "", + "min_label_icon": "", + "name": "satisfaction", + "override_skip": false, + "placeholder": "", + "range_spec": { + "max": 5, + "min": 1, + "step": 1, + "step_type": "float" + }, + "required": false, + "show": true, + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "title_case": false, + "tool_mode": false, + "track_in_telemetry": false, + "type": "slider", + "value": 3 + }, + "topics": { + "_input_type": "MultiselectInput", + "advanced": false, + "api_editable": false, + "combobox": false, + "display_name": "Topics", + "dynamic": false, + "info": "What are you interested in", + "list": true, + "list_add_label": "Add More", + "name": "topics", + "options": [ + "Worship", + "Outreach", + "Courses" + ], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": [ + "Outreach", + "Courses" + ] + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "CustomInputForm" + }, + "dragging": false, + "id": "ext:kirche:CustomInputForm@extra-BhL6Q", + "measured": { + "height": 933, + "width": 320 + }, + "position": { + "x": -2869.215421770588, + "y": -259.29504008742293 + }, + "selected": true, + "type": "genericNode" + }, + { + "data": { + "id": "ChatOutput-HkK3i", + "node": { + "base_classes": [ + "Message" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Display a chat message in the Playground.", + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "edited": false, + "field_order": [ + "input_value", + "should_store_message", + "sender", + "sender_name", + "session_id", + "context_id", + "data_template", + "clean_data" + ], + "frozen": false, + "icon": "MessagesSquare", + "legacy": false, + "lf_version": "1.12.0", + "metadata": { + "code_hash": "84009527d08c", + "dependencies": { + "dependencies": [ + { + "name": "orjson", + "version": "3.11.9" + }, + { + "name": "fastapi", + "version": "0.139.2" + }, + { + "name": "lfx", + "version": null + } + ], + "total_dependencies": 3 + }, + "module": "lfx.components.input_output.chat_output.ChatOutput" + }, + "minimized": true, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Output Message", + "group_outputs": false, + "method": "message_response", + "name": "message", + "selected": "Message", + "tool_mode": true, + "types": [ + "Message" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "clean_data": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Basic Clean Data", + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "list": false, + "list_add_label": "Add More", + "name": "clean_data", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "code": { + "advanced": true, + "api_editable": false, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" + }, + "context_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Context ID", + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "context_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "data_template": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Data Template", + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "data_template", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "{text}" + }, + "input_value": { + "_input_type": "HandleInput", + "advanced": false, + "api_editable": false, + "display_name": "Inputs", + "dynamic": false, + "info": "Message to be passed as output.", + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "list": false, + "list_add_label": "Add More", + "name": "input_value", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "sender": { + "_input_type": "DropdownInput", + "advanced": true, + "api_editable": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Sender Type", + "dynamic": false, + "external_options": {}, + "info": "Type of sender.", + "name": "sender", + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "str", + "value": "Machine" + }, + "sender_name": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Sender Name", + "dynamic": false, + "info": "Name of the sender.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "sender_name", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "AI" + }, + "session_id": { + "_input_type": "MessageTextInput", + "advanced": true, + "api_editable": false, + "display_name": "Session ID", + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "session_id", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "should_store_message": { + "_input_type": "BoolInput", + "advanced": true, + "api_editable": false, + "display_name": "Store Messages", + "dynamic": false, + "info": "Store the message in the history.", + "list": false, + "list_add_label": "Add More", + "name": "should_store_message", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "ChatOutput" + }, + "id": "ChatOutput-HkK3i", + "measured": { + "height": 168, + "width": 320 + }, + "position": { + "x": -2181.727435009515, + "y": 144.80068578973714 + }, + "selected": false, + "type": "genericNode" + } + ], + "viewport": { + "x": 1755.3131372405762, + "y": 167.2405536194492, + "zoom": 0.5755627009646302 + } + }, + "description": "The Pinnacle of Prompt Generation.", + "endpoint_name": null, + "id": "fd440bee-f669-44a2-aa12-6af3bc299731", + "is_component": false, + "name": "test_flow", + "tags": [], + "webhook": false +} diff --git a/backend/scripts/sync_flows.py b/backend/scripts/sync_flows.py index 5db9670..8eaeff1 100644 --- a/backend/scripts/sync_flows.py +++ b/backend/scripts/sync_flows.py @@ -18,8 +18,9 @@ from pathlib import Path from sqlmodel import Session from app.db import get_engine, reset_engine -from app.flows import export, sync -from app.flows.langflow_client import LangflowClient, LangflowError +from app.flows import sync +from app.flows.deploy import pull_all_from_langflow +from app.flows.langflow_client import LangflowClient from app.flows.lfx_client import LfxClient from app.flows.schemas import SyncReport from app.settings import settings @@ -28,19 +29,7 @@ from app.settings import settings async def pull_from_langflow(flows_dir: Path) -> dict: """Export every Langflow flow to its own canonical file. Returns a report.""" client = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key) - headers = await client.list_flows() - pulled: list[str] = [] - errors: dict[str, str] = {} - for header in headers: - flow_id = str(header.get("id", "")) - try: - doc = await client.export_flow(flow_id) - name = doc.get("name") or flow_id - path = export.write_flow_file(flows_dir, doc, name=name) - pulled.append(path.stem) - except (LangflowError, OSError, ValueError) as exc: - errors[flow_id] = str(exc) - return {"pulled": sorted(pulled), "errors": errors} + return await pull_all_from_langflow(client, flows_dir) async def run_sync(flows_dir: Path) -> SyncReport: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6af8fd5..4f8c4e7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -218,22 +218,36 @@ def override_langflow(): Usage: override_langflow(export_doc) -- the fake export_flow() returns the doc, or raises LangflowNotFound/Unavailable for status=404/0. + Pass flows=[...] + docs={id: doc} for multi-flow pull tests, or + list_status=0/500 to fail the listing. Default list is empty so + file-only sync tests stay hermetic. """ from app.flows.api import get_langflow - from app.flows.langflow_client import LangflowNotFound, LangflowUnavailable + from app.flows.langflow_client import LangflowError, LangflowNotFound, LangflowUnavailable from app.main import app - def _override(export_doc=None, status=200): + def _override(export_doc=None, status=200, flows=None, docs=None, list_status=200): + if flows is None: + flows = [{"id": "fake-1", "name": "fake"}] if export_doc is not None else [] + class FakeLangflow: async def export_flow(self, flow_id): if status == 404: raise LangflowNotFound(f"flow {flow_id} not found") if status == 0: raise LangflowUnavailable("Langflow unreachable") + if docs is not None: + if flow_id in docs: + return docs[flow_id] + raise LangflowNotFound(f"flow {flow_id} not found") return export_doc async def list_flows(self): - return [{"id": "fake-1", "name": "fake"}] + if list_status == 0: + raise LangflowUnavailable("Langflow unreachable") + if list_status == 500: + raise LangflowError("Langflow error 500") + return flows app.dependency_overrides[get_langflow] = lambda: FakeLangflow() diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index c05d4db..f500575 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -5,11 +5,15 @@ import json from app.flows import export -def test_slugify(): - assert export.slugify("Dachsanierung 200k") == "dachsanierung-200k" - assert export.slugify(" A/B\\C ") == "a-b-c" - assert export.slugify("simple_rule") == "simple-rule" - assert export.slugify("") == "flow" +def test_filename_stem_keeps_original_names(): + assert export.filename_stem("simple_rule") == "simple_rule" + assert export.filename_stem("test_flow") == "test_flow" + assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung" + assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k" + assert export.filename_stem(" A/B\\C ") == "A-B-C" + assert export.filename_stem('a:b*c?d"eg|h') == "a-b-c-d-e-f-g-h" + assert export.filename_stem("trailing...") == "trailing" + assert export.filename_stem("") == "flow" def test_canonical_bytes_stable_under_key_reorder(): @@ -22,7 +26,7 @@ def test_canonical_bytes_stable_under_key_reorder(): def test_write_and_read_roundtrip_is_byte_stable(tmp_path): doc = {"name": "My Flow", "data": {"nodes": [], "edges": []}} path = export.write_flow_file(tmp_path, doc) - assert path == tmp_path / "my-flow.json" + assert path == tmp_path / "My Flow.json" assert json.loads(path.read_text()) == doc # Rewriting identical content is byte-identical (no phantom git diffs). before = path.read_bytes() @@ -37,3 +41,42 @@ def test_iter_flow_files_only_top_level_json(tmp_path): sub.mkdir() (sub / "c.json").write_text("{}") assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"] + + +def test_find_reuse_path_matches_identical_content_first(tmp_path): + doc = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}} + other = tmp_path / "other-name.json" + other.write_text(export.canonical_bytes(doc).decode()) + path, identical = export.find_reuse_path(tmp_path, dict(doc)) + assert (path, identical) == (other, True) + + +def test_find_reuse_path_matches_same_name(tmp_path): + old = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}, "extra": 1} + (tmp_path / "simple_rule.json").write_text(json.dumps(old)) + new = {"name": "Simple Rule", "data": {"nodes": [], "edges": []}} + path, identical = export.find_reuse_path(tmp_path, new) + assert (path, identical) == (tmp_path / "simple_rule.json", False) + + +def test_find_reuse_path_no_match_and_missing_dir(tmp_path): + (tmp_path / "a.json").write_text(json.dumps({"name": "Other"})) + assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False) + assert export.find_reuse_path(tmp_path / "gibts-nicht", {"name": "Neu"}) == (None, False) + + +def test_find_reuse_path_skips_unreadable_files(tmp_path): + (tmp_path / "kaputt.json").write_text("{kein json") + assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False) + + +def test_write_flow_file_is_world_readable(tmp_path): + import os + import stat + + doc = {"name": "Lesbar", "data": {"nodes": [], "edges": []}} + path = export.write_flow_file(tmp_path, doc) + mode = stat.S_IMODE(os.stat(path).st_mode) + # Read bits for user/group/other (mkstemp defaults to 0o600; on Windows + # chmod maps to 0o666, so only assert the read bits, not the exact mode). + assert mode & 0o444 == 0o444, f"expected world-readable, got {oct(mode)}" diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index bbff7ca..6c582b4 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -118,20 +118,127 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path assert definition.name == "umbenannt" -async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login): +async def test_sync_endpoint_returns_report( + client, lfx_server, tmp_path, monkeypatch, login, override_langflow +): await login() + override_langflow() # empty Langflow: file-only pass monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) _seed_dir(tmp_path) response = await client.post("/api/flows/sync") assert response.status_code == 200, response.text body = response.json() assert body["added"] == ["simple_rule"] + assert body["pulled"] == [] assert body["errors"] == {} response = await client.post("/api/flows/sync") assert response.json()["skipped"] == ["simple_rule"] +def _langflow_doc(name: str, langflow_id: str) -> dict: + doc = load_fixture("simple_rule.json") + return { + "id": langflow_id, + "name": name, + "description": "aus Langflow", + "endpoint_name": None, + "data": doc["data"], + "tags": [], + "webhook": False, + "is_component": False, + } + + +async def test_sync_endpoint_pulls_from_langflow_then_syncs( + client, lfx_server, tmp_path, monkeypatch, login, override_langflow +): + await login() + monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) + langflow_id = "22222222-3333-4444-8555-666666666666" + override_langflow( + flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}], + docs={langflow_id: _langflow_doc("Gehaltserhöhung", langflow_id)}, + ) + response = await client.post("/api/flows/sync") + assert response.status_code == 200, response.text + body = response.json() + assert body["pulled"] == ["Gehaltserhöhung"] + assert body["added"] == ["Gehaltserhöhung"] + assert body["errors"] == {} + assert (tmp_path / "Gehaltserhöhung.json").is_file() + + # Second pass: same content pulls again but syncs nothing new. + response = await client.post("/api/flows/sync") + assert response.status_code == 200, response.text + body = response.json() + assert body["pulled"] == ["Gehaltserhöhung"] + assert body["skipped"] == ["Gehaltserhöhung"] + + +async def test_sync_endpoint_pull_reuses_same_named_file( + client, lfx_server, tmp_path, monkeypatch, login, override_langflow +): + """A pulled flow seeded from simple_rule.json updates it in place + instead of duplicating it as simple-rule.json (slug drift).""" + await login() + monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) + _seed_dir(tmp_path) # fixture shape, name "simple_rule" + langflow_id = "44444444-5555-4666-8777-888888888888" + override_langflow( + flows=[{"id": langflow_id, "name": "simple_rule"}], + docs={langflow_id: _langflow_doc("simple_rule", langflow_id)}, + ) + response = await client.post("/api/flows/sync") + assert response.status_code == 200, response.text + body = response.json() + assert body["pulled"] == ["simple_rule"] + assert body["added"] == ["simple_rule"] + assert body["errors"] == {} + assert not (tmp_path / "simple-rule.json").exists() + + response = await client.post("/api/flows/sync") + assert response.json()["skipped"] == ["simple_rule"] + + +async def test_sync_endpoint_pull_error_continues( + client, lfx_server, tmp_path, monkeypatch, login, override_langflow +): + await login() + monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) + good_id = "33333333-4444-4555-8666-777777777777" + override_langflow( + flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}], + docs={good_id: _langflow_doc("Gut", good_id)}, + ) + response = await client.post("/api/flows/sync") + assert response.status_code == 200, response.text + body = response.json() + assert body["pulled"] == ["Gut"] + assert body["added"] == ["Gut"] + assert "kaputt-id" in body["errors"] + + +async def test_sync_endpoint_langflow_unreachable( + client, lfx_server, tmp_path, monkeypatch, login, override_langflow +): + await login() + monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) + override_langflow(flows=[], list_status=0) + response = await client.post("/api/flows/sync") + assert response.status_code == 503 + + +async def test_sync_endpoint_langflow_error( + client, lfx_server, tmp_path, monkeypatch, login, override_langflow +): + await login() + monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) + override_langflow(flows=[], list_status=500) + response = await client.post("/api/flows/sync") + assert response.status_code == 502 + + async def test_watch_forever_triggers_on_file_change(tmp_path): import contextlib diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f0af0f7..e693c20 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -66,6 +66,7 @@ export interface SyncReport { updated: string[]; deleted: string[]; skipped: string[]; + pulled: string[]; errors: Record; } diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx index 9c068de..c695386 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -24,6 +24,7 @@ import { type FlowDefinition, type FlowVersion, } from "../api"; +import FlowHelp from "./FlowHelp"; import StatusBadge from "./StatusBadge"; export default function AdminTab() { @@ -89,6 +90,7 @@ export default function AdminTab() { try { const r = await api.syncFlows(); const parts = [ + `Langflow: ${r.pulled.length}`, `+${r.added.length}`, `~${r.updated.length}`, `-${r.deleted.length}`, @@ -147,6 +149,7 @@ export default function AdminTab() { + + + Holt alle Flows aus Langflow nach backend/flows und deployed sie + danach automatisch. + {flows.length === 0 ? ( Keine Flows vorhanden. diff --git a/frontend/src/components/FlowHelp.tsx b/frontend/src/components/FlowHelp.tsx new file mode 100644 index 0000000..1b82010 --- /dev/null +++ b/frontend/src/components/FlowHelp.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { + Anchor, + Button, + Card, + List, + Text, + Title, +} from "@mantine/core"; +import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react"; +import { LANGFLOW_URL } from "../api"; + +export default function FlowHelp() { + const [open, setOpen] = useState(false); + + return ( + + + {open && ( +
+ + So kommt ein neuer Flow in die Webapp + + + + + 1. In Langflow entwerfen und speichern:{" "} + + + Langflow öffnen + + . Die bestehenden Flows sind dort bereits geladen. Name vergeben, speichern. + + + + 2. Sync-Button unten klicken:{" "} + + holt alle Flows aus Langflow nach{" "} + + backend/flows/*.json + {" "} + und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt, + sonst übersprungen). + + + + 3. Prüfen:{" "} + + Tabelle unten zeigt Version, Autor und Status. Danach erscheint der Flow + automatisch auf der Seite Workflows mit seinem Formular. Flows ohne + Formular-Knoten zeigen ein Freitext-Feld. + + + + Regeln + + + + Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json). + Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt + bestehen und muss bei Bedarf gelöscht werden. + + + Eine gelöschte Datei löscht den Flow inklusive seiner Läufe + (Datei bleibt in Git erhalten). + + + Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON). + Der automatische Beobachter im Hintergrund syncet nur Dateien, + Langflow holt nur dieser Button. + + + + Formular-Felder + + + Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: jeder + Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename = Label). + Die Verbindung ChatInput nach form_values bitte behalten — darüber kommen + die ausgefüllten Werte an. Unbekannte Feldtypen fallen automatisch auf ein + Textfeld zurück. + +
+ )} +
+ ); +} diff --git a/lfx-entrypoint.sh b/lfx-entrypoint.sh new file mode 100644 index 0000000..a506a36 --- /dev/null +++ b/lfx-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# LFX entrypoint (runs as root, drops privileges for the server). +# The named volume arrives root-owned (Docker creates it as root and the +# image USER cannot chown it) — without this every flow upload fails with +# "Permission denied" and synced flows stay version-less (invisible in UI). +set -e + +chown -R lfx:lfx /flows +exec gosu lfx "$@"