feat: form-input library with schema parsing and endpoint
This commit is contained in:
parent
80d81b8022
commit
46ecab00db
13 changed files with 2775 additions and 2 deletions
|
|
@ -18,6 +18,7 @@ from app.settings import settings
|
|||
from app.auth.deps import get_optional_user, resolve_tenant
|
||||
|
||||
from . import runner, store
|
||||
from . import input_schema as form_schema
|
||||
from .deploy import import_from_langflow
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
from .langflow_client import (
|
||||
|
|
@ -46,6 +47,7 @@ from .schemas import (
|
|||
FlowImportRequest,
|
||||
FlowVersionCreate,
|
||||
FlowVersionRead,
|
||||
InputSchemaRead,
|
||||
RunRead,
|
||||
RunRequest,
|
||||
SyncReport,
|
||||
|
|
@ -91,6 +93,7 @@ def _read_version(v) -> FlowVersionRead:
|
|||
deployed_at=v.deployed_at,
|
||||
author=v.author,
|
||||
created_at=v.created_at,
|
||||
input_schema=v.input_schema,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -386,6 +389,28 @@ async def import_flow(
|
|||
)
|
||||
|
||||
|
||||
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
|
||||
def get_input_schema(
|
||||
flow_id: UUID,
|
||||
session: Session = Depends(get_db),
|
||||
):
|
||||
"""Form schema of the latest deployed version (404 when none)."""
|
||||
try:
|
||||
version = store.get_latest_deployed_version(session, flow_id)
|
||||
except (store.NotFoundError, store.InvalidError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
cached = version.input_schema or form_schema.extract_form_schema(
|
||||
version.langflow_json
|
||||
)
|
||||
return InputSchemaRead(
|
||||
flow_id=flow_id,
|
||||
version_no=version.version_no,
|
||||
json_schema=cached.get("json_schema", {}),
|
||||
ui_schema=cached.get("ui_schema", {}),
|
||||
fallback=cached.get("fallback"),
|
||||
)
|
||||
|
||||
|
||||
@flows_router.get("/runs/{run_id}", response_model=RunRead)
|
||||
def get_run(
|
||||
run_id: UUID,
|
||||
|
|
|
|||
147
backend/app/flows/input_schema.py
Normal file
147
backend/app/flows/input_schema.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Derive a web-form schema from a Langflow export.
|
||||
|
||||
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||
``display_name`` starting with ``CustomInput`` — see
|
||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
||||
|
||||
* ``name`` → property key, ``display_name`` → ``title``,
|
||||
``info`` → ``description``, ``value`` → ``default``,
|
||||
``options`` → ``enum``, ``required`` → required array
|
||||
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
||||
form fields, and are skipped
|
||||
* ``advanced`` fields render last via ``ui:order``
|
||||
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
||||
so the parser never crashes on new fields)
|
||||
|
||||
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
||||
renders one free-text box instead of a generated form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
||||
if not isinstance(langflow_json, dict):
|
||||
return []
|
||||
data = langflow_json.get("data") or {}
|
||||
nodes = data.get("nodes") or []
|
||||
found = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_data = node.get("data") or {}
|
||||
node_type = str(node_data.get("type") or "")
|
||||
inner = node_data.get("node") or {}
|
||||
display = str(inner.get("display_name") or "")
|
||||
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
||||
found.append(inner)
|
||||
return found
|
||||
|
||||
|
||||
def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
||||
"""(JSON-Schema property, uiSchema entry) for one template field."""
|
||||
input_type = str(field.get("_input_type") or "")
|
||||
prop: dict = {}
|
||||
ui: dict = {}
|
||||
title = field.get("display_name") or name
|
||||
info = field.get("info") or ""
|
||||
if title:
|
||||
prop["title"] = title
|
||||
if info:
|
||||
prop["description"] = info
|
||||
|
||||
options = field.get("options") or []
|
||||
if input_type in ("StrInput", "MessageTextInput"):
|
||||
prop["type"] = "string"
|
||||
elif input_type == "MultilineInput":
|
||||
prop["type"] = "string"
|
||||
ui["ui:widget"] = "textarea"
|
||||
elif input_type == "SecretStrInput":
|
||||
prop["type"] = "string"
|
||||
ui["ui:widget"] = "password"
|
||||
elif input_type == "IntInput":
|
||||
prop["type"] = "integer"
|
||||
elif input_type == "FloatInput":
|
||||
prop["type"] = "number"
|
||||
elif input_type == "BoolInput":
|
||||
prop["type"] = "boolean"
|
||||
elif input_type == "DropdownInput":
|
||||
prop["type"] = "string"
|
||||
if options:
|
||||
prop["enum"] = list(options)
|
||||
elif input_type == "MultiselectInput":
|
||||
prop["type"] = "array"
|
||||
items: dict = {"type": "string"}
|
||||
if options:
|
||||
items["enum"] = list(options)
|
||||
prop["items"] = items
|
||||
prop["uniqueItems"] = True
|
||||
elif input_type == "SliderInput":
|
||||
prop["type"] = "number"
|
||||
spec = field.get("range_spec") or {}
|
||||
if spec.get("min") is not None:
|
||||
prop["minimum"] = spec["min"]
|
||||
if spec.get("max") is not None:
|
||||
prop["maximum"] = spec["max"]
|
||||
if spec.get("step"):
|
||||
prop["multipleOf"] = spec["step"]
|
||||
ui["ui:widget"] = "range"
|
||||
elif input_type == "FileInput":
|
||||
# Stub for now: string reference only, real upload comes later.
|
||||
prop["type"] = "string"
|
||||
else:
|
||||
prop["type"] = "string"
|
||||
|
||||
value = field.get("value")
|
||||
if value is not None:
|
||||
# A default outside its enum would fail validation on touch.
|
||||
enum = prop.get("enum")
|
||||
if not (enum is not None and value not in enum):
|
||||
# bool is an int subclass; keep integer defaults clean.
|
||||
if prop.get("type") == "integer" and isinstance(value, bool):
|
||||
pass
|
||||
else:
|
||||
prop["default"] = value
|
||||
return prop, ui
|
||||
|
||||
|
||||
def extract_form_schema(langflow_json: dict) -> dict:
|
||||
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
||||
templates = _form_templates(langflow_json)
|
||||
if not templates:
|
||||
return {
|
||||
"json_schema": {"type": "object", "properties": {}},
|
||||
"ui_schema": {},
|
||||
"fallback": "textarea",
|
||||
}
|
||||
template = templates[0].get("template") or {}
|
||||
properties: dict = {}
|
||||
required: list[str] = []
|
||||
ui_schema: dict = {}
|
||||
normal: list[str] = []
|
||||
advanced: list[str] = []
|
||||
for key, field in template.items():
|
||||
if key == "code" or not isinstance(field, dict):
|
||||
continue
|
||||
if field.get("show") is False:
|
||||
continue
|
||||
name = field.get("name") or key
|
||||
prop, ui = _field_schema(name, field)
|
||||
properties[name] = prop
|
||||
if field.get("required"):
|
||||
required.append(name)
|
||||
if ui:
|
||||
ui_schema[name] = ui
|
||||
(advanced if field.get("advanced") else normal).append(name)
|
||||
if advanced:
|
||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||
return {
|
||||
"json_schema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
"ui_schema": ui_schema,
|
||||
}
|
||||
|
|
@ -37,6 +37,18 @@ class FlowVersionRead(BaseModel):
|
|||
deployed_at: datetime | None
|
||||
author: str
|
||||
created_at: datetime
|
||||
input_schema: dict | None = None
|
||||
|
||||
|
||||
class InputSchemaRead(BaseModel):
|
||||
"""Form schema of the latest deployed version (RJSF renders it)."""
|
||||
|
||||
flow_id: UUID
|
||||
version_no: int
|
||||
json_schema: dict
|
||||
ui_schema: dict = Field(default_factory=dict)
|
||||
# Set when the flow has no CustomInput node: render a free-text box.
|
||||
fallback: str | None = None
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from sqlmodel import Session, func, select
|
|||
|
||||
from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||
|
||||
from . import input_schema as form_schema
|
||||
from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate
|
||||
|
||||
|
||||
|
|
@ -154,6 +155,9 @@ def create_version(
|
|||
version_no=(max_no or 0) + 1,
|
||||
langflow_json=data.langflow_json,
|
||||
author=data.author,
|
||||
# Single choke point: sync, import, and API creation all cache the
|
||||
# form schema here, so reads never re-parse.
|
||||
input_schema=form_schema.extract_form_schema(data.langflow_json),
|
||||
)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue