diff --git a/CHANGELOG.md b/CHANGELOG.md index c320d28..ae63868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 zusätzlich Pull-Dedup (wiederverwendet gleichnamige/identische Dateien statt Bindestrich-Duplikate wie `simple-rule.json`, die jede Sync-Meldung mit Namenskonflikten füllten). +- Sync-Dateinamen behalten die Langflow-Namen (`test_flow.json` statt + `test-flow.json`; nur dateisystem-reservierte Zeichen werden ersetzt). +- Gezogene Flow-Dateien sind world-readable (0o644): `mkstemp` erzeugte + 0o600-Dateien, die Langflow beim Start abstürzen ließen + (`Permission denied` in `/app/flows`, Crash-Loop) und der API Fehler + (`unreadable`) verursachten. - LFX runtime user gets a home directory (`useradd -m`, `HOME` set): without one every flow run failed with "Settings service not found". ### Security diff --git a/backend/app/flows/deploy.py b/backend/app/flows/deploy.py index ef53c3c..6f017cb 100644 --- a/backend/app/flows/deploy.py +++ b/backend/app/flows/deploy.py @@ -53,7 +53,7 @@ async def pull_all_from_langflow( if identical and reuse is not None: pulled.append(reuse.stem) continue - target = reuse or export.flow_path(flows_dir, export.slugify(doc["name"])) + target = reuse or export.flow_path(flows_dir, export.filename_stem(doc["name"])) path = export.write_flow_bytes(target, doc) pulled.append(path.stem) except (LangflowError, OSError, ValueError) as exc: diff --git a/backend/app/flows/export.py b/backend/app/flows/export.py index 2ad6a49..0681b2f 100644 --- a/backend/app/flows/export.py +++ b/backend/app/flows/export.py @@ -16,10 +16,17 @@ from pathlib import Path FLOWS_GLOB = "*.json" -def slugify(name: str) -> str: - """Map a flow name to a safe filename stem (lowercase, [a-z0-9-]).""" - slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - return slug or "flow" +def filename_stem(name: str) -> str: + """Map a flow name to a filename stem, keeping the original name. + + Only filesystem-reserved characters (``/ \\ : * ? " < > |`` and control + chars) become ``-``; leading/trailing whitespace and trailing dots are + stripped (Windows). Case, spaces, underscores and umlauts are preserved: + ``test_flow`` stays ``test_flow``. + """ + stem = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "-", name).strip() + stem = stem.rstrip(".") + return stem or "flow" def canonical_bytes(doc: dict) -> bytes: @@ -70,7 +77,12 @@ def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool def write_flow_bytes(target: Path, doc: dict) -> Path: - """Write a flow document to an explicit path atomically (tmp + rename).""" + """Write a flow document to an explicit path atomically (tmp + rename). + + The file ends up world-readable (0o644): mkstemp creates 0o600, which + would lock out the other containers (Langflow/LFX run as different + users and crash on unreadable seed files). + """ target.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp( dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp" @@ -79,6 +91,7 @@ def write_flow_bytes(target: Path, doc: dict) -> Path: with os.fdopen(fd, "wb") as tmp: tmp.write(canonical_bytes(doc)) os.replace(tmp_name, target) + os.chmod(target, 0o644) except BaseException: try: os.unlink(tmp_name) @@ -96,7 +109,7 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None """ flows_dir = Path(flows_dir) flows_dir.mkdir(parents=True, exist_ok=True) - stem = slugify(name or doc.get("name") or "flow") + stem = filename_stem(name or doc.get("name") or "flow") return write_flow_bytes(flow_path(flows_dir, stem), doc) diff --git a/backend/flows/new-flow.json b/backend/flows/new-flow.json deleted file mode 100644 index 7b3f462..0000000 --- a/backend/flows/new-flow.json +++ /dev/null @@ -1,747 +0,0 @@ -{ - "data": { - "edges": [ - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "CustomInputForm", - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "name": "form_data", - "output_types": [ - "Message" - ] - }, - "targetHandle": { - "fieldName": "input_value", - "id": "ChatOutput-HkK3i", - "inputTypes": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "type": "other" - } - }, - "id": "xy-edge__ext:kirche:CustomInputForm@extra-BhL6Q{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}-ChatOutput-HkK3i{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}", - "selected": false, - "source": "ext:kirche:CustomInputForm@extra-BhL6Q", - "sourceHandle": "{œdataTypeœ:œCustomInputFormœ,œidœ:œext:kirche:CustomInputForm@extra-BhL6Qœ,œnameœ:œform_dataœ,œoutput_typesœ:[œMessageœ]}", - "target": "ChatOutput-HkK3i", - "targetHandle": "{œfieldNameœ:œinput_valueœ,œidœ:œChatOutput-HkK3iœ,œinputTypesœ:[œDataœ,œJSONœ,œDataFrameœ,œTableœ,œMessageœ],œtypeœ:œotherœ}" - } - ], - "nodes": [ - { - "data": { - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "node": { - "base_classes": [ - "Message" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Collects structured form values into a single JSON message.", - "display_name": "CustomInput Form", - "documentation": "", - "edited": false, - "field_order": [ - "form_values", - "full_name", - "notes", - "password", - "age", - "amount", - "newsletter", - "department", - "topics", - "satisfaction", - "attachment" - ], - "frozen": false, - "icon": "ClipboardList", - "legacy": false, - "lf_version": "1.12.0", - "metadata": { - "code_hash": "6d186fa88d37", - "dependencies": { - "dependencies": [ - { - "name": "lfx", - "version": "1.12.0" - } - ], - "total_dependencies": 1 - }, - "module": "custom_components.custominput_form" - }, - "minimized": false, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Form Data", - "group_outputs": false, - "loop_types": null, - "method": "collect", - "name": "form_data", - "options": null, - "required_inputs": null, - "selected": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "age": { - "_input_type": "IntInput", - "advanced": false, - "api_editable": false, - "display_name": "Age", - "dynamic": false, - "info": "Your age in years", - "list": false, - "list_add_label": "Add More", - "name": "age", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "int", - "value": 18 - }, - "amount": { - "_input_type": "FloatInput", - "advanced": false, - "api_editable": false, - "display_name": "Amount", - "dynamic": false, - "info": "Amount in euros", - "list": false, - "list_add_label": "Add More", - "name": "amount", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "float", - "value": 0 - }, - "attachment": { - "_input_type": "FileInput", - "advanced": false, - "api_editable": false, - "display_name": "Attachment", - "dynamic": false, - "fileTypes": [ - "pdf", - "png", - "jpg" - ], - "file_path": "", - "info": "String reference only for now (no upload yet)", - "list": false, - "list_add_label": "Add More", - "name": "attachment", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "temp_file": false, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "file", - "value": "" - }, - "code": { - "advanced": true, - "api_editable": false, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "code", - "value": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom typing import ClassVar\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.message import Message\n\n\ndef _text(value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs: ClassVar[list] = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs: ClassVar[list] = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except json.JSONDecodeError:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\n" - }, - "department": { - "_input_type": "DropdownInput", - "advanced": false, - "api_editable": false, - "combobox": false, - "dialog_inputs": {}, - "display_name": "Department", - "dynamic": false, - "external_options": {}, - "info": "Which team is this for", - "name": "department", - "options": [ - "Music", - "Youth", - "Seniors", - "Admin" - ], - "options_metadata": [], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "str", - "value": "Youth" - }, - "form_values": { - "_input_type": "MessageTextInput", - "advanced": false, - "api_editable": false, - "display_name": "Form values (JSON envelope)", - "dynamic": false, - "info": "Submitted values as JSON; merged over the defaults", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "form_values", - "override_skip": false, - "placeholder": "", - "required": false, - "show": false, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "full_name": { - "_input_type": "StrInput", - "advanced": false, - "api_editable": false, - "display_name": "Full name", - "dynamic": false, - "info": "Your first and last name", - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "full_name", - "override_skip": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "Hi there" - }, - "newsletter": { - "_input_type": "BoolInput", - "advanced": false, - "api_editable": false, - "display_name": "Newsletter", - "dynamic": false, - "info": "Send me the parish newsletter", - "list": false, - "list_add_label": "Add More", - "name": "newsletter", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": false - }, - "notes": { - "_input_type": "MultilineInput", - "advanced": false, - "ai_enabled": false, - "api_editable": false, - "copy_field": false, - "display_name": "Notes", - "dynamic": false, - "info": "Anything else we should know", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "multiline": true, - "name": "notes", - "override_skip": false, - "password": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "Blabla" - }, - "password": { - "_input_type": "SecretStrInput", - "advanced": false, - "api_editable": false, - "display_name": "Password", - "dynamic": false, - "info": "Optional access phrase", - "input_types": [], - "load_from_db": false, - "name": "password", - "override_skip": false, - "password": true, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "track_in_telemetry": false, - "type": "str", - "value": "hidden" - }, - "satisfaction": { - "_input_type": "SliderInput", - "advanced": false, - "api_editable": false, - "display_name": "Satisfaction", - "dynamic": false, - "info": "How satisfied are you (1-5)", - "max_label": "", - "max_label_icon": "", - "min_label": "", - "min_label_icon": "", - "name": "satisfaction", - "override_skip": false, - "placeholder": "", - "range_spec": { - "max": 5, - "min": 1, - "step": 1, - "step_type": "float" - }, - "required": false, - "show": true, - "slider_buttons": false, - "slider_buttons_options": [], - "slider_input": false, - "title_case": false, - "tool_mode": false, - "track_in_telemetry": false, - "type": "slider", - "value": 3 - }, - "topics": { - "_input_type": "MultiselectInput", - "advanced": false, - "api_editable": false, - "combobox": false, - "display_name": "Topics", - "dynamic": false, - "info": "What are you interested in", - "list": true, - "list_add_label": "Add More", - "name": "topics", - "options": [ - "Worship", - "Outreach", - "Courses" - ], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": [ - "Outreach", - "Courses" - ] - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "CustomInputForm" - }, - "dragging": false, - "id": "ext:kirche:CustomInputForm@extra-BhL6Q", - "measured": { - "height": 933, - "width": 320 - }, - "position": { - "x": -2869.215421770588, - "y": -259.29504008742293 - }, - "selected": true, - "type": "genericNode" - }, - { - "data": { - "id": "ChatOutput-HkK3i", - "node": { - "base_classes": [ - "Message" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Display a chat message in the Playground.", - "display_name": "Chat Output", - "documentation": "https://docs.langflow.org/chat-input-and-output", - "edited": false, - "field_order": [ - "input_value", - "should_store_message", - "sender", - "sender_name", - "session_id", - "context_id", - "data_template", - "clean_data" - ], - "frozen": false, - "icon": "MessagesSquare", - "legacy": false, - "lf_version": "1.12.0", - "metadata": { - "code_hash": "84009527d08c", - "dependencies": { - "dependencies": [ - { - "name": "orjson", - "version": "3.11.9" - }, - { - "name": "fastapi", - "version": "0.139.2" - }, - { - "name": "lfx", - "version": null - } - ], - "total_dependencies": 3 - }, - "module": "lfx.components.input_output.chat_output.ChatOutput" - }, - "minimized": true, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Output Message", - "group_outputs": false, - "method": "message_response", - "name": "message", - "selected": "Message", - "tool_mode": true, - "types": [ - "Message" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "clean_data": { - "_input_type": "BoolInput", - "advanced": true, - "api_editable": false, - "display_name": "Basic Clean Data", - "dynamic": false, - "info": "Whether to clean data before converting to string.", - "list": false, - "list_add_label": "Add More", - "name": "clean_data", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": true - }, - "code": { - "advanced": true, - "api_editable": false, - "dynamic": true, - "fileTypes": [], - "file_path": "", - "info": "", - "list": false, - "load_from_db": false, - "multiline": true, - "name": "code", - "password": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "code", - "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n" - }, - "context_id": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Context ID", - "dynamic": false, - "info": "The context ID of the chat. Adds an extra layer to the local memory.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "context_id", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "data_template": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Data Template", - "dynamic": false, - "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "data_template", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "{text}" - }, - "input_value": { - "_input_type": "HandleInput", - "advanced": false, - "api_editable": false, - "display_name": "Inputs", - "dynamic": false, - "info": "Message to be passed as output.", - "input_types": [ - "Data", - "JSON", - "DataFrame", - "Table", - "Message" - ], - "list": false, - "list_add_label": "Add More", - "name": "input_value", - "override_skip": false, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "other", - "value": "" - }, - "sender": { - "_input_type": "DropdownInput", - "advanced": true, - "api_editable": false, - "combobox": false, - "dialog_inputs": {}, - "display_name": "Sender Type", - "dynamic": false, - "external_options": {}, - "info": "Type of sender.", - "name": "sender", - "options": [ - "Machine", - "User" - ], - "options_metadata": [], - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "str", - "value": "Machine" - }, - "sender_name": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Sender Name", - "dynamic": false, - "info": "Name of the sender.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "sender_name", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "AI" - }, - "session_id": { - "_input_type": "MessageTextInput", - "advanced": true, - "api_editable": false, - "display_name": "Session ID", - "dynamic": false, - "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "session_id", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "track_in_telemetry": false, - "type": "str", - "value": "" - }, - "should_store_message": { - "_input_type": "BoolInput", - "advanced": true, - "api_editable": false, - "display_name": "Store Messages", - "dynamic": false, - "info": "Store the message in the history.", - "list": false, - "list_add_label": "Add More", - "name": "should_store_message", - "override_skip": false, - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "track_in_telemetry": true, - "type": "bool", - "value": true - } - }, - "tool_mode": false - }, - "showNode": true, - "type": "ChatOutput" - }, - "id": "ChatOutput-HkK3i", - "measured": { - "height": 168, - "width": 320 - }, - "position": { - "x": -2181.727435009515, - "y": 144.80068578973714 - }, - "selected": false, - "type": "genericNode" - } - ], - "viewport": { - "x": 1755.3131372405762, - "y": 167.2405536194492, - "zoom": 0.5755627009646302 - } - }, - "description": "The Pinnacle of Prompt Generation.", - "endpoint_name": null, - "id": "fd440bee-f669-44a2-aa12-6af3bc299731", - "is_component": false, - "name": "New Flow", - "tags": [], - "webhook": false -} diff --git a/backend/flows/test-flow.json b/backend/flows/test_flow.json similarity index 100% rename from backend/flows/test-flow.json rename to backend/flows/test_flow.json diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index 9b47657..f500575 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -5,11 +5,15 @@ import json from app.flows import export -def test_slugify(): - assert export.slugify("Dachsanierung 200k") == "dachsanierung-200k" - assert export.slugify(" A/B\\C ") == "a-b-c" - assert export.slugify("simple_rule") == "simple-rule" - assert export.slugify("") == "flow" +def test_filename_stem_keeps_original_names(): + assert export.filename_stem("simple_rule") == "simple_rule" + assert export.filename_stem("test_flow") == "test_flow" + assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung" + assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k" + assert export.filename_stem(" A/B\\C ") == "A-B-C" + assert export.filename_stem('a:b*c?d"eg|h') == "a-b-c-d-e-f-g-h" + assert export.filename_stem("trailing...") == "trailing" + assert export.filename_stem("") == "flow" def test_canonical_bytes_stable_under_key_reorder(): @@ -22,7 +26,7 @@ def test_canonical_bytes_stable_under_key_reorder(): def test_write_and_read_roundtrip_is_byte_stable(tmp_path): doc = {"name": "My Flow", "data": {"nodes": [], "edges": []}} path = export.write_flow_file(tmp_path, doc) - assert path == tmp_path / "my-flow.json" + assert path == tmp_path / "My Flow.json" assert json.loads(path.read_text()) == doc # Rewriting identical content is byte-identical (no phantom git diffs). before = path.read_bytes() @@ -64,3 +68,15 @@ def test_find_reuse_path_no_match_and_missing_dir(tmp_path): def test_find_reuse_path_skips_unreadable_files(tmp_path): (tmp_path / "kaputt.json").write_text("{kein json") assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False) + + +def test_write_flow_file_is_world_readable(tmp_path): + import os + import stat + + doc = {"name": "Lesbar", "data": {"nodes": [], "edges": []}} + path = export.write_flow_file(tmp_path, doc) + mode = stat.S_IMODE(os.stat(path).st_mode) + # Read bits for user/group/other (mkstemp defaults to 0o600; on Windows + # chmod maps to 0o666, so only assert the read bits, not the exact mode). + assert mode & 0o444 == 0o444, f"expected world-readable, got {oct(mode)}" diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index 5b74204..6c582b4 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -163,17 +163,17 @@ async def test_sync_endpoint_pulls_from_langflow_then_syncs( 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["pulled"] == ["Gehaltserhöhung"] + assert body["added"] == ["Gehaltserhöhung"] assert body["errors"] == {} - assert (tmp_path / "gehaltserh-hung.json").is_file() + 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"] + assert body["pulled"] == ["Gehaltserhöhung"] + assert body["skipped"] == ["Gehaltserhöhung"] async def test_sync_endpoint_pull_reuses_same_named_file( @@ -214,8 +214,8 @@ async def test_sync_endpoint_pull_error_continues( 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 body["pulled"] == ["Gut"] + assert body["added"] == ["Gut"] assert "kaputt-id" in body["errors"] diff --git a/frontend/src/components/FlowHelp.tsx b/frontend/src/components/FlowHelp.tsx index ed568cb..1b82010 100644 --- a/frontend/src/components/FlowHelp.tsx +++ b/frontend/src/components/FlowHelp.tsx @@ -62,8 +62,9 @@ export default function FlowHelp() { - Der Dateiname wird aus dem Flow-Namen erzeugt (Kleinbuchstaben, - Bindestriche). Umbenennen erzeugt eine neue Datei. + Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json). + Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt + bestehen und muss bei Bedarf gelöscht werden. Eine gelöschte Datei löscht den Flow inklusive seiner Läufe