feat: manual full-pass sync Langflow -> files -> DB+LFX with admin guide
This commit is contained in:
parent
acea6ae624
commit
1326cece42
11 changed files with 284 additions and 41 deletions
|
|
@ -5,6 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync`
|
||||||
|
(Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle
|
||||||
|
Flows aus Langflow nach `backend/flows/*.json` und deployed sie danach
|
||||||
|
automatisch (Report mit `pulled`-Liste). Der Hintergrund-Beobachter und der
|
||||||
|
Startup-Sync bleiben dateibasiert. Die Verwaltung zeigt eine deutsche
|
||||||
|
Schritt-Anleitung direkt beim Sync-Button.
|
||||||
### Fixed
|
### Fixed
|
||||||
- LFX runtime user gets a home directory (`useradd -m`, `HOME` set):
|
- LFX runtime user gets a home directory (`useradd -m`, `HOME` set):
|
||||||
without one every flow run failed with "Settings service not found".
|
without one every flow run failed with "Settings service not found".
|
||||||
|
|
|
||||||
|
|
@ -122,9 +122,12 @@ Frontend (node lives in the `frontend` container):
|
||||||
- `docker compose exec frontend npm run build` — typecheck + build.
|
- `docker compose exec frontend npm run build` — typecheck + build.
|
||||||
- Without a docker socket: VS Code "Attach to Running Container" → frontend.
|
- Without a docker socket: VS Code "Attach to Running Container" → frontend.
|
||||||
|
|
||||||
Flow workflow: design in Langflow → export into `backend/flows/<name>.json`
|
Flow workflow: design in Langflow → press "Aus Langflow synchronisieren"
|
||||||
(`--pull`, or UI download) → commit → the watcher deploys it automatically.
|
in Verwaltung (manual full pass: Langflow → `backend/flows/<name>.json` →
|
||||||
Deleting a file removes the flow including its runs (git keeps the JSON).
|
DB + LFX deploy) → commit. File-only alternatives: `scripts/sync_flows.py
|
||||||
|
--pull --sync`, UI download, or the single-flow import by Langflow ID. The
|
||||||
|
watcher deploys file edits automatically. Deleting a file removes the flow
|
||||||
|
including its runs (git keeps the JSON).
|
||||||
|
|
||||||
## Production (self-hosted — TLS/reverse proxy required, not included)
|
## Production (self-hosted — TLS/reverse proxy required, not included)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ from app.settings import settings
|
||||||
|
|
||||||
from . import input_schema as form_schema
|
from . import input_schema as form_schema
|
||||||
from . import runner, store
|
from . import runner, store
|
||||||
from .deploy import import_from_langflow
|
from .deploy import import_from_langflow, pull_all_from_langflow
|
||||||
from .langflow_client import (
|
from .langflow_client import (
|
||||||
LangflowClient,
|
LangflowClient,
|
||||||
LangflowError,
|
LangflowError,
|
||||||
|
|
@ -160,13 +160,25 @@ def list_flows(session: Annotated[Session, Depends(get_db)]):
|
||||||
async def sync_flows(
|
async def sync_flows(
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||||
|
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
||||||
_admin: Annotated[User, Depends(require_admin)],
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
"""Manual full pass: Langflow -> backend/flows/*.json -> DB + LFX."""
|
||||||
try:
|
try:
|
||||||
return await sync_from_directory(session, lfx, Path(settings.flows_dir))
|
pulled_result = await pull_all_from_langflow(langflow, Path(settings.flows_dir))
|
||||||
|
except LangflowUnavailable as exc:
|
||||||
|
logger.warning("langflow unavailable: %s", exc)
|
||||||
|
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
||||||
|
except LangflowError as exc:
|
||||||
|
logger.warning("langflow error: %s", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
||||||
|
try:
|
||||||
|
report = await sync_from_directory(session, lfx, Path(settings.flows_dir))
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
report.pulled = pulled_result["pulled"]
|
||||||
|
report.errors = {**pulled_result["errors"], **report.errors}
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,60 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.models import FlowDefinition, FlowVersion
|
from app.models import FlowDefinition, FlowVersion
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
from . import export, sync
|
from . import export, sync
|
||||||
from .langflow_client import LangflowClient
|
from .langflow_client import LangflowClient, LangflowError
|
||||||
from .lfx_client import LfxClient
|
from .lfx_client import LfxClient
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_exported_flow(exported: dict, langflow_flow_id: str, name: str | None = None) -> dict:
|
||||||
|
"""Reduce a Langflow FlowRead to the canonical fields we persist."""
|
||||||
|
flow_name = name or exported.get("name") or f"langflow-{langflow_flow_id}"
|
||||||
|
return {
|
||||||
|
"id": exported.get("id", langflow_flow_id),
|
||||||
|
"name": flow_name,
|
||||||
|
"description": exported.get("description") or "",
|
||||||
|
"endpoint_name": exported.get("endpoint_name"),
|
||||||
|
"data": exported.get("data", {}),
|
||||||
|
"tags": exported.get("tags", []),
|
||||||
|
"webhook": exported.get("webhook", False),
|
||||||
|
"is_component": exported.get("is_component", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def pull_all_from_langflow(
|
||||||
|
langflow: LangflowClient, flows_dir: str | Path
|
||||||
|
) -> dict[str, list[str] | dict[str, str]]:
|
||||||
|
"""Export every Langflow flow to its canonical file.
|
||||||
|
|
||||||
|
Returns {"pulled": [...stems], "errors": {langflow_id: reason}}.
|
||||||
|
Per-flow failures are collected, never raised; listing failures raise
|
||||||
|
(caller maps them to 503/502).
|
||||||
|
"""
|
||||||
|
flows_dir = Path(flows_dir)
|
||||||
|
headers = await langflow.list_flows()
|
||||||
|
pulled: list[str] = []
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
for header in headers:
|
||||||
|
flow_id = str(header.get("id", ""))
|
||||||
|
if not flow_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exported = await langflow.export_flow(flow_id)
|
||||||
|
doc = normalize_exported_flow(exported, flow_id)
|
||||||
|
path = export.write_flow_file(flows_dir, doc, name=doc["name"])
|
||||||
|
pulled.append(path.stem)
|
||||||
|
except (LangflowError, OSError, ValueError) as exc:
|
||||||
|
errors[flow_id] = str(exc)
|
||||||
|
return {"pulled": sorted(pulled), "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
async def import_from_langflow(
|
async def import_from_langflow(
|
||||||
session: Session,
|
session: Session,
|
||||||
langflow: LangflowClient,
|
langflow: LangflowClient,
|
||||||
|
|
@ -28,18 +72,8 @@ async def import_from_langflow(
|
||||||
truth; sync_document() derives the definition + version from it.
|
truth; sync_document() derives the definition + version from it.
|
||||||
"""
|
"""
|
||||||
exported = await langflow.export_flow(langflow_flow_id)
|
exported = await langflow.export_flow(langflow_flow_id)
|
||||||
flow_name = name or exported.get("name") or f"langflow-{langflow_flow_id}"
|
langflow_json = normalize_exported_flow(exported, langflow_flow_id, name=name)
|
||||||
langflow_json = {
|
path = export.write_flow_file(settings.flows_dir, langflow_json, name=langflow_json["name"])
|
||||||
"id": exported.get("id", langflow_flow_id),
|
|
||||||
"name": flow_name,
|
|
||||||
"description": exported.get("description") or "",
|
|
||||||
"endpoint_name": exported.get("endpoint_name"),
|
|
||||||
"data": exported.get("data", {}),
|
|
||||||
"tags": exported.get("tags", []),
|
|
||||||
"webhook": exported.get("webhook", False),
|
|
||||||
"is_component": exported.get("is_component", False),
|
|
||||||
}
|
|
||||||
path = export.write_flow_file(settings.flows_dir, langflow_json, name=flow_name)
|
|
||||||
definition, version, _action = await sync.sync_document(
|
definition, version, _action = await sync.sync_document(
|
||||||
session,
|
session,
|
||||||
lfx,
|
lfx,
|
||||||
|
|
|
||||||
|
|
@ -127,13 +127,15 @@ class DecisionRead(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SyncReport(BaseModel):
|
class SyncReport(BaseModel):
|
||||||
"""Result of reconciling backend/flows/*.json into DB + LFX.
|
"""Result of the manual full pass: Langflow -> files -> DB + LFX.
|
||||||
|
|
||||||
Lists hold filename stems; errors maps filename to reason.
|
Lists hold filename stems; errors maps filename (or Langflow id on
|
||||||
|
pull failures) to reason.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
added: list[str] = Field(default_factory=list)
|
added: list[str] = Field(default_factory=list)
|
||||||
updated: list[str] = Field(default_factory=list)
|
updated: list[str] = Field(default_factory=list)
|
||||||
deleted: list[str] = Field(default_factory=list)
|
deleted: list[str] = Field(default_factory=list)
|
||||||
skipped: list[str] = Field(default_factory=list)
|
skipped: list[str] = Field(default_factory=list)
|
||||||
|
pulled: list[str] = Field(default_factory=list)
|
||||||
errors: dict[str, str] = Field(default_factory=dict)
|
errors: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ from pathlib import Path
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.db import get_engine, reset_engine
|
from app.db import get_engine, reset_engine
|
||||||
from app.flows import export, sync
|
from app.flows import sync
|
||||||
from app.flows.langflow_client import LangflowClient, LangflowError
|
from app.flows.deploy import pull_all_from_langflow
|
||||||
|
from app.flows.langflow_client import LangflowClient
|
||||||
from app.flows.lfx_client import LfxClient
|
from app.flows.lfx_client import LfxClient
|
||||||
from app.flows.schemas import SyncReport
|
from app.flows.schemas import SyncReport
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
@ -28,19 +29,7 @@ from app.settings import settings
|
||||||
async def pull_from_langflow(flows_dir: Path) -> dict:
|
async def pull_from_langflow(flows_dir: Path) -> dict:
|
||||||
"""Export every Langflow flow to its own canonical file. Returns a report."""
|
"""Export every Langflow flow to its own canonical file. Returns a report."""
|
||||||
client = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key)
|
client = LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key)
|
||||||
headers = await client.list_flows()
|
return await pull_all_from_langflow(client, flows_dir)
|
||||||
pulled: list[str] = []
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
for header in headers:
|
|
||||||
flow_id = str(header.get("id", ""))
|
|
||||||
try:
|
|
||||||
doc = await client.export_flow(flow_id)
|
|
||||||
name = doc.get("name") or flow_id
|
|
||||||
path = export.write_flow_file(flows_dir, doc, name=name)
|
|
||||||
pulled.append(path.stem)
|
|
||||||
except (LangflowError, OSError, ValueError) as exc:
|
|
||||||
errors[flow_id] = str(exc)
|
|
||||||
return {"pulled": sorted(pulled), "errors": errors}
|
|
||||||
|
|
||||||
|
|
||||||
async def run_sync(flows_dir: Path) -> SyncReport:
|
async def run_sync(flows_dir: Path) -> SyncReport:
|
||||||
|
|
|
||||||
|
|
@ -218,22 +218,36 @@ def override_langflow():
|
||||||
|
|
||||||
Usage: override_langflow(export_doc) -- the fake export_flow() returns the
|
Usage: override_langflow(export_doc) -- the fake export_flow() returns the
|
||||||
doc, or raises LangflowNotFound/Unavailable for status=404/0.
|
doc, or raises LangflowNotFound/Unavailable for status=404/0.
|
||||||
|
Pass flows=[...] + docs={id: doc} for multi-flow pull tests, or
|
||||||
|
list_status=0/500 to fail the listing. Default list is empty so
|
||||||
|
file-only sync tests stay hermetic.
|
||||||
"""
|
"""
|
||||||
from app.flows.api import get_langflow
|
from app.flows.api import get_langflow
|
||||||
from app.flows.langflow_client import LangflowNotFound, LangflowUnavailable
|
from app.flows.langflow_client import LangflowError, LangflowNotFound, LangflowUnavailable
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
def _override(export_doc=None, status=200):
|
def _override(export_doc=None, status=200, flows=None, docs=None, list_status=200):
|
||||||
|
if flows is None:
|
||||||
|
flows = [{"id": "fake-1", "name": "fake"}] if export_doc is not None else []
|
||||||
|
|
||||||
class FakeLangflow:
|
class FakeLangflow:
|
||||||
async def export_flow(self, flow_id):
|
async def export_flow(self, flow_id):
|
||||||
if status == 404:
|
if status == 404:
|
||||||
raise LangflowNotFound(f"flow {flow_id} not found")
|
raise LangflowNotFound(f"flow {flow_id} not found")
|
||||||
if status == 0:
|
if status == 0:
|
||||||
raise LangflowUnavailable("Langflow unreachable")
|
raise LangflowUnavailable("Langflow unreachable")
|
||||||
|
if docs is not None:
|
||||||
|
if flow_id in docs:
|
||||||
|
return docs[flow_id]
|
||||||
|
raise LangflowNotFound(f"flow {flow_id} not found")
|
||||||
return export_doc
|
return export_doc
|
||||||
|
|
||||||
async def list_flows(self):
|
async def list_flows(self):
|
||||||
return [{"id": "fake-1", "name": "fake"}]
|
if list_status == 0:
|
||||||
|
raise LangflowUnavailable("Langflow unreachable")
|
||||||
|
if list_status == 500:
|
||||||
|
raise LangflowError("Langflow error 500")
|
||||||
|
return flows
|
||||||
|
|
||||||
app.dependency_overrides[get_langflow] = lambda: FakeLangflow()
|
app.dependency_overrides[get_langflow] = lambda: FakeLangflow()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,20 +118,102 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
|
||||||
assert definition.name == "umbenannt"
|
assert definition.name == "umbenannt"
|
||||||
|
|
||||||
|
|
||||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
|
async def test_sync_endpoint_returns_report(
|
||||||
|
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||||
|
):
|
||||||
await login()
|
await login()
|
||||||
|
override_langflow() # empty Langflow: file-only pass
|
||||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
_seed_dir(tmp_path)
|
_seed_dir(tmp_path)
|
||||||
response = await client.post("/api/flows/sync")
|
response = await client.post("/api/flows/sync")
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["added"] == ["simple_rule"]
|
assert body["added"] == ["simple_rule"]
|
||||||
|
assert body["pulled"] == []
|
||||||
assert body["errors"] == {}
|
assert body["errors"] == {}
|
||||||
|
|
||||||
response = await client.post("/api/flows/sync")
|
response = await client.post("/api/flows/sync")
|
||||||
assert response.json()["skipped"] == ["simple_rule"]
|
assert response.json()["skipped"] == ["simple_rule"]
|
||||||
|
|
||||||
|
|
||||||
|
def _langflow_doc(name: str, langflow_id: str) -> dict:
|
||||||
|
doc = load_fixture("simple_rule.json")
|
||||||
|
return {
|
||||||
|
"id": langflow_id,
|
||||||
|
"name": name,
|
||||||
|
"description": "aus Langflow",
|
||||||
|
"endpoint_name": None,
|
||||||
|
"data": doc["data"],
|
||||||
|
"tags": [],
|
||||||
|
"webhook": False,
|
||||||
|
"is_component": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_endpoint_pulls_from_langflow_then_syncs(
|
||||||
|
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||||
|
):
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
|
langflow_id = "22222222-3333-4444-8555-666666666666"
|
||||||
|
override_langflow(
|
||||||
|
flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}],
|
||||||
|
docs={langflow_id: _langflow_doc("Gehaltserhöhung", langflow_id)},
|
||||||
|
)
|
||||||
|
response = await client.post("/api/flows/sync")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["pulled"] == ["gehaltserh-hung"]
|
||||||
|
assert body["added"] == ["gehaltserh-hung"]
|
||||||
|
assert body["errors"] == {}
|
||||||
|
assert (tmp_path / "gehaltserh-hung.json").is_file()
|
||||||
|
|
||||||
|
# Second pass: same content pulls again but syncs nothing new.
|
||||||
|
response = await client.post("/api/flows/sync")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["pulled"] == ["gehaltserh-hung"]
|
||||||
|
assert body["skipped"] == ["gehaltserh-hung"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_endpoint_pull_error_continues(
|
||||||
|
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||||
|
):
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
|
good_id = "33333333-4444-4555-8666-777777777777"
|
||||||
|
override_langflow(
|
||||||
|
flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}],
|
||||||
|
docs={good_id: _langflow_doc("Gut", good_id)},
|
||||||
|
)
|
||||||
|
response = await client.post("/api/flows/sync")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["pulled"] == ["gut"]
|
||||||
|
assert body["added"] == ["gut"]
|
||||||
|
assert "kaputt-id" in body["errors"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_endpoint_langflow_unreachable(
|
||||||
|
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||||
|
):
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
|
override_langflow(flows=[], list_status=0)
|
||||||
|
response = await client.post("/api/flows/sync")
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_endpoint_langflow_error(
|
||||||
|
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||||
|
):
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
|
override_langflow(flows=[], list_status=500)
|
||||||
|
response = await client.post("/api/flows/sync")
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
async def test_watch_forever_triggers_on_file_change(tmp_path):
|
async def test_watch_forever_triggers_on_file_change(tmp_path):
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export interface SyncReport {
|
||||||
updated: string[];
|
updated: string[];
|
||||||
deleted: string[];
|
deleted: string[];
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
|
pulled: string[];
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
type FlowDefinition,
|
type FlowDefinition,
|
||||||
type FlowVersion,
|
type FlowVersion,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import FlowHelp from "./FlowHelp";
|
||||||
import StatusBadge from "./StatusBadge";
|
import StatusBadge from "./StatusBadge";
|
||||||
|
|
||||||
export default function AdminTab() {
|
export default function AdminTab() {
|
||||||
|
|
@ -89,6 +90,7 @@ export default function AdminTab() {
|
||||||
try {
|
try {
|
||||||
const r = await api.syncFlows();
|
const r = await api.syncFlows();
|
||||||
const parts = [
|
const parts = [
|
||||||
|
`Langflow: ${r.pulled.length}`,
|
||||||
`+${r.added.length}`,
|
`+${r.added.length}`,
|
||||||
`~${r.updated.length}`,
|
`~${r.updated.length}`,
|
||||||
`-${r.deleted.length}`,
|
`-${r.deleted.length}`,
|
||||||
|
|
@ -147,6 +149,7 @@ export default function AdminTab() {
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
|
<FlowHelp />
|
||||||
<Card withBorder padding="md" mb="md">
|
<Card withBorder padding="md" mb="md">
|
||||||
<Group>
|
<Group>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -154,9 +157,13 @@ export default function AdminTab() {
|
||||||
leftSection={<IconRefresh size={16} />}
|
leftSection={<IconRefresh size={16} />}
|
||||||
onClick={() => void onSync()}
|
onClick={() => void onSync()}
|
||||||
>
|
>
|
||||||
Aus backend/flows synchronisieren
|
Aus Langflow synchronisieren
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" mt="xs">
|
||||||
|
Holt alle Flows aus Langflow nach backend/flows und deployed sie
|
||||||
|
danach automatisch.
|
||||||
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
{flows.length === 0 ? (
|
{flows.length === 0 ? (
|
||||||
<Text c="dimmed">Keine Flows vorhanden.</Text>
|
<Text c="dimmed">Keine Flows vorhanden.</Text>
|
||||||
|
|
|
||||||
92
frontend/src/components/FlowHelp.tsx
Normal file
92
frontend/src/components/FlowHelp.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
List,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
|
||||||
|
import { LANGFLOW_URL } from "../api";
|
||||||
|
|
||||||
|
export default function FlowHelp() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder padding="md" mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={open ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? "Anleitung verbergen" : "Anleitung: Neuer Flow in Langflow"}
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<div>
|
||||||
|
<Title order={4} mt="sm" mb="xs">
|
||||||
|
So kommt ein neuer Flow in die Webapp
|
||||||
|
</Title>
|
||||||
|
<List size="sm" spacing="xs">
|
||||||
|
<List.Item>
|
||||||
|
<Text component="span" fw={600}>
|
||||||
|
1. In Langflow entwerfen und speichern:{" "}
|
||||||
|
</Text>
|
||||||
|
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
||||||
|
Langflow öffnen <IconExternalLink size={14} />
|
||||||
|
</Anchor>
|
||||||
|
. Die bestehenden Flows sind dort bereits geladen. Name vergeben, speichern.
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
<Text component="span" fw={600}>
|
||||||
|
2. Sync-Button unten klicken:{" "}
|
||||||
|
</Text>
|
||||||
|
holt alle Flows aus Langflow nach{" "}
|
||||||
|
<Text component="span" ff="monospace">
|
||||||
|
backend/flows/*.json
|
||||||
|
</Text>{" "}
|
||||||
|
und deployed sie danach automatisch (neue Version nur bei geändertem Inhalt,
|
||||||
|
sonst übersprungen).
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
<Text component="span" fw={600}>
|
||||||
|
3. Prüfen:{" "}
|
||||||
|
</Text>
|
||||||
|
Tabelle unten zeigt Version, Autor und Status. Danach erscheint der Flow
|
||||||
|
automatisch auf der Seite Workflows mit seinem Formular. Flows ohne
|
||||||
|
Formular-Knoten zeigen ein Freitext-Feld.
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
<Title order={5} mt="md" mb="xs">
|
||||||
|
Regeln
|
||||||
|
</Title>
|
||||||
|
<List size="sm" spacing="xs">
|
||||||
|
<List.Item>
|
||||||
|
Der Dateiname wird aus dem Flow-Namen erzeugt (Kleinbuchstaben,
|
||||||
|
Bindestriche). Umbenennen erzeugt eine neue Datei.
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
Eine gelöschte Datei löscht den Flow inklusive seiner Läufe
|
||||||
|
(Datei bleibt in Git erhalten).
|
||||||
|
</List.Item>
|
||||||
|
<List.Item>
|
||||||
|
Fehler stehen in der Sync-Meldung (z. B. ungültiges Flow-JSON).
|
||||||
|
Der automatische Beobachter im Hintergrund syncet nur Dateien,
|
||||||
|
Langflow holt nur dieser Button.
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
<Title order={5} mt="md" mb="xs">
|
||||||
|
Formular-Felder
|
||||||
|
</Title>
|
||||||
|
<Text size="sm">
|
||||||
|
Für ein Web-Formular braucht der Flow einen CustomInputForm-Knoten: jeder
|
||||||
|
Eintrag dort ist ein Formularfeld (Name = Feld-Key, Anzeigename = Label).
|
||||||
|
Die Verbindung ChatInput nach form_values bitte behalten — darüber kommen
|
||||||
|
die ausgefüllten Werte an. Unbekannte Feldtypen fallen automatisch auf ein
|
||||||
|
Textfeld zurück.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue