diff --git a/.devcontainer/Dockerfile.lfx b/.devcontainer/Dockerfile.lfx index 00514b0..42c9480 100644 --- a/.devcontainer/Dockerfile.lfx +++ b/.devcontainer/Dockerfile.lfx @@ -8,7 +8,7 @@ ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 # lfx = flow runtime; requests = convenience for custom-component code. -RUN pip install lfx requests +RUN pip install "lfx==1.12.0" requests RUN mkdir -p /flows VOLUME /flows diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 9acbe67..7c0b6a9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,8 +19,26 @@ services: - "8000:8000" # FastAPI - "3000:3000" # Vite + db: + image: postgres:16-trixie + env_file: + - ../.env + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 10 + langflow: - image: langflowai/langflow:latest + image: langflowai/langflow:1.12.0 + depends_on: + db: + condition: service_healthy env_file: - ../.env environment: @@ -31,9 +49,6 @@ services: PORT: "7860" LANGFLOW_AUTO_LOGIN: "false" LANGFLOW_API_KEY_SOURCE: env - # SQLite file inside the langflow-data volume (absolute path required - # by Langflow); overrides any postgres URL in ../.env. - LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db volumes: - langflow-data:/app/langflow ports: @@ -70,23 +85,9 @@ services: retries: 12 start_period: 30s - # Optional nightly auto-updates of :latest images. Not part of the default - # dev startup (no container updates needed while iterating). - watchtower: - image: nickfedor/watchtower:latest - profiles: ["updates"] - volumes: - - /var/run/docker.sock:/var/run/docker.sock - environment: - WATCHTOWER_LABEL_ENABLE: "true" - WATCHTOWER_SCHEDULE: "0 0 4 * * *" - WATCHTOWER_CLEANUP: "true" - WATCHTOWER_ROLLING_RESTART: "true" - labels: - com.centurylinklabs.watchtower.enable: "true" - volumes: kirche-einfach-venv: kirche-einfach-node-modules: + postgres-data: langflow-data: - lfx-data: \ No newline at end of file + lfx-data: diff --git a/.devcontainer/init-db.sql b/.devcontainer/init-db.sql new file mode 100644 index 0000000..795450a --- /dev/null +++ b/.devcontainer/init-db.sql @@ -0,0 +1,7 @@ +-- Runs once on first init of the postgres-data volume. +-- The main app database ($POSTGRES_DB) is created by the image itself. +-- Langflow (authoring UI) gets its own database on the same instance. +CREATE DATABASE langflow; + +-- Isolated database for the pytest suite (migrated via Alembic in conftest). +CREATE DATABASE kirche_einfach_test; diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 1ed3096..5b9be3b 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -13,7 +13,7 @@ echo "--- Installing Node dependencies ---" cd /workspaces/kirche-einfach/frontend npm install -# Compose services (langflow, lfx) must be up; fail loudly otherwise. +# Compose services (db, langflow, lfx) must be up; fail loudly otherwise. echo "--- Waiting for compose services ---" if [ ! -f /workspaces/kirche-einfach/.env ]; then echo "ERROR: /workspaces/kirche-einfach/.env is missing. Copy .env.example to .env and fill in secrets." >&2 @@ -24,7 +24,7 @@ import socket import sys import time -targets = [("langflow", 7860), ("lfx", 6004)] +targets = [("db", 5432), ("langflow", 7860), ("lfx", 6004)] deadline = time.time() + 180 pending = dict(targets) while pending and time.time() < deadline: @@ -45,14 +45,6 @@ if pending: sys.exit(f"ERROR: compose services not ready: {sorted(pending)}") EOF -# Migrate the SQLite schema and reconcile backend/flows into the DB + LFX. -echo "--- Migrating database ---" -cd /workspaces/kirche-einfach/backend -uv run alembic upgrade head - -echo "--- Syncing canonical flows ---" -uv run python scripts/sync_flows.py --sync || echo "WARN: flow sync failed (check backend/flows and LFX)" - echo "" echo "=== Setup complete ===" echo "Forgejo username: $FORGEJO_USERNAME" diff --git a/.env.example b/.env.example index cb90e32..05f1325 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ # Copy to `.env` and fill in values. `.env` is gitignored. FORGEJO_TOKEN= -# --- app database (SQLite, default if unset) --- -# Override only if you want a different engine/URL. -# DATABASE_URL=sqlite:///backend/data/kirche.db - -# --- canonical flow definitions --- -# FLOWS_DIR=backend/flows -# Auto-reconcile backend/flows into DB + LFX while the API runs. -# FLOWS_WATCH_ENABLED=true +# --- databases (compose service `db`) --- +POSTGRES_USER=kirche +POSTGRES_PASSWORD= +POSTGRES_DB=kirche_einfach +DATABASE_URL=postgresql+psycopg://kirche:@db:5432/kirche_einfach +TEST_DATABASE_URL=postgresql+psycopg://kirche:@db:5432/kirche_einfach_test # --- langflow (authoring, dev only) --- LANGFLOW_URL=http://langflow:7860 @@ -17,7 +15,7 @@ LANGFLOW_SUPERUSER_PASSWORD= LANGFLOW_SECRET_KEY= # Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. LANGFLOW_API_KEY=sk- -# Langflow stores flows in its own SQLite file when unset. +LANGFLOW_DATABASE_URL=postgresql://kirche:@db:5432/langflow # --- lfx (flow runtime) --- LFX_URL=http://lfx:6004 @@ -27,4 +25,4 @@ LFX_API_KEY=lfx- CALLBACK_BASE_URL=http://app:8000 # --- LLM (optional; tests mock the provider without it) --- -# LLM_API_KEY= \ No newline at end of file +# LLM_API_KEY= diff --git a/.gitignore b/.gitignore index ba92757..ecebd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,12 +14,6 @@ dist/ .env .env.local -# Runtime SQLite databases -backend/data/ -*.db -*.db-shm -*.db-wal - # IDE .vscode/ .idea/ diff --git a/AGENTS.md b/AGENTS.md index a964607..8b7efd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,44 +1,5 @@ # Agents -Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/`, Python 3.12, SQLModel, SQLite) + React 19/Vite frontend (`frontend/`). Flow definitions live as JSON files in `backend/flows/` (source of truth); the DB is derived by the sync engine. - -## Commands - -Backend (run from `backend/`): -- `uv sync` — install dependencies -- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed) -- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored) -- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX -- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent) - -Frontend (run from `frontend/`): `npm install`, `npm run dev`, `npm run build` (typecheck + build). - -## Definition of done - -- New behavior has tests; `uv run pytest` is green before reporting completion. -- `npm run build` is green when frontend code changed. -- Schema changes ship as a new Alembic revision — never edit an applied migration. - -## Architecture rules - -- `backend/flows/*.json` is the source of truth (one Langflow export per file). Never write flow JSON anywhere else. -- Only `backend/app/flows/sync.py` reconciles files into the DB. CLI, `POST /api/flows/sync`, and the embedded watcher must call it — never duplicate its logic. -- All DB access goes through `backend/app/flows/store.py`. Keep API handlers thin. -- Talk to LFX only via `LfxClient`, to Langflow only via `LangflowClient`. -- Flow JSON must stay a valid Langflow export (`store` requires `data.nodes`/`data.edges`). - -```python -from app.flows.sync import sync_from_directory -report = await sync_from_directory(session, lfx, settings.flows_dir) -``` - -## Boundaries - -- Never commit secrets. `.env` is gitignored; only `.env.example` is tracked. -- Never edit `backend/data/*` (runtime SQLite files) or `__pycache__`. -- Never add a second file-watcher or a second sync path. -- Deleting `backend/flows/.json` deletes the flow including its runs from the DB (by design). - ## Git -When committing: `git add -A && git commit -m ""` (conventional commits: `feat:`, `fix:`, `chore:`, `docs:`). When pushing: `git push` (branch is tracking `origin/main`). +When committing: `git add -A && git commit -m ""`. When pushing: `git push` (branch is tracking `origin/main`). diff --git a/README.md b/README.md index 46b87c4..f375cdc 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,17 @@ # Kirche Einfach -AI-assisted administration platform for church organizations. +KI-gestützte Verwaltungsplattform für Kirche. -Staff describe a request in plain words. The platform guides them through the -process, checks the rules automatically, and asks a human only when something -needs judgment. Every step is documented and traceable. +## Stack -## Features - -- **Visual flow authoring** — design decision flows in Langflow, then bring - them into the platform. -- **Flows as files** — each flow lives as one JSON file under - `backend/flows/`. The directory is the source of truth: committed to git, - reviewable, diffable. -- **Automatic sync** — the API watches `backend/flows/` and reconciles every - change into the database and the runtime: new files become flows, edits - become new versions, deleted files remove the flow (including its runs). - Same on every startup; also triggerable via `POST /api/flows/sync` or - `uv run python scripts/sync_flows.py --sync`. -- **Versioned runtime** — every change creates an immutable version; versions - deploy to the lightweight LFX runtime and can run synchronously or streamed. -- **Approvals** — a flow can pause and ask a human for a decision. Approving - can automatically start a follow-up ("continuation") flow. -- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben* - (approvals), *Administration* (import, sync, deploy). - -## Infrastructure - -```mermaid -flowchart LR - subgraph Repo["Git repo — source of truth"] - FLOWS["backend/flows/*.json
one Langflow export per flow"] - end - subgraph Stack["Compose stack (dev + prod)"] - UI["frontend — React UI (3000)"] - API["app — FastAPI (8000)
+ embedded flow-watcher"] - DB[("app.db — SQLite file")] - LF["langflow — authoring UI (7860)
langflow.db — SQLite file"] - LFX["lfx — flow runtime (6004)"] - WT["watchtower — nightly image updates"] - end - UI --> API - FLOWS -->|watch + sync| API - API -->|definitions · versions · runs · approvals| DB - API -->|pull / export| LF - API -->|upload · run · unregister| LFX - LFX -->|approval callbacks| API - WT -.->|updates :latest images| LF -``` - -| Service | Purpose | URL | -| -------------- | ------------------------------------ | -------------------------- | -| React frontend | Web UI (Vite) | http://localhost:3000 | -| FastAPI backend| API + flow orchestration | http://localhost:8000 | -| API docs | Swagger UI | http://localhost:8000/api/docs | -| Langflow | Visual flow authoring | http://localhost:7860 | -| LFX | Flow runtime (internal, no direct UI)| localhost:6004 | -| SQLite files | `backend/data/kirche.db` (app), Langflow keeps its own file | — | - -Why SQLite: a flow run needs only a handful of single-row statements, and -Langflow itself runs on SQLite by default — so the whole stack works without -a database server. Postgres can return later by changing `DATABASE_URL` -(migrations stay portable). +- **Backend:** Python 3.12, FastAPI, Django, uv +- **Frontend:** React 19, TypeScript, Vite +- **Dev:** Devcontainer, opencode ## Getting Started -1. Copy `.env.example` to `.env` and fill in the secrets. -2. Open the repo in VS Code → "Reopen in Container". Setup installs - dependencies, migrates the database, and syncs `backend/flows/`. -3. Run the backend from `backend/`: `uv run uvicorn app.main:app --port 8000`. - Run the frontend from `frontend/`: `npm run dev`. +Open in VS Code → "Reopen in Container". -Production (self-hosted, internal use — no TLS/reverse proxy included): - -```sh -docker compose -f compose.prod.yml up -d --build -``` - -## Development - -Backend (from `backend/`): - -- `uv run pytest` — full test suite (throwaway SQLite file, no external DB). -- `uv run alembic upgrade head` — migrate the dev database. -- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/`. -- `uv run python scripts/sync_flows.py --pull` — export all Langflow flows - into `backend/flows/` first. -- `uv run python scripts/deploy_flow.py --langflow-id ` — import one - Langflow flow via the API. -- `uv run python scripts/make_flow_fixtures.py` — regenerate the bundled - flows (writes test fixtures and `backend/flows/`). -- `uv run python scripts/smoke_e2e.py` — manual end-to-end smoke. - -Frontend (from `frontend/`): `npm install`, `npm run dev`, `npm run build`. - -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). - -Container updates: registry images (`langflowai/langflow:latest`) -auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built -images (`app`, `lfx`, `frontend`) update with `docker compose build --pull`. +Backend: `http://localhost:8000` +Frontend: `http://localhost:3000` +API Docs: `http://localhost:8000/api/docs` diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index d89de3e..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# Production image for the FastAPI backend. -FROM python:3.12-slim - -ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -WORKDIR /srv/backend -COPY pyproject.toml alembic.ini ./ -COPY app ./app -COPY migrations ./migrations -COPY scripts ./scripts -# No lockfile is committed; dependencies resolve at build time. -RUN uv sync --no-dev - -ENV FLOWS_WATCH_ENABLED=true -EXPOSE 8000 - -# Migrate on start, then serve (watcher reconciles backend/flows automatically). -CMD ["sh", "-c", ".venv/bin/alembic upgrade head && exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/app/db.py b/backend/app/db.py index 768c2ea..668fede 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,7 +1,6 @@ """Database engine, sessions, and FastAPI dependency.""" from collections.abc import Iterator -from pathlib import Path from sqlalchemy import Engine from sqlmodel import Session, SQLModel, create_engine @@ -11,23 +10,11 @@ from .settings import settings _engine: Engine | None = None -def _engine_kwargs(url: str) -> dict: - if url.startswith("sqlite"): - # FastAPI runs sync endpoints in a threadpool; connections are shared - # across threads, so defer the same-thread check. File-based SQLite - # needs an existing parent dir (SQLite won't create it). - return {"connect_args": {"check_same_thread": False}} - return {} - - def get_engine(url: str | None = None) -> Engine: """Process-wide engine singleton (override URL in tests via settings).""" global _engine if _engine is None: - url = url or settings.database_url - if url.startswith("sqlite:///") and not url.startswith("sqlite:///:memory:"): - Path(url.removeprefix("sqlite:///")).parent.mkdir(parents=True, exist_ok=True) - _engine = create_engine(url, **_engine_kwargs(url)) + _engine = create_engine(url or settings.database_url) return _engine diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index 6d4aaa9..aa155b9 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -4,7 +4,6 @@ from __future__ import annotations import json from collections.abc import AsyncIterator, Iterator -from pathlib import Path from uuid import UUID import httpx @@ -17,7 +16,6 @@ from app.settings import settings from . import runner, store from .deploy import import_from_langflow -from .sync import lfx_id_for, sync_from_directory from .langflow_client import ( LangflowClient, LangflowError, @@ -46,7 +44,6 @@ from .schemas import ( FlowVersionRead, RunRead, RunRequest, - SyncReport, ) flows_router = APIRouter(prefix="/api/flows", tags=["flows"]) @@ -73,7 +70,6 @@ def _read_definition(d) -> FlowDefinitionRead: name=d.name, description=d.description, status=d.status, - source_path=d.source_path, continuation_flow_id=d.continuation_flow_id, created_at=d.created_at, ) @@ -138,18 +134,6 @@ def list_flows(session: Session = Depends(get_db)): return [_read_definition(d) for d in store.list_definitions(session)] -@flows_router.post("/sync", response_model=SyncReport) -async def sync_flows( - session: Session = Depends(get_db), - lfx: LfxClient = Depends(get_lfx), -): - """Reconcile backend/flows/*.json into the DB + LFX (add/update/delete).""" - try: - return await sync_from_directory(session, lfx, Path(settings.flows_dir)) - except FileNotFoundError as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - - @flows_router.get("/{flow_id}", response_model=FlowDefinitionRead) def get_flow(flow_id: UUID, session: Session = Depends(get_db)): try: @@ -194,12 +178,8 @@ async def deploy_version( except store.NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc try: - # Deterministic LFX id: same (definition, version) always maps to the - # same registration, no matter which path deployed it. uploaded = await lfx.upload_flow( - version.langflow_json, - flow_id=lfx_id_for(flow_id, version_no), - replace=True, + version.langflow_json, flow_id=str(version.id), replace=True ) except LfxInvalidFlow as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc diff --git a/backend/app/flows/deploy.py b/backend/app/flows/deploy.py index 7cf761c..2d6e961 100644 --- a/backend/app/flows/deploy.py +++ b/backend/app/flows/deploy.py @@ -1,15 +1,15 @@ -"""Authoring pipeline: Langflow export -> canonical file -> version -> LFX deploy.""" +"""Authoring pipeline: Langflow export -> versioned definition -> LFX deploy.""" from __future__ import annotations from sqlmodel import Session from app.models import FlowDefinition, FlowVersion -from app.settings import settings -from . import export, store, sync +from . import store from .langflow_client import LangflowClient from .lfx_client import LfxClient +from .schemas import FlowDefinitionCreate, FlowVersionCreate async def import_from_langflow( @@ -22,31 +22,39 @@ async def import_from_langflow( author: str = "", deploy: bool = True, ) -> tuple[FlowDefinition, FlowVersion]: - """Export a flow from Langflow, persist it to backend/flows, then sync. + """Export a flow from Langflow, store it as a new version, deploy to LFX. - The canonical file is written first so the directory stays the source of - truth; sync_document() derives the definition + version from it. + Definitions are matched by name (explicit `name` wins, else the exported + name); every import creates a new immutable version. """ - exported = await langflow.export_flow(langflow_flow_id) - flow_name = name or exported.get("name") or f"langflow-{langflow_flow_id}" + export = await langflow.export_flow(langflow_flow_id) + flow_name = name or export.get("name") or f"langflow-{langflow_flow_id}" langflow_json = { - "id": exported.get("id", langflow_flow_id), + "id": export.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), + "description": export.get("description") or "", + "endpoint_name": export.get("endpoint_name"), + "data": export.get("data", {}), + "tags": export.get("tags", []), + "webhook": export.get("webhook", False), + "is_component": export.get("is_component", False), } - path = export.write_flow_file(settings.flows_dir, langflow_json, name=flow_name) - definition, version, _action = await sync.sync_document( + try: + definition = next( + d for d in store.list_definitions(session) if d.name == flow_name + ) + except StopIteration: + definition = store.create_definition( + session, FlowDefinitionCreate(name=flow_name) + ) + version = store.create_version( session, - lfx, - langflow_json, - source_filename=path.name, - author=author, - deploy=deploy, + definition.id, + FlowVersionCreate(langflow_json=langflow_json, author=author), ) - assert version is not None # sync_document always versions on import + if deploy: + uploaded = await lfx.upload_flow( + version.langflow_json, flow_id=str(version.id), replace=True + ) + version = store.mark_deployed(session, version, uploaded.id) return definition, version diff --git a/backend/app/flows/export.py b/backend/app/flows/export.py deleted file mode 100644 index fa25f44..0000000 --- a/backend/app/flows/export.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Canonical flow files: backend/flows/.json is the source of truth. - -One Langflow export per file, committed to git. This module only reads and -writes the directory; reconciliation into the DB lives in sync.py. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import tempfile -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 canonical_bytes(doc: dict) -> bytes: - """Deterministic serialization: stable bytes for stable git diffs.""" - return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode( - "utf-8" - ) - - -def content_hash(doc: dict) -> str: - """Hash of the parsed document (formatting-independent).""" - return hashlib.sha256(canonical_bytes(doc)).hexdigest() - - -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). - - 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 = 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" - ) - try: - with os.fdopen(fd, "wb") as tmp: - tmp.write(canonical_bytes(doc)) - os.replace(tmp_name, target) - except BaseException: - try: - os.unlink(tmp_name) - except OSError: - pass - raise - return target - - -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 a3eb907..c024c34 100644 --- a/backend/app/flows/schemas.py +++ b/backend/app/flows/schemas.py @@ -17,8 +17,6 @@ class FlowDefinitionRead(BaseModel): name: str description: str status: str - # backend/flows filename this definition syncs from (None if API-created). - source_path: str | None = None continuation_flow_id: UUID | None created_at: datetime @@ -112,16 +110,3 @@ class DecisionRequest(BaseModel): class DecisionRead(BaseModel): task: ApprovalRead continuation_run: RunRead | None - - -class SyncReport(BaseModel): - """Result of reconciling backend/flows/*.json into DB + LFX. - - Lists hold filename stems; errors maps filename 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) - errors: dict[str, str] = Field(default_factory=dict) diff --git a/backend/app/flows/store.py b/backend/app/flows/store.py index e11f2c2..c5039f0 100644 --- a/backend/app/flows/store.py +++ b/backend/app/flows/store.py @@ -56,82 +56,7 @@ def list_definitions(session: Session) -> list[FlowDefinition]: return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all()) -def get_definition_by_source_path( - session: Session, source_path: str -) -> FlowDefinition | None: - """Find the definition synced from a given backend/flows filename.""" - return session.exec( - select(FlowDefinition).where(FlowDefinition.source_path == source_path) - ).first() - - -def set_definition_name( - session: Session, definition: FlowDefinition, name: str -) -> FlowDefinition: - """Rename a definition and record its source file. Name clashes 409.""" - definition.name = name - session.add(definition) - try: - session.commit() - except IntegrityError as exc: - session.rollback() - raise ConflictError(f"flow {name!r} already exists") from exc - session.refresh(definition) - return definition - - -def next_version_no(session: Session, flow_id: UUID) -> int: - max_no = session.exec( - select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id) - ).one() - return (max_no or 0) + 1 - - -def delete_definition_cascade( - session: Session, definition: FlowDefinition -) -> dict[str, int]: - """Remove a definition and its whole subtree (tasks, runs, versions). - - Used when the canonical file disappears: the directory is the source of - truth, so the derived registry follows. Commits once. - """ - version_ids = list( - session.exec( - select(FlowVersion.id).where(FlowVersion.flow_id == definition.id) - ).all() - ) - task_count = 0 - run_count = 0 - if version_ids: - tasks = list( - session.exec( - select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids)) - ).all() - ) - runs = list( - session.exec( - select(FlowRun).where(FlowRun.version_id.in_(version_ids)) - ).all() - ) - task_count, run_count = len(tasks), len(runs) - for row in (*tasks, *runs): - session.delete(row) - for version in session.exec( - select(FlowVersion).where(FlowVersion.flow_id == definition.id) - ).all(): - session.delete(version) - session.delete(definition) - session.commit() - return { - "definitions": 1, - "versions": len(version_ids), - "runs": run_count, - "tasks": task_count, - } - - -def validate_flow_json(flow_json: dict) -> None: - """Reject anything that is not a Langflow-export-shaped document.""" +def _validate_flow_json(flow_json: dict) -> None: if not isinstance(flow_json, dict): raise InvalidError("langflow_json must be an object") data = flow_json.get("data") @@ -145,7 +70,7 @@ def create_version( session: Session, flow_id: UUID, data: FlowVersionCreate ) -> FlowVersion: get_definition(session, flow_id) - validate_flow_json(data.langflow_json) + _validate_flow_json(data.langflow_json) max_no = session.exec( select(func.max(FlowVersion.version_no)).where(FlowVersion.flow_id == flow_id) ).one() diff --git a/backend/app/flows/sync.py b/backend/app/flows/sync.py deleted file mode 100644 index 993abac..0000000 --- a/backend/app/flows/sync.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Reconciliation: backend/flows/*.json (source of truth) -> DB + LFX. - -The ONLY place that derives registry state from the canonical directory. -Triggers (CLI, POST /api/flows/sync, embedded watcher) call -sync_from_directory(); single-document callers (e.g. /import) use -sync_document(). Everything is async (the LFX client is async) and guarded -by a module lock so concurrent triggers serialize on SQLite's single writer. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import threading -import uuid -from collections.abc import Callable -from pathlib import Path -from uuid import UUID - -from sqlmodel import Session -from watchfiles import DefaultFilter, awatch - -from app.db import get_engine -from app.models import FlowDefinition, FlowVersion -from app.settings import settings - -from . import export, store -from .lfx_client import LfxClient, LfxError -from .schemas import FlowDefinitionCreate, FlowVersionCreate, SyncReport - -logger = logging.getLogger(__name__) - -# Serializes all writers in this process (sync engine + manual API writes). -sync_lock = threading.Lock() - - -def lfx_id_for(definition_id: UUID, version_no: int) -> str: - """Deterministic LFX id (a real UUID: LFX validates the format). - - Same (definition, version) always maps to the same id, so re-syncs and - crash recovery overwrite instead of orphaning LFX registrations. - """ - return str( - uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}") - ) - - -def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None: - versions = store.list_versions(session, flow_id) - return versions[-1] if versions else None - - -async def sync_document( - session: Session, - lfx: LfxClient, - doc: dict, - *, - source_filename: str, - author: str = "", - deploy: bool = True, -) -> tuple[FlowDefinition, FlowVersion | None, str]: - """Reconcile one parsed document. Returns (definition, version, action). - - Actions: "added" (new definition + version), "updated" (new version), - "skipped" (content unchanged). Raises store.ConflictError on name - clashes, store.InvalidError on invalid JSON, LfxError when deploy fails. - With deploy=False a version is recorded without touching LFX. - """ - store.validate_flow_json(doc) - name = doc.get("name") or Path(source_filename).stem - with sync_lock: - definition = store.get_definition_by_source_path(session, source_filename) - if definition is None: - definition = store.create_definition( - session, FlowDefinitionCreate(name=name) - ) - definition.source_path = source_filename - session.add(definition) - session.commit() - session.refresh(definition) - action = "added" - else: - action = "updated" - if definition.name != name: - store.set_definition_name(session, definition, name) - - latest = _latest_version(session, definition.id) - if ( - latest is not None - and export.content_hash(latest.langflow_json) - == export.content_hash(doc) - ): - return definition, latest, "skipped" - - if not deploy: - version = store.create_version( - session, - definition.id, - FlowVersionCreate(langflow_json=doc, author=author), - ) - return definition, version, action - - version_no = store.next_version_no(session, definition.id) - lfx_id = lfx_id_for(definition.id, version_no) - uploaded = await lfx.upload_flow(doc, flow_id=lfx_id, replace=True) - version = store.create_version( - session, - definition.id, - FlowVersionCreate(langflow_json=doc, author=author), - ) - if version.version_no != version_no: # pragma: no cover - lock prevents this - raise store.ConflictError( - f"concurrent version write for flow {definition.id}" - ) - version = store.mark_deployed(session, version, uploaded.id) - if ( - latest is not None - and latest.lfx_flow_id - and latest.lfx_flow_id != uploaded.id - ): - try: - await lfx.delete_flow(latest.lfx_flow_id) - except LfxError as exc: - logger.warning( - "LFX unregister %s failed (continuing): %s", - latest.lfx_flow_id, - exc, - ) - return definition, version, action - - -async def delete_definition_tree( - session: Session, lfx: LfxClient, definition: FlowDefinition -) -> dict[str, int]: - """Remove a definition whose canonical file is gone. - - LFX registrations go first (best-effort, fail-closed for new runs), the - DB subtree (tasks, runs, versions, definition) after. Deterministic LFX - ids make any crash in between self-healing on the next sync. - """ - for version in store.list_versions(session, definition.id): - if version.lfx_flow_id: - try: - await lfx.delete_flow(version.lfx_flow_id) - except LfxError as exc: - logger.warning( - "LFX unregister %s failed (continuing): %s", - version.lfx_flow_id, - exc, - ) - with sync_lock: - return store.delete_definition_cascade(session, definition) - - -async def sync_from_directory( - session: Session, lfx: LfxClient, flows_dir: str | Path -) -> SyncReport: - """Reconcile the whole canonical directory. Per-file problems land in - report.errors (never raised); a missing directory raises FileNotFoundError - so misconfiguration fails loudly instead of wiping the registry. - """ - flows_dir = Path(flows_dir) - report = SyncReport() - if not flows_dir.is_dir(): - raise FileNotFoundError(f"flows directory missing: {flows_dir}") - files = {path.name: path for path in export.iter_flow_files(flows_dir)} - for filename, path in files.items(): - try: - doc = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError) as exc: - report.errors[filename] = f"unreadable: {exc}" - continue - if not isinstance(doc, dict): - report.errors[filename] = "top-level JSON must be an object" - continue - try: - _, _, action = await sync_document( - session, lfx, doc, source_filename=filename - ) - except Exception as exc: # noqa: BLE001 - reconciliation is total - report.errors[filename] = str(exc) - continue - getattr(report, action).append(path.stem) - known = { - definition.source_path - for definition in store.list_definitions(session) - if definition.source_path - } - for stale in sorted(known - set(files)): - definition = store.get_definition_by_source_path(session, stale) - if definition is None: # pragma: no cover - defensive - continue - try: - await delete_definition_tree(session, lfx, definition) - except Exception as exc: # noqa: BLE001 - reconciliation is total - report.errors[stale] = str(exc) - continue - report.deleted.append(Path(stale).stem) - return report - - -def default_session_factory() -> Callable[[], Session]: - return lambda: Session(get_engine()) - - -def default_lfx_factory() -> Callable[[], LfxClient]: - return lambda: LfxClient( - settings.lfx_url, api_key=settings.lfx_api_key or None - ) - - -def run_sync_blocking( - flows_dir: str | Path, - *, - session_factory: Callable[[], Session] | None = None, - lfx_factory: Callable[[], LfxClient] | None = None, -) -> SyncReport: - """Blocking one-shot sync for worker threads (lifespan, watcher).""" - session = (session_factory or default_session_factory())() - try: - lfx = (lfx_factory or default_lfx_factory())() - return asyncio.run(sync_from_directory(session, lfx, Path(flows_dir))) - finally: - session.close() - - -class _JsonFilter(DefaultFilter): - def __call__(self, change, path: str) -> bool: # type: ignore[override] - return super().__call__(change, path) and path.endswith(".json") - - -async def watch_forever( - stop: asyncio.Event, - flows_dir: str | Path, - run_sync: Callable[[], SyncReport] | None = None, -) -> None: - """Run until `stop` is set: sync on every batch of .json changes. - - All failures are logged, never raised — a broken file must not kill the - watcher. `run_sync` is injectable for tests. - """ - flows_dir = Path(flows_dir) - run = run_sync or (lambda: run_sync_blocking(flows_dir)) - while not stop.is_set() and not flows_dir.is_dir(): - logger.error("flows dir missing, retrying in 30s: %s", flows_dir) - try: - await asyncio.wait_for(stop.wait(), timeout=30) - except asyncio.TimeoutError: - pass - if stop.is_set(): - return - logger.info("watching %s", flows_dir) - async for _ in awatch( - flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100 - ): - if stop.is_set(): - break - try: - report = await asyncio.to_thread(run) - except FileNotFoundError as exc: - logger.error("flow sync failed: %s", exc) - continue - except Exception: # noqa: BLE001 - watcher must survive - logger.exception("flow sync failed") - continue - logger.info( - "flow sync: +%d ~%d -%d skipped=%d errors=%d", - len(report.added), - len(report.updated), - len(report.deleted), - len(report.skipped), - len(report.errors), - ) diff --git a/backend/app/main.py b/backend/app/main.py index 94c67d6..99d3397 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,58 +1,8 @@ -import asyncio -import logging -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from pathlib import Path - from fastapi import FastAPI from .flows.api import approvals_router, flows_router -from .flows.sync import run_sync_blocking, watch_forever from .settings import settings -logger = logging.getLogger(__name__) - - -def _blocking_startup_sync() -> None: - """One-shot reconciliation at startup (fresh session, never fatal).""" - try: - report = run_sync_blocking(Path(settings.flows_dir)) - logger.info( - "startup flow sync: +%d ~%d -%d skipped=%d errors=%d", - len(report.added), - len(report.updated), - len(report.deleted), - len(report.skipped), - len(report.errors), - ) - for filename, reason in report.errors.items(): - logger.warning("startup flow sync: %s: %s", filename, reason) - except FileNotFoundError as exc: - logger.error("startup flow sync skipped: %s", exc) - except Exception: # noqa: BLE001 - API must start even if sync fails - logger.exception("startup flow sync failed") - - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncIterator[None]: - task: asyncio.Task | None = None - stop: asyncio.Event | None = None - if settings.flows_watch_enabled: - await asyncio.to_thread(_blocking_startup_sync) - stop = asyncio.Event() - task = asyncio.create_task( - watch_forever(stop, Path(settings.flows_dir)), name="flows-watcher" - ) - yield - if task is not None and stop is not None: - stop.set() - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown - pass - - app = FastAPI( title="Kirche Einfach API", description="KI-gestützte Verwaltungsplattform für Kirche", @@ -60,7 +10,6 @@ app = FastAPI( docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", - lifespan=lifespan, ) app.include_router(flows_router) diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py index 14273d2..4d45ca3 100644 --- a/backend/app/models/flow.py +++ b/backend/app/models/flow.py @@ -29,9 +29,6 @@ class FlowDefinition(SQLModel, table=True): name: str = Field(index=True, unique=True, max_length=200) description: str = Field(default="", max_length=2000) status: str = Field(default="active", max_length=20) # active | archived - # Filename under backend/flows/ this definition is synced from - # (e.g. "simple_rule.json"). None for definitions created via the API. - source_path: str | None = Field(default=None, max_length=255) # Flow to run after a human approval on this flow's runs (nullable). continuation_flow_id: UUID | None = Field( default=None, foreign_key="flow_definitions.id" diff --git a/backend/app/settings.py b/backend/app/settings.py index f3373df..8f4b7f5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -13,13 +13,10 @@ class Settings(BaseSettings): version: str = "0.1.0" debug: bool = False - # App database (SQLite file). Override via DATABASE_URL for other engines. - database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db") - - # Canonical flow definitions (source of truth): one Langflow export per file. - flows_dir: str = str(REPO_ROOT / "backend" / "flows") - # Auto-reconcile backend/flows into the DB + LFX while the API is running. - flows_watch_enabled: bool = True + database_url: str = "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach" + test_database_url: str = ( + "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach_test" + ) langflow_url: str = "http://localhost:7860" langflow_api_key: str = "" diff --git a/backend/flows/approval_handoff.json b/backend/flows/approval_handoff.json deleted file mode 100644 index cb70cf3..0000000 --- a/backend/flows/approval_handoff.json +++ /dev/null @@ -1,1945 +0,0 @@ -{ - "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 ]\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", - "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 - } - ], - "field_order": [ - "raw" - ], - "beta": false, - "legacy": false, - "edited": false, - "metadata": { - "module": "custom_components.envelope", - "code_hash": "2b0bd3c3d529", - "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_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=\"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 try:\n response = httpx.post(url, json=body, 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", - "continuation_flow_id", - "facts" - ], - "beta": false, - "legacy": false, - "edited": false, - "metadata": { - "module": "custom_components.approval_callback", - "code_hash": "a334376410f7", - "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, - "className": "", - "data": { - "sourceHandle": { - "dataType": "ChatInput", - "id": "ChatInput-entry", - "name": "message", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "raw", - "id": "CustomComponent-envelope", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œrawœ, œidœ: œCustomComponent-envelopeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "ChatInput-entry", - "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-envelope", - "targetHandle": "{œfieldNameœ: œrawœ, œidœ: œCustomComponent-envelopeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-envelope", - "name": "payload", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "payload", - "id": "CustomComponent-rule", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-envelope", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-rule", - "targetHandle": "{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-rule", - "name": "verdict", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_text", - "id": "ConditionalRouter-router", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-rule", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}", - "target": "ConditionalRouter-router", - "targetHandle": "{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "ConditionalRouter", - "id": "ConditionalRouter-router", - "name": "true_result", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "trigger", - "id": "CustomComponent-callback", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œtriggerœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "ConditionalRouter-router", - "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-callback", - "targetHandle": "{œfieldNameœ: œtriggerœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-envelope", - "name": "run_id", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "run_id", - "id": "CustomComponent-callback", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œrun_idœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œrun_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-envelope", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œrun_idœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-callback", - "targetHandle": "{œfieldNameœ: œrun_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-envelope", - "name": "callback_url", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "callback_url", - "id": "CustomComponent-callback", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_urlœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-envelope", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_urlœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-callback", - "targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-envelope", - "name": "continuation", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "continuation_flow_id", - "id": "CustomComponent-callback", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcontinuationœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcontinuation_flow_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-envelope", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcontinuationœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-callback", - "targetHandle": "{œfieldNameœ: œcontinuation_flow_idœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-envelope", - "name": "payload", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "facts", - "id": "CustomComponent-callback", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œfactsœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-envelope", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œpayloadœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-callback", - "targetHandle": "{œfieldNameœ: œfactsœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-callback", - "name": "result", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-pending", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-callbackœ, œnameœ: œresultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-pendingœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", - "selected": false, - "source": "CustomComponent-callback", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-callbackœ, œnameœ: œresultœ, œoutput_typesœ: [œMessageœ]}", - "target": "ChatOutput-pending", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-pendingœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "ConditionalRouter", - "id": "ConditionalRouter-router", - "name": "false_result", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-ok", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", - "selected": false, - "source": "ConditionalRouter-router", - "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}", - "target": "ChatOutput-ok", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" - } - ], - "viewport": { - "x": 0, - "y": 0, - "zoom": 1 - } - }, - "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/simple_rule.json b/backend/flows/simple_rule.json deleted file mode 100644 index aba77ad..0000000 --- a/backend/flows/simple_rule.json +++ /dev/null @@ -1,1393 +0,0 @@ -{ - "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, - "className": "", - "data": { - "sourceHandle": { - "dataType": "ChatInput", - "id": "ChatInput-entry", - "name": "message", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "payload", - "id": "CustomComponent-rule", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "ChatInput-entry", - "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", - "target": "CustomComponent-rule", - "targetHandle": "{œfieldNameœ: œpayloadœ, œidœ: œCustomComponent-ruleœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomComponent", - "id": "CustomComponent-rule", - "name": "verdict", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_text", - "id": "ConditionalRouter-router", - "inputTypes": [ - "Message" - ], - "type": "str" - } - }, - "id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", - "selected": false, - "source": "CustomComponent-rule", - "sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-ruleœ, œnameœ: œverdictœ, œoutput_typesœ: [œMessageœ]}", - "target": "ConditionalRouter-router", - "targetHandle": "{œfieldNameœ: œinput_textœ, œidœ: œConditionalRouter-routerœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "ConditionalRouter", - "id": "ConditionalRouter-router", - "name": "false_result", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-ok", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", - "selected": false, - "source": "ConditionalRouter-router", - "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œfalse_resultœ, œoutput_typesœ: [œMessageœ]}", - "target": "ChatOutput-ok", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-okœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" - }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "ConditionalRouter", - "id": "ConditionalRouter-router", - "name": "true_result", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-sonder", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-sonderœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", - "selected": false, - "source": "ConditionalRouter-router", - "sourceHandle": "{œdataTypeœ: œConditionalRouterœ, œidœ: œConditionalRouter-routerœ, œnameœ: œtrue_resultœ, œoutput_typesœ: [œMessageœ]}", - "target": "ChatOutput-sonder", - "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-sonderœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" - } - ], - "viewport": { - "x": 0, - "y": 0, - "zoom": 1 - } - }, - "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/migrations/versions/d932b6993f9a_add_source_path_to_flow_definitions.py b/backend/migrations/versions/d932b6993f9a_add_source_path_to_flow_definitions.py deleted file mode 100644 index 63944c8..0000000 --- a/backend/migrations/versions/d932b6993f9a_add_source_path_to_flow_definitions.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add source_path to flow_definitions - -Revision ID: d932b6993f9a -Revises: 62ea9a5d8f9d -Create Date: 2026-09-07 08:40:48.986026 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types - - -# revision identifiers, used by Alembic. -revision: str = 'd932b6993f9a' -down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - op.add_column( - "flow_definitions", - sa.Column("source_path", sa.String(length=255), nullable=True), - ) - - -def downgrade() -> None: - """Downgrade schema.""" - op.drop_column("flow_definitions", "source_path") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bfec02f..d8ffaae 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,10 +7,10 @@ dependencies = [ "alembic>=1.14", "fastapi[standard]>=0.115.0", "httpx>=0.28", + "psycopg[binary]>=3.2", "pydantic-settings>=2.0", "sqlmodel>=0.0.24", "uvicorn[standard]>=0.30.0", - "watchfiles>=0.24", ] [build-system] @@ -27,7 +27,7 @@ testpaths = ["tests"] [dependency-groups] dev = [ - "lfx", + "lfx==1.12.0", "pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=6", diff --git a/backend/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py index 3a369b6..0fd6c45 100644 --- a/backend/scripts/make_flow_fixtures.py +++ b/backend/scripts/make_flow_fixtures.py @@ -6,10 +6,6 @@ Langflow UI would export. Run with the backend venv (has the `lfx` dev dep):: uv run python scripts/make_flow_fixtures.py -Writes to two places: -- backend/tests/fixtures/flows/ (test fixtures, never deployed as-is) -- backend/flows/ (canonical source of truth, synced into DB + LFX) - Then verify:: uv run lfx validate --level 4 backend/tests/fixtures/flows/ @@ -25,7 +21,6 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent.parent FIXTURE_DIR = REPO_ROOT / "backend" / "tests" / "fixtures" / "flows" -FLOW_DIR = REPO_ROOT / "backend" / "flows" HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports @@ -435,16 +430,14 @@ def build_approval_handoff() -> dict: def main() -> None: FIXTURE_DIR.mkdir(parents=True, exist_ok=True) - FLOW_DIR.mkdir(parents=True, exist_ok=True) flows = { "simple_rule.json": build_simple_rule(), "approval_handoff.json": build_approval_handoff(), } for filename, doc in flows.items(): - payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" - for target in (FIXTURE_DIR / filename, FLOW_DIR / filename): - target.write_text(payload) - print(f"wrote {target}") + path = FIXTURE_DIR / filename + path.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n") + print(f"wrote {path}") if __name__ == "__main__": diff --git a/backend/scripts/smoke_e2e.py b/backend/scripts/smoke_e2e.py index 4ce0692..90cb3d2 100644 --- a/backend/scripts/smoke_e2e.py +++ b/backend/scripts/smoke_e2e.py @@ -1,8 +1,7 @@ """Manual end-to-end smoke: real LFX + real API, frontend-shaped calls. Usage (from backend/): - uv run python scripts/smoke_e2e.py -Runs the API against a throwaway SQLite file and an ephemeral LFX pass. + TEST_DATABASE_URL=postgresql+psycopg://... uv run python scripts/smoke_e2e.py Exits non-zero on any unexpected status/body. """ @@ -12,7 +11,7 @@ import json import os import subprocess import sys -import tempfile +import time import urllib.request from pathlib import Path @@ -55,31 +54,17 @@ def check(name: str, cond: bool, detail: str = "") -> None: def main() -> None: - tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-")) flow_dir = Path("/tmp/smoke-e2e-flows") flow_dir.mkdir(exist_ok=True) (flow_dir / "seed.json").write_text( (FIXTURES / "simple_rule.json").read_text() ) - # Migrate a throwaway SQLite schema to head before starting the API. - from alembic import command as alembic_command - from alembic.config import Config - - db_url = f"sqlite:///{tmp / 'smoke.db'}" - cfg = Config() - cfg.set_main_option("script_location", str(BACKEND / "migrations")) - cfg.set_main_option("sqlalchemy.url", db_url) - alembic_command.upgrade(cfg, "head") - env = { **os.environ, - # lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set - # it. Clear it so the serve key below is the one we send. - "LANGFLOW_API_KEY": "", "LFX_API_KEY": LFX_KEY, - "DATABASE_URL": db_url, - "FLOWS_WATCH_ENABLED": "false", + "DATABASE_URL": os.environ["TEST_DATABASE_URL"], "LFX_URL": f"http://127.0.0.1:{LFX_PORT}", + "LFX_API_KEY": LFX_KEY, "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", } lfx = subprocess.Popen( diff --git a/backend/scripts/sync_flows.py b/backend/scripts/sync_flows.py deleted file mode 100644 index 365c5fc..0000000 --- a/backend/scripts/sync_flows.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Reconcile backend/flows/*.json into the DB + LFX. - -Usage (from backend/, venv active): - uv run python scripts/sync_flows.py --sync [--dir backend/flows] - uv run python scripts/sync_flows.py --pull [--sync] # export all Langflow flows to files first - -Exit codes: 0 ok, 1 sync errors, 2 flows dir missing. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import sys -from pathlib import Path - -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 -from app.flows.lfx_client import LfxClient -from app.flows.schemas import SyncReport -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 Exception as exc: # noqa: BLE001 - pull-all is total - errors[flow_id] = str(exc) - return {"pulled": sorted(pulled), "errors": errors} - - -async def run_sync(flows_dir: Path) -> SyncReport: - reset_engine() - session = Session(get_engine()) - try: - lfx = LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) - return await sync.sync_from_directory(session, lfx, flows_dir) - finally: - session.close() - - -async def main_async(args) -> int: - flows_dir = Path(args.dir or settings.flows_dir) - if args.pull: - pulled = await pull_from_langflow(flows_dir) - print(json.dumps(pulled, indent=2, ensure_ascii=False)) - if pulled["errors"]: - return 1 - if args.sync or not args.pull: - try: - report = await run_sync(flows_dir) - except FileNotFoundError as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 2 - print(report.model_dump_json(indent=2)) - return 1 if report.errors else 0 - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--pull", action="store_true", - help="export all Langflow flows to backend/flows first") - parser.add_argument("--sync", action="store_true", - help="reconcile backend/flows into DB + LFX") - parser.add_argument("--dir", default=None, - help="flows directory (default: settings.flows_dir)") - args = parser.parse_args() - if not args.pull and not args.sync: - args.sync = True - return asyncio.run(main_async(args)) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6f786fb..7629df3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,25 +1,25 @@ """Shared pytest fixtures. -Tests run on a throwaway SQLite file (no external database required). -`DATABASE_URL` and `FLOWS_DIR` are pointed at a session temp dir *before* any -app module is imported, so settings pick them up at import time. The app -schema is migrated to head once per session via Alembic. +Requires TEST_DATABASE_URL (a dedicated Postgres database, e.g. the +`kirche_einfach_test` database from the compose stack). The URL is mapped +onto DATABASE_URL *before* any app module is imported, so settings and the +alembic env both target the test database. Never point this at production. """ import os -import tempfile from pathlib import Path import pytest -TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-")) +TEST_DB_URL = os.environ.get("TEST_DATABASE_URL") +if not TEST_DB_URL: + raise RuntimeError( + "TEST_DATABASE_URL is not set. Refusing to run tests without " + "an explicit test database (see .env.example)." + ) # Must precede all `app.*` imports: Settings is instantiated at import time. -os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}" -os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows") -# The embedded watcher + startup sync stay off in tests; sync is exercised -# directly (sync_from_directory) with per-test tmp dirs instead. -os.environ["FLOWS_WATCH_ENABLED"] = "false" +os.environ["DATABASE_URL"] = TEST_DB_URL BACKEND_DIR = Path(__file__).resolve().parent.parent @@ -115,10 +115,6 @@ def lfx_server(tmp_path_factory): env = { **os.environ, # lfx serve refuses to start without a serve key; requests need it too. - # get_api_key() prefers LANGFLOW_API_KEY over LFX_API_KEY, and the repo - # .env may put a real Langflow key in the environment — clear it so the - # test key below is the one the client sends. - "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key", } proc = subprocess.Popen( diff --git a/backend/tests/test_deploy.py b/backend/tests/test_deploy.py index f2c4601..7af0843 100644 --- a/backend/tests/test_deploy.py +++ b/backend/tests/test_deploy.py @@ -2,9 +2,7 @@ import json from pathlib import Path -from uuid import UUID, uuid4 - -from app.flows.sync import lfx_id_for +from uuid import uuid4 FIXTURES = Path(__file__).parent / "fixtures" / "flows" LANGFLOW_ID = "11111111-2222-4333-8444-555555555555" @@ -37,9 +35,7 @@ async def test_import_creates_definition_version_and_deploys( assert body["definition"]["name"] == "importiert" assert body["version"]["version_no"] == 1 assert body["version"]["author"] == "admin" - assert body["version"]["lfx_flow_id"] == lfx_id_for( - UUID(body["definition"]["id"]), 1 - ) + assert body["version"]["lfx_flow_id"] == body["version"]["id"] assert body["version"]["deployed_at"] is not None assert body["version"]["langflow_json"]["data"]["nodes"] @@ -52,7 +48,7 @@ async def test_import_creates_definition_version_and_deploys( assert response.json()["status"] == "completed" -async def test_import_same_content_is_idempotent( +async def test_import_reuses_definition_and_bumps_version( client, lfx_server, override_langflow ): override_langflow(_export_doc()) @@ -65,44 +61,9 @@ async def test_import_same_content_is_idempotent( assert first.status_code == 201 assert second.status_code == 201 assert first.json()["definition"]["id"] == second.json()["definition"]["id"] - # Unchanged content creates no new version. - assert second.json()["version"]["version_no"] == 1 - - -async def test_import_changed_content_bumps_version( - client, lfx_server, override_langflow -): - override_langflow(_export_doc()) - first = await client.post( - "/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} - ) - assert first.status_code == 201 - changed = _export_doc() - changed["description"] = "neue Beschreibung" - override_langflow(changed) - second = await client.post( - "/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} - ) - assert second.status_code == 201 assert second.json()["version"]["version_no"] == 2 -async def test_import_writes_canonical_file( - client, lfx_server, override_langflow, tmp_path, monkeypatch -): - from app.settings import settings - - monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) - override_langflow(_export_doc()) - response = await client.post( - "/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID} - ) - assert response.status_code == 201, response.text - written = tmp_path / "importiert.json" - assert written.is_file() - assert json.loads(written.read_text())["name"] == "importiert" - - async def test_import_with_explicit_name(client, lfx_server, override_langflow): override_langflow(_export_doc(name="langflow-name")) response = await client.post( diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py deleted file mode 100644 index c05d4db..0000000 --- a/backend/tests/test_export.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Unit tests for canonical flow files (export.py). No DB or LFX needed.""" - -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_canonical_bytes_stable_under_key_reorder(): - doc = {"name": "x", "data": {"nodes": [], "edges": []}, "tags": ["b", "a"]} - shuffled = {"tags": ["b", "a"], "data": {"edges": [], "nodes": []}, "name": "x"} - assert export.canonical_bytes(shuffled) == export.canonical_bytes(doc) - assert export.content_hash(doc) == export.content_hash(shuffled) - - -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 json.loads(path.read_text()) == doc - # Rewriting identical content is byte-identical (no phantom git diffs). - before = path.read_bytes() - export.write_flow_file(tmp_path, doc) - assert path.read_bytes() == before - - -def test_iter_flow_files_only_top_level_json(tmp_path): - (tmp_path / "a.json").write_text("{}") - (tmp_path / "b.txt").write_text("{}") - sub = tmp_path / "sub" - sub.mkdir() - (sub / "c.json").write_text("{}") - assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"] diff --git a/backend/tests/test_flows_api.py b/backend/tests/test_flows_api.py index 8de1d39..d27e0d0 100644 --- a/backend/tests/test_flows_api.py +++ b/backend/tests/test_flows_api.py @@ -2,9 +2,7 @@ import json from pathlib import Path -from uuid import UUID, uuid4 - -from app.flows.sync import lfx_id_for +from uuid import uuid4 FIXTURES = Path(__file__).parent / "fixtures" / "flows" @@ -83,7 +81,7 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server): flow = await _create_flow(client, "dachsanierung") await _create_version(client, flow["id"]) deployed = await _deploy(client, flow["id"]) - assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1) + assert deployed["lfx_flow_id"] == deployed["id"] assert deployed["deployed_at"] is not None response = await client.post( diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 0f3c650..40a82de 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -16,9 +16,7 @@ def test_flow_definition_defaults(session): assert d.status == "active" assert d.continuation_flow_id is None assert d.created_at is not None - # Timestamps are written as UTC. SQLite does not round-trip tzinfo, so - # after a refresh the value is calendar-time UTC without tzinfo. - assert d.created_at.year >= 2026 + assert d.created_at.tzinfo is not None def test_flow_definition_name_unique(session): diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py deleted file mode 100644 index 1cd40f4..0000000 --- a/backend/tests/test_sync.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Sync engine tests: backend/flows/*.json -> DB + LFX (LFX real, DB sqlite).""" - -import asyncio -import json -import shutil -import time -from pathlib import Path -from uuid import UUID - -import pytest - -from app.flows import store, sync -from app.flows.lfx_client import LfxClient, LfxNotFound -from app.flows.schemas import SyncReport -from app.flows.sync import lfx_id_for -from app.settings import settings - -FIXTURES = Path(__file__).parent / "fixtures" / "flows" - - -def load_fixture(name: str) -> dict: - return json.loads((FIXTURES / name).read_text()) - - -def _lfx() -> LfxClient: - return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None) - - -def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path: - shutil.copy(FIXTURES / name, tmp_path / name) - return tmp_path - - -async def test_sync_adds_new_flow(session, lfx_server, tmp_path): - _seed_dir(tmp_path) - report = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert report.added == ["simple_rule"] - assert report.errors == {} - definition = store.get_definition_by_source_path(session, "simple_rule.json") - assert definition is not None - assert definition.name == "simple_rule" - versions = store.list_versions(session, definition.id) - assert [v.version_no for v in versions] == [1] - assert versions[0].lfx_flow_id == lfx_id_for(definition.id, 1) - assert versions[0].deployed_at is not None - - -async def test_sync_skips_unchanged(session, lfx_server, tmp_path): - _seed_dir(tmp_path) - first = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert first.added == ["simple_rule"] - second = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert second.skipped == ["simple_rule"] - assert second.added == second.updated == [] - definition = store.get_definition_by_source_path(session, "simple_rule.json") - assert len(store.list_versions(session, definition.id)) == 1 - - -async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, tmp_path): - _seed_dir(tmp_path) - await sync.sync_from_directory(session, _lfx(), tmp_path) - definition = store.get_definition_by_source_path(session, "simple_rule.json") - old_lfx_id = lfx_id_for(definition.id, 1) - - doc = load_fixture("simple_rule.json") - doc["description"] = "geaendert" - (tmp_path / "simple_rule.json").write_text(json.dumps(doc)) - - report = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert report.updated == ["simple_rule"] - versions = store.list_versions(session, definition.id) - assert [v.version_no for v in versions] == [1, 2] - assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2) - # Superseded LFX registration is gone. - with pytest.raises(LfxNotFound): - await _lfx().flow_info(old_lfx_id) - - -async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, tmp_path): - _seed_dir(tmp_path) - await sync.sync_from_directory(session, _lfx(), tmp_path) - definition = store.get_definition_by_source_path(session, "simple_rule.json") - version = store.list_versions(session, definition.id)[0] - run = store.create_run(session, version, input={"a": 1}, tenant_id="default") - lfx_id = lfx_id_for(definition.id, 1) - - (tmp_path / "simple_rule.json").unlink() - report = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert report.deleted == ["simple_rule"] - assert store.get_definition_by_source_path(session, "simple_rule.json") is None - with pytest.raises(store.NotFoundError): - store.get_run(session, run.id) - with pytest.raises(LfxNotFound): - await _lfx().flow_info(lfx_id) - - -async def test_sync_reports_invalid_json(session, lfx_server, tmp_path): - (tmp_path / "kaputt.json").write_text("{kein json") - report = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert "kaputt.json" in report.errors - assert report.added == [] - assert store.list_definitions(session) == [] - - -async def test_sync_missing_dir_raises(session, lfx_server, tmp_path): - with pytest.raises(FileNotFoundError): - await sync.sync_from_directory(session, _lfx(), tmp_path / "gibts-nicht") - - -async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path): - _seed_dir(tmp_path) - await sync.sync_from_directory(session, _lfx(), tmp_path) - doc = load_fixture("simple_rule.json") - doc["name"] = "umbenannt" - (tmp_path / "simple_rule.json").write_text(json.dumps(doc)) - report = await sync.sync_from_directory(session, _lfx(), tmp_path) - assert report.updated == ["simple_rule"] - definition = store.get_definition_by_source_path(session, "simple_rule.json") - assert definition.name == "umbenannt" - - -async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch): - 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["errors"] == {} - - response = await client.post("/api/flows/sync") - assert response.json()["skipped"] == ["simple_rule"] - - -async def test_watch_forever_triggers_on_file_change(tmp_path): - import contextlib - - calls: list[str] = [] - stop = asyncio.Event() - - def run(): - calls.append("sync") - return SyncReport() - - task = asyncio.create_task(sync.watch_forever(stop, tmp_path, run_sync=run)) - try: - await asyncio.sleep(0.5) # let the watcher arm - (tmp_path / "neu.json").write_text("{}") - deadline = time.time() + 15 - while not calls and time.time() < deadline: - await asyncio.sleep(0.1) - assert calls == ["sync"] - finally: - stop.set() - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task diff --git a/compose.prod.yml b/compose.prod.yml deleted file mode 100644 index 6afd1ea..0000000 --- a/compose.prod.yml +++ /dev/null @@ -1,122 +0,0 @@ -# Minimal self-hosted deployment (internal use: no TLS / reverse proxy). -# -# Flows are the bind-mounted ./backend/flows directory (source of truth); -# the embedded API watcher reconciles them into the DB + LFX automatically. -# Registry images (:latest) auto-update nightly via watchtower; locally -# built images (app, lfx, frontend) update via `docker compose build --pull`. -# -# Usage: docker compose -f compose.prod.yml up -d --build -services: - app: - build: - context: ./backend - dockerfile: Dockerfile - env_file: - - .env - environment: - DATABASE_URL: sqlite:////srv/backend/data/kirche.db - FLOWS_DIR: /srv/backend/flows - LFX_URL: http://lfx:6004 - LANGFLOW_URL: http://langflow:7860 - CALLBACK_BASE_URL: http://app:8000 - volumes: - - ./backend/flows:/srv/backend/flows - - appdata:/srv/backend/data - ports: - - "8000:8000" - depends_on: - lfx: - condition: service_healthy - langflow: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: - [ - "CMD-SHELL", - "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"", - ] - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - ports: - - "3000:3000" - depends_on: - app: - condition: service_healthy - restart: unless-stopped - - langflow: - image: langflowai/langflow:latest - env_file: - - .env - environment: - LANGFLOW_CONFIG_DIR: /app/langflow - LANGFLOW_HOST: 0.0.0.0 - PORT: "7860" - LANGFLOW_AUTO_LOGIN: "false" - LANGFLOW_API_KEY_SOURCE: env - LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db - volumes: - - langflow-data:/app/langflow - ports: - - "7860:7860" - labels: - com.centurylinklabs.watchtower.enable: "true" - restart: unless-stopped - healthcheck: - test: - [ - "CMD-SHELL", - "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:7860/health', timeout=5)\"", - ] - interval: 30s - timeout: 5s - retries: 20 - start_period: 60s - - lfx: - build: - context: .devcontainer - dockerfile: Dockerfile.lfx - env_file: - - .env - volumes: - - lfx-data:/flows - ports: - - "6004:6004" - restart: unless-stopped - healthcheck: - test: - [ - "CMD-SHELL", - "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:6004/health', timeout=5)\"", - ] - interval: 30s - timeout: 5s - retries: 12 - start_period: 30s - - watchtower: - image: nickfedor/watchtower:latest - volumes: - - /var/run/docker.sock:/var/run/docker.sock - environment: - WATCHTOWER_LABEL_ENABLE: "true" - WATCHTOWER_SCHEDULE: "0 0 4 * * *" - WATCHTOWER_CLEANUP: "true" - WATCHTOWER_ROLLING_RESTART: "true" - labels: - com.centurylinklabs.watchtower.enable: "true" - restart: unless-stopped - -volumes: - appdata: - langflow-data: - lfx-data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 5fbf3ee..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# Production image for the React UI (build + Vite preview; internal use). -FROM node:22-slim - -WORKDIR /srv/frontend -COPY package.json package-lock.json ./ -RUN npm ci -COPY . ./ -RUN npm run build - -ENV VITE_API_PROXY_TARGET=http://app:8000 -EXPOSE 3000 - -CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"] diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 763ad9f..1dbc676 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -5,7 +5,6 @@ export interface FlowDefinition { name: string; description: string; status: string; - source_path: string | null; continuation_flow_id: string | null; created_at: string; } @@ -61,14 +60,6 @@ export interface Decision { continuation_run: Run | null; } -export interface SyncReport { - added: string[]; - updated: string[]; - deleted: string[]; - skipped: string[]; - errors: Record; -} - export class ApiError extends Error { status: number; constructor(status: number, message: string) { @@ -144,8 +135,4 @@ export const api = { ), listLangflow: () => req<{ id: string; name: string }[]>("/api/flows/langflow/available"), - syncFlows: () => - req("/api/flows/sync", { - method: "POST", - }), }; diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx index 041ff96..23908ac 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -64,26 +64,6 @@ export default function AdminTab() { } } - async function onSync() { - setError(""); - setInfo(""); - try { - const r = await api.syncFlows(); - const parts = [ - `+${r.added.length}`, - `~${r.updated.length}`, - `-${r.deleted.length}`, - ].join(" "); - const errs = Object.entries(r.errors) - .map(([f, e]) => `${f}: ${e}`) - .join("; "); - setInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`); - await load(); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - } - return (

Administration

@@ -113,19 +93,11 @@ export default function AdminTab() {

Flows + Versionen

-
- -
{flows.length === 0 &&

Keine Flows vorhanden.

}
    {flows.map((f) => (
  • - {f.name}{" "} - - {f.source_path ? `(${f.source_path})` : "(API)"} - + {f.name}
      {(versions[f.id] ?? []).map((v) => (
    • diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9da1522..d8d6737 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,14 +12,4 @@ export default defineConfig({ }, }, }, - preview: { - port: 3000, - proxy: { - "/api": { - // Overridable at container start (prod compose: http://app:8000). - target: process.env.VITE_API_PROXY_TARGET ?? "http://localhost:8000", - changeOrigin: true, - }, - }, - }, });