feat: asked-field envelope, standalone form components, branched forms
This commit is contained in:
parent
0d43b0e8fb
commit
f27be044b4
26 changed files with 3651 additions and 104 deletions
|
|
@ -317,6 +317,9 @@ async def _run_stream(
|
|||
callback_base_url=settings.callback_base_url,
|
||||
continuation_flow_id=definition.continuation_flow_id,
|
||||
callback_token=settings.callback_token,
|
||||
fields=form_schema.schema_field_names(
|
||||
version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
),
|
||||
)
|
||||
run_id = run.id
|
||||
lfx_flow_id = version.lfx_flow_id
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import Session
|
||||
|
|
@ -13,6 +14,8 @@ from . import export, sync
|
|||
from .langflow_client import LangflowClient, LangflowError
|
||||
from .lfx_client import LfxClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict:
|
||||
"""Reduce a Langflow FlowRead to the canonical fields we persist."""
|
||||
|
|
@ -40,6 +43,7 @@ async def pull_all_from_langflow(
|
|||
"""
|
||||
flows_dir = Path(flows_dir)
|
||||
headers = await langflow.list_flows()
|
||||
ignored = export.ignored_stems(flows_dir)
|
||||
pulled: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
for header in headers:
|
||||
|
|
@ -49,6 +53,9 @@ async def pull_all_from_langflow(
|
|||
try:
|
||||
exported = await langflow.export_flow(flow_id)
|
||||
doc = normalize_exported_flow(exported, flow_id)
|
||||
if export.filename_stem(doc["name"]) in ignored:
|
||||
logger.warning("pull skips ignored flow %r", doc["name"])
|
||||
continue
|
||||
reuse, identical = export.find_reuse_path(flows_dir, doc)
|
||||
if identical and reuse is not None:
|
||||
pulled.append(reuse.stem)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ from pathlib import Path
|
|||
|
||||
FLOWS_GLOB = "*.json"
|
||||
|
||||
#: Opt-out list in the flows dir (one stem per line, `#` comments):
|
||||
#: listed flows stay in Langflow only — never synced, deployed,
|
||||
#: validated, or shown in the frontend.
|
||||
IGNORE_FILENAME = ".flowsignore"
|
||||
|
||||
|
||||
def ignored_stems(flows_dir: str | Path) -> set[str]:
|
||||
"""Stems excluded from the pipeline (empty when no ignore file)."""
|
||||
try:
|
||||
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return set()
|
||||
return {
|
||||
line.strip()
|
||||
for line in text.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
}
|
||||
|
||||
|
||||
def filename_stem(name: str) -> str:
|
||||
"""Map a flow name to a filename stem, keeping the original name.
|
||||
|
|
@ -113,6 +131,7 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
|
|||
return write_flow_bytes(flow_path(flows_dir, stem), doc)
|
||||
|
||||
|
||||
def iter_flow_files(flows_dir: str | Path) -> list[Path]:
|
||||
"""Sorted list of candidate flow files (top level only)."""
|
||||
return sorted(Path(flows_dir).glob(FLOWS_GLOB))
|
||||
def iter_flow_files(flows_dir: str | Path, *, ignored: set[str] | None = None) -> list[Path]:
|
||||
"""Sorted list of candidate flow files (top level only, minus ignored)."""
|
||||
skip = ignored if ignored is not None else ignored_stems(flows_dir)
|
||||
return sorted(p for p in Path(flows_dir).glob(FLOWS_GLOB) if p.stem not in skip)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""Derive a web-form schema from a Langflow export.
|
||||
|
||||
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||
``display_name`` starting with ``CustomInput`` — see
|
||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
||||
Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner
|
||||
``display_name`` starting with ``CustomInput``, see
|
||||
``backend/components/kirche/form_inputs.py``) plus user-built
|
||||
``CustomComponent`` nodes whose display name ends with ``"Form"``
|
||||
(e.g. ``Mitarbeiter Einstellen Form``) — and maps their ``template``
|
||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
||||
|
||||
* ``name`` → property key (freely renamable in the Langflow code editor;
|
||||
|
|
@ -39,7 +41,7 @@ _VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
|||
|
||||
|
||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
||||
"""Inner node dicts (``data.node``) of all form nodes."""
|
||||
if not isinstance(langflow_json, dict):
|
||||
return []
|
||||
data = langflow_json.get("data") or {}
|
||||
|
|
@ -54,6 +56,10 @@ def _form_templates(langflow_json: dict) -> list[dict]:
|
|||
display = str(inner.get("display_name") or "")
|
||||
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
||||
found.append(inner)
|
||||
elif node_type == "CustomComponent" and display.endswith("Form"):
|
||||
# User-built form components (e.g. "Mitarbeiter Einstellen
|
||||
# Form"): same template contract, no kirche base needed.
|
||||
found.append(inner)
|
||||
return found
|
||||
|
||||
|
||||
|
|
@ -328,3 +334,37 @@ def extract_form_schema(langflow_json: dict) -> dict:
|
|||
elif advanced:
|
||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||
return {"json_schema": json_schema, "ui_schema": ui_schema}
|
||||
|
||||
|
||||
def schema_field_names(cached: dict) -> list[str]:
|
||||
"""All asked field names of a cached form schema (root + branches).
|
||||
|
||||
Used for the run envelope's ``fields`` list so the runtime emits
|
||||
defaults only for asked fields. Order: root properties first, then
|
||||
branch discoveries.
|
||||
"""
|
||||
if not isinstance(cached, dict):
|
||||
return []
|
||||
json_schema = cached.get("json_schema") or {}
|
||||
names: list[str] = []
|
||||
|
||||
def _walk(node: object) -> None:
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
for subschema in (node.get("then"), node.get("else")):
|
||||
if isinstance(subschema, dict):
|
||||
for key in subschema.get("properties") or {}:
|
||||
if key not in names:
|
||||
names.append(key)
|
||||
_walk(subschema)
|
||||
for key in ("allOf", "oneOf", "anyOf"):
|
||||
items = node.get(key) or []
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
_walk(item)
|
||||
|
||||
for key in json_schema.get("properties") or {}:
|
||||
if key not in names:
|
||||
names.append(key)
|
||||
_walk(json_schema)
|
||||
return names
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from sqlmodel import Session, select
|
|||
|
||||
from app.models import ApprovalTask, FlowRun, FlowVersion
|
||||
|
||||
from . import input_schema as form_schema
|
||||
from . import store
|
||||
from .lfx_client import LfxClient, LfxRunFailed
|
||||
from .store import InvalidError
|
||||
|
|
@ -21,16 +22,21 @@ def build_envelope(
|
|||
callback_base_url: str,
|
||||
continuation_flow_id: UUID | None,
|
||||
callback_token: str,
|
||||
fields: list[str] | None = None,
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"payload": payload,
|
||||
"run_id": str(run_id),
|
||||
"callback_url": callback_base_url.rstrip("/"),
|
||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||
"callback_token": callback_token,
|
||||
}
|
||||
)
|
||||
"""Run envelope for LFX. ``fields`` (asked form-field names, from the
|
||||
version's cached input schema) lets form components emit defaults only
|
||||
for asked fields; omitted (None) preserves the legacy shape."""
|
||||
body: dict = {
|
||||
"payload": payload,
|
||||
"run_id": str(run_id),
|
||||
"callback_url": callback_base_url.rstrip("/"),
|
||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||
"callback_token": callback_token,
|
||||
}
|
||||
if fields is not None:
|
||||
body["fields"] = list(fields)
|
||||
return json.dumps(body)
|
||||
|
||||
|
||||
def _task_exists(session: Session, run_id: UUID) -> bool:
|
||||
|
|
@ -66,12 +72,14 @@ async def execute_run(
|
|||
run = store.create_run(
|
||||
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
|
||||
)
|
||||
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
envelope = build_envelope(
|
||||
payload=payload,
|
||||
run_id=run.id,
|
||||
callback_base_url=callback_base_url,
|
||||
continuation_flow_id=continuation_flow_id,
|
||||
callback_token=callback_token,
|
||||
fields=form_schema.schema_field_names(cached),
|
||||
)
|
||||
try:
|
||||
result = await lfx.run_flow(
|
||||
|
|
|
|||
|
|
@ -211,7 +211,11 @@ def run_sync_blocking(
|
|||
|
||||
class _JsonFilter(DefaultFilter):
|
||||
def __call__(self, change, path: str) -> bool: # type: ignore[override]
|
||||
return super().__call__(change, path) and path.endswith(".json")
|
||||
if not (super().__call__(change, path) and path.endswith(".json")):
|
||||
return False
|
||||
# The ignore file itself is tiny; re-read per event so no restart
|
||||
# is needed after editing it.
|
||||
return Path(path).stem not in export.ignored_stems(Path(path).parent)
|
||||
|
||||
|
||||
async def watch_forever(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue