diff --git a/.env.example b/.env.example index fe908795..ee2a838c 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ LANGFLOW_SECRET_KEY= LANGFLOW_CHAT_FLOW_ID=1098eea1-6649-4e1d-aed1-b77249fb8dd0 LANGFLOW_INGEST_FLOW_ID=5488df7c-b93f-4f87-a446-b67028bc0813 # Ingest flow using docling -LANGFLOW_INGEST_FLOW_ID=1402618b-e6d1-4ff2-9a11-d6ce71186915 +# LANGFLOW_INGEST_FLOW_ID=1402618b-e6d1-4ff2-9a11-d6ce71186915 NUDGES_FLOW_ID=ebc01d31-1976-46ce-a385-b0240327226c # Set a strong admin password for OpenSearch; a bcrypt hash is generated at diff --git a/flows/openrag_ingest_docling.json b/flows/openrag_ingest_docling.json index cd6d7d39..889f8425 100644 --- a/flows/openrag_ingest_docling.json +++ b/flows/openrag_ingest_docling.json @@ -30,34 +30,6 @@ "target": "OpenSearchHybrid-XtKoA", "targetHandle": "{œfieldNameœ:œingest_dataœ,œidœ:œOpenSearchHybrid-XtKoAœ,œinputTypesœ:[œDataœ,œDataFrameœ],œtypeœ:œotherœ}" }, - { - "animated": false, - "className": "", - "data": { - "sourceHandle": { - "dataType": "OpenAIEmbeddings", - "id": "OpenAIEmbeddings-mP45L", - "name": "embeddings", - "output_types": [ - "Embeddings" - ] - }, - "targetHandle": { - "fieldName": "embedding", - "id": "OpenSearchHybrid-XtKoA", - "inputTypes": [ - "Embeddings" - ], - "type": "other" - } - }, - "id": "reactflow__edge-OpenAIEmbeddings-mP45L{œdataTypeœ:œOpenAIEmbeddingsœ,œidœ:œOpenAIEmbeddings-mP45Lœ,œnameœ:œembeddingsœ,œoutput_typesœ:[œEmbeddingsœ]}-OpenSearchHybrid-XtKoA{œfieldNameœ:œembeddingœ,œidœ:œOpenSearchHybrid-XtKoAœ,œinputTypesœ:[œEmbeddingsœ],œtypeœ:œotherœ}", - "selected": false, - "source": "OpenAIEmbeddings-mP45L", - "sourceHandle": "{œdataTypeœ:œOpenAIEmbeddingsœ,œidœ:œOpenAIEmbeddings-mP45Lœ,œnameœ:œembeddingsœ,œoutput_typesœ:[œEmbeddingsœ]}", - "target": "OpenSearchHybrid-XtKoA", - "targetHandle": "{œfieldNameœ:œembeddingœ,œidœ:œOpenSearchHybrid-XtKoAœ,œinputTypesœ:[œEmbeddingsœ],œtypeœ:œotherœ}" - }, { "animated": false, "className": "", @@ -116,6 +88,34 @@ "sourceHandle": "{œdataTypeœ:œExportDoclingDocumentœ,œidœ:œExportDoclingDocument-xFoCIœ,œnameœ:œdataœ,œoutput_typesœ:[œDataœ]}", "target": "SplitText-3ZI5B", "targetHandle": "{œfieldNameœ:œdata_inputsœ,œidœ:œSplitText-3ZI5Bœ,œinputTypesœ:[œDataœ,œDataFrameœ,œMessageœ],œtypeœ:œotherœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "EmbeddingModel", + "id": "EmbeddingModel-cxG9r", + "name": "embeddings", + "output_types": [ + "Embeddings" + ] + }, + "targetHandle": { + "fieldName": "embedding", + "id": "OpenSearchHybrid-XtKoA", + "inputTypes": [ + "Embeddings" + ], + "type": "other" + } + }, + "id": "xy-edge__EmbeddingModel-cxG9r{œdataTypeœ:œEmbeddingModelœ,œidœ:œEmbeddingModel-cxG9rœ,œnameœ:œembeddingsœ,œoutput_typesœ:[œEmbeddingsœ]}-OpenSearchHybrid-XtKoA{œfieldNameœ:œembeddingœ,œidœ:œOpenSearchHybrid-XtKoAœ,œinputTypesœ:[œEmbeddingsœ],œtypeœ:œotherœ}", + "selected": false, + "source": "EmbeddingModel-cxG9r", + "sourceHandle": "{œdataTypeœ:œEmbeddingModelœ,œidœ:œEmbeddingModel-cxG9rœ,œnameœ:œembeddingsœ,œoutput_typesœ:[œEmbeddingsœ]}", + "target": "OpenSearchHybrid-XtKoA", + "targetHandle": "{œfieldNameœ:œembeddingœ,œidœ:œOpenSearchHybrid-XtKoAœ,œinputTypesœ:[œEmbeddingsœ],œtypeœ:œotherœ}" } ], "nodes": [ @@ -361,585 +361,6 @@ "type": "genericNode", "width": 320 }, - { - "data": { - "id": "OpenAIEmbeddings-mP45L", - "node": { - "base_classes": [ - "Embeddings" - ], - "beta": false, - "conditional_paths": [], - "custom_fields": {}, - "description": "Generate embeddings using OpenAI models.", - "display_name": "OpenAI Embeddings", - "documentation": "", - "edited": false, - "field_order": [ - "default_headers", - "default_query", - "chunk_size", - "client", - "deployment", - "embedding_ctx_length", - "max_retries", - "model", - "model_kwargs", - "openai_api_key", - "openai_api_base", - "openai_api_type", - "openai_api_version", - "openai_organization", - "openai_proxy", - "request_timeout", - "show_progress_bar", - "skip_empty", - "tiktoken_model_name", - "tiktoken_enable", - "dimensions" - ], - "frozen": false, - "icon": "OpenAI", - "legacy": false, - "metadata": { - "code_hash": "8a658ed6d4c9", - "dependencies": { - "dependencies": [ - { - "name": "langchain_openai", - "version": "0.3.23" - }, - { - "name": "lfx", - "version": null - } - ], - "total_dependencies": 2 - }, - "module": "custom_components.openai_embeddings" - }, - "minimized": false, - "output_types": [], - "outputs": [ - { - "allows_loop": false, - "cache": true, - "display_name": "Embedding Model", - "group_outputs": false, - "method": "build_embeddings", - "name": "embeddings", - "options": null, - "required_inputs": null, - "selected": "Embeddings", - "tool_mode": true, - "types": [ - "Embeddings" - ], - "value": "__UNDEFINED__" - } - ], - "pinned": false, - "template": { - "_type": "Component", - "chunk_size": { - "_input_type": "IntInput", - "advanced": true, - "display_name": "Chunk Size", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "chunk_size", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "int", - "value": 1000 - }, - "client": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "Client", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "client", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "code": { - "advanced": true, - "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 langchain_openai import OpenAIEmbeddings\n\nfrom lfx.base.embeddings.model import LCEmbeddingsModel\nfrom lfx.base.models.openai_constants import OPENAI_EMBEDDING_MODEL_NAMES\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import BoolInput, DictInput, DropdownInput, FloatInput, IntInput, MessageTextInput, SecretStrInput\n\n\nclass OpenAIEmbeddingsComponent(LCEmbeddingsModel):\n display_name = \"OpenAI Embeddings\"\n description = \"Generate embeddings using OpenAI models.\"\n icon = \"OpenAI\"\n name = \"OpenAIEmbeddings\"\n\n inputs = [\n DictInput(\n name=\"default_headers\",\n display_name=\"Default Headers\",\n advanced=True,\n info=\"Default headers to use for the API request.\",\n ),\n DictInput(\n name=\"default_query\",\n display_name=\"Default Query\",\n advanced=True,\n info=\"Default query parameters to use for the API request.\",\n ),\n IntInput(name=\"chunk_size\", display_name=\"Chunk Size\", advanced=True, value=1000),\n MessageTextInput(name=\"client\", display_name=\"Client\", advanced=True),\n MessageTextInput(name=\"deployment\", display_name=\"Deployment\", advanced=True),\n IntInput(name=\"embedding_ctx_length\", display_name=\"Embedding Context Length\", advanced=True, value=1536),\n IntInput(name=\"max_retries\", display_name=\"Max Retries\", value=3, advanced=True),\n DropdownInput(\n name=\"model\",\n display_name=\"Model\",\n advanced=False,\n options=OPENAI_EMBEDDING_MODEL_NAMES,\n value=\"text-embedding-3-small\",\n ),\n DictInput(name=\"model_kwargs\", display_name=\"Model Kwargs\", advanced=True),\n SecretStrInput(name=\"openai_api_key\", display_name=\"OpenAI API Key\", value=\"OPENAI_API_KEY\", required=True),\n MessageTextInput(name=\"openai_api_base\", display_name=\"OpenAI API Base\", advanced=True),\n MessageTextInput(name=\"openai_api_type\", display_name=\"OpenAI API Type\", advanced=True),\n MessageTextInput(name=\"openai_api_version\", display_name=\"OpenAI API Version\", advanced=True),\n MessageTextInput(\n name=\"openai_organization\",\n display_name=\"OpenAI Organization\",\n advanced=True,\n ),\n MessageTextInput(name=\"openai_proxy\", display_name=\"OpenAI Proxy\", advanced=True),\n FloatInput(name=\"request_timeout\", display_name=\"Request Timeout\", advanced=True),\n BoolInput(name=\"show_progress_bar\", display_name=\"Show Progress Bar\", advanced=True),\n BoolInput(name=\"skip_empty\", display_name=\"Skip Empty\", advanced=True),\n MessageTextInput(\n name=\"tiktoken_model_name\",\n display_name=\"TikToken Model Name\",\n advanced=True,\n ),\n BoolInput(\n name=\"tiktoken_enable\",\n display_name=\"TikToken Enable\",\n advanced=True,\n value=True,\n info=\"If False, you must have transformers installed.\",\n ),\n IntInput(\n name=\"dimensions\",\n display_name=\"Dimensions\",\n info=\"The number of dimensions the resulting output embeddings should have. \"\n \"Only supported by certain models.\",\n advanced=True,\n ),\n ]\n\n def build_embeddings(self) -> Embeddings:\n return OpenAIEmbeddings(\n client=self.client or None,\n model=self.model,\n dimensions=self.dimensions or None,\n deployment=self.deployment or None,\n api_version=self.openai_api_version or None,\n base_url=self.openai_api_base or None,\n openai_api_type=self.openai_api_type or None,\n openai_proxy=self.openai_proxy or None,\n embedding_ctx_length=self.embedding_ctx_length,\n api_key=self.openai_api_key or None,\n organization=self.openai_organization or None,\n allowed_special=\"all\",\n disallowed_special=\"all\",\n chunk_size=self.chunk_size,\n max_retries=self.max_retries,\n timeout=self.request_timeout or None,\n tiktoken_enabled=self.tiktoken_enable,\n tiktoken_model_name=self.tiktoken_model_name or None,\n show_progress_bar=self.show_progress_bar,\n model_kwargs=self.model_kwargs,\n skip_empty=self.skip_empty,\n default_headers=self.default_headers or None,\n default_query=self.default_query or None,\n )\n" - }, - "default_headers": { - "_input_type": "DictInput", - "advanced": true, - "display_name": "Default Headers", - "dynamic": false, - "info": "Default headers to use for the API request.", - "list": false, - "list_add_label": "Add More", - "name": "default_headers", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "type": "dict", - "value": {} - }, - "default_query": { - "_input_type": "DictInput", - "advanced": true, - "display_name": "Default Query", - "dynamic": false, - "info": "Default query parameters to use for the API request.", - "list": false, - "list_add_label": "Add More", - "name": "default_query", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "type": "dict", - "value": {} - }, - "deployment": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "Deployment", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "deployment", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "dimensions": { - "_input_type": "IntInput", - "advanced": true, - "display_name": "Dimensions", - "dynamic": false, - "info": "The number of dimensions the resulting output embeddings should have. Only supported by certain models.", - "list": false, - "list_add_label": "Add More", - "name": "dimensions", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "int", - "value": "" - }, - "embedding_ctx_length": { - "_input_type": "IntInput", - "advanced": true, - "display_name": "Embedding Context Length", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "embedding_ctx_length", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "int", - "value": 1536 - }, - "max_retries": { - "_input_type": "IntInput", - "advanced": true, - "display_name": "Max Retries", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "max_retries", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "int", - "value": 3 - }, - "model": { - "_input_type": "DropdownInput", - "advanced": false, - "combobox": false, - "dialog_inputs": {}, - "display_name": "Model", - "dynamic": false, - "info": "", - "name": "model", - "options": [ - "text-embedding-3-small", - "text-embedding-3-large", - "text-embedding-ada-002" - ], - "options_metadata": [], - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "toggle": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "str", - "value": "text-embedding-3-small" - }, - "model_kwargs": { - "_input_type": "DictInput", - "advanced": true, - "display_name": "Model Kwargs", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "model_kwargs", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "type": "dict", - "value": {} - }, - "openai_api_base": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "OpenAI API Base", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "openai_api_base", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "openai_api_key": { - "_input_type": "SecretStrInput", - "advanced": false, - "display_name": "OpenAI API Key", - "dynamic": false, - "info": "", - "input_types": [], - "load_from_db": false, - "name": "openai_api_key", - "password": true, - "placeholder": "", - "required": true, - "show": true, - "title_case": false, - "type": "str", - "value": "" - }, - "openai_api_type": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "OpenAI API Type", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "openai_api_type", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "openai_api_version": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "OpenAI API Version", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "openai_api_version", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "openai_organization": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "OpenAI Organization", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "openai_organization", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "openai_proxy": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "OpenAI Proxy", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "openai_proxy", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - }, - "request_timeout": { - "_input_type": "FloatInput", - "advanced": true, - "display_name": "Request Timeout", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "request_timeout", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "float", - "value": "" - }, - "show_progress_bar": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Show Progress Bar", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "show_progress_bar", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "bool", - "value": false - }, - "skip_empty": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "Skip Empty", - "dynamic": false, - "info": "", - "list": false, - "list_add_label": "Add More", - "name": "skip_empty", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "bool", - "value": false - }, - "tiktoken_enable": { - "_input_type": "BoolInput", - "advanced": true, - "display_name": "TikToken Enable", - "dynamic": false, - "info": "If False, you must have transformers installed.", - "list": false, - "list_add_label": "Add More", - "name": "tiktoken_enable", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_metadata": true, - "type": "bool", - "value": true - }, - "tiktoken_model_name": { - "_input_type": "MessageTextInput", - "advanced": true, - "display_name": "TikToken Model Name", - "dynamic": false, - "info": "", - "input_types": [ - "Message" - ], - "list": false, - "list_add_label": "Add More", - "load_from_db": false, - "name": "tiktoken_model_name", - "placeholder": "", - "required": false, - "show": true, - "title_case": false, - "tool_mode": false, - "trace_as_input": true, - "trace_as_metadata": true, - "type": "str", - "value": "" - } - }, - "tool_mode": false - }, - "selected_output": "embeddings", - "type": "OpenAIEmbeddings" - }, - "dragging": false, - "height": 320, - "id": "OpenAIEmbeddings-mP45L", - "measured": { - "height": 320, - "width": 320 - }, - "position": { - "x": 1704.8491676318172, - "y": 1879.144249471858 - }, - "positionAbsolute": { - "x": 1690.9220896443658, - "y": 1866.483269483266 - }, - "selected": false, - "type": "genericNode", - "width": 320 - }, - { - "data": { - "id": "note-59mzY", - "node": { - "description": "### 💡 Add your OpenAI API key here 👇", - "display_name": "", - "documentation": "", - "template": { - "backgroundColor": "transparent" - } - }, - "type": "note" - }, - "dragging": false, - "height": 324, - "id": "note-59mzY", - "measured": { - "height": 324, - "width": 324 - }, - "position": { - "x": 1692.2322233423606, - "y": 1821.9077961087607 - }, - "positionAbsolute": { - "x": 1692.2322233423606, - "y": 1821.9077961087607 - }, - "selected": false, - "type": "noteNode", - "width": 324 - }, { "data": { "id": "OpenSearchHybrid-XtKoA", @@ -1327,7 +748,7 @@ "dynamic": false, "info": "Paste a valid JWT (sent as a header).", "input_types": [], - "load_from_db": false, + "load_from_db": true, "name": "jwt_token", "password": true, "placeholder": "", @@ -1562,7 +983,7 @@ "dragging": false, "id": "OpenSearchHybrid-XtKoA", "measured": { - "height": 765, + "height": 760, "width": 320 }, "position": { @@ -1574,6 +995,8 @@ }, { "data": { + "description": "Uses Docling to process input documents connecting to your instance of Docling Serve.", + "display_name": "Docling Serve", "id": "DoclingRemote-78KoX", "node": { "base_classes": [ @@ -1603,9 +1026,8 @@ "frozen": false, "icon": "Docling", "legacy": false, - "lf_version": "1.6.0", "metadata": { - "code_hash": "930312ffe40c", + "code_hash": "880538860431", "dependencies": { "dependencies": [ { @@ -1621,13 +1043,13 @@ "version": "2.10.6" }, { - "name": "lfx", + "name": "langflow", "version": null } ], "total_dependencies": 4 }, - "module": "lfx.components.docling.docling_remote.DoclingRemoteComponent" + "module": "custom_components.docling_serve" }, "minimized": false, "output_types": [], @@ -1639,6 +1061,8 @@ "group_outputs": false, "method": "load_files", "name": "dataframe", + "options": null, + "required_inputs": null, "selected": "DataFrame", "tool_mode": true, "types": [ @@ -1704,7 +1128,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import base64\nimport time\nfrom concurrent.futures import Future, ThreadPoolExecutor\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom docling_core.types.doc import DoclingDocument\nfrom pydantic import ValidationError\n\nfrom lfx.base.data import BaseFileComponent\nfrom lfx.inputs import IntInput, NestedDictInput, StrInput\nfrom lfx.inputs.inputs import FloatInput\nfrom lfx.schema import Data\n\n\nclass DoclingRemoteComponent(BaseFileComponent):\n display_name = \"Docling Serve\"\n description = \"Uses Docling to process input documents connecting to your instance of Docling Serve.\"\n documentation = \"https://docling-project.github.io/docling/\"\n trace_type = \"tool\"\n icon = \"Docling\"\n name = \"DoclingRemote\"\n\n MAX_500_RETRIES = 5\n\n # https://docling-project.github.io/docling/usage/supported_formats/\n VALID_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"csv\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"docx\",\n \"htm\",\n \"html\",\n \"jpeg\",\n \"json\",\n \"md\",\n \"pdf\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"txt\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"xml\",\n \"webp\",\n ]\n\n inputs = [\n *BaseFileComponent.get_base_inputs(),\n StrInput(\n name=\"api_url\",\n display_name=\"Server address\",\n info=\"URL of the Docling Serve instance.\",\n required=True,\n ),\n IntInput(\n name=\"max_concurrency\",\n display_name=\"Concurrency\",\n info=\"Maximum number of concurrent requests for the server.\",\n advanced=True,\n value=2,\n ),\n FloatInput(\n name=\"max_poll_timeout\",\n display_name=\"Maximum poll time\",\n info=\"Maximum waiting time for the document conversion to complete.\",\n advanced=True,\n value=3600,\n ),\n NestedDictInput(\n name=\"api_headers\",\n display_name=\"HTTP headers\",\n advanced=True,\n required=False,\n info=(\"Optional dictionary of additional headers required for connecting to Docling Serve.\"),\n ),\n NestedDictInput(\n name=\"docling_serve_opts\",\n display_name=\"Docling options\",\n advanced=True,\n required=False,\n info=(\n \"Optional dictionary of additional options. \"\n \"See https://github.com/docling-project/docling-serve/blob/main/docs/usage.md for more information.\"\n ),\n ),\n ]\n\n outputs = [\n *BaseFileComponent.get_base_outputs(),\n ]\n\n def process_files(self, file_list: list[BaseFileComponent.BaseFile]) -> list[BaseFileComponent.BaseFile]:\n base_url = f\"{self.api_url}/v1\"\n\n def _convert_document(client: httpx.Client, file_path: Path, options: dict[str, Any]) -> Data | None:\n encoded_doc = base64.b64encode(file_path.read_bytes()).decode()\n payload = {\n \"options\": options,\n \"sources\": [{\"kind\": \"file\", \"base64_string\": encoded_doc, \"filename\": file_path.name}],\n }\n\n response = client.post(f\"{base_url}/convert/source/async\", json=payload)\n response.raise_for_status()\n task = response.json()\n\n http_failures = 0\n retry_status_start = 500\n retry_status_end = 600\n start_wait_time = time.monotonic()\n while task[\"task_status\"] not in (\"success\", \"failure\"):\n # Check if processing exceeds the maximum poll timeout\n processing_time = time.monotonic() - start_wait_time\n if processing_time >= self.max_poll_timeout:\n msg = (\n f\"Processing time {processing_time=} exceeds the maximum poll timeout {self.max_poll_timeout=}.\"\n \"Please increase the max_poll_timeout parameter or review why the processing \"\n \"takes long on the server.\"\n )\n self.log(msg)\n raise RuntimeError(msg)\n\n # Call for a new status update\n time.sleep(2)\n response = client.get(f\"{base_url}/status/poll/{task['task_id']}\")\n\n # Check if the status call gets into 5xx errors and retry\n if retry_status_start <= response.status_code < retry_status_end:\n http_failures += 1\n if http_failures > self.MAX_500_RETRIES:\n self.log(f\"The status requests got a http response {response.status_code} too many times.\")\n return None\n continue\n\n # Update task status\n task = response.json()\n\n result_resp = client.get(f\"{base_url}/result/{task['task_id']}\")\n result_resp.raise_for_status()\n result = result_resp.json()\n\n if \"json_content\" not in result[\"document\"] or result[\"document\"][\"json_content\"] is None:\n self.log(\"No JSON DoclingDocument found in the result.\")\n return None\n\n try:\n doc = DoclingDocument.model_validate(result[\"document\"][\"json_content\"])\n return Data(data={\"doc\": doc, \"file_path\": str(file_path)})\n except ValidationError as e:\n self.log(f\"Error validating the document. {e}\")\n return None\n\n docling_options = {\n \"to_formats\": [\"json\"],\n \"image_export_mode\": \"placeholder\",\n **(self.docling_serve_opts or {}),\n }\n\n processed_data: list[Data | None] = []\n with (\n httpx.Client(headers=self.api_headers) as client,\n ThreadPoolExecutor(max_workers=self.max_concurrency) as executor,\n ):\n futures: list[tuple[int, Future]] = []\n for i, file in enumerate(file_list):\n if file.path is None:\n processed_data.append(None)\n continue\n\n futures.append((i, executor.submit(_convert_document, client, file.path, docling_options)))\n\n for _index, future in futures:\n try:\n result_data = future.result()\n processed_data.append(result_data)\n except (httpx.HTTPStatusError, httpx.RequestError, KeyError, ValueError) as exc:\n self.log(f\"Docling remote processing failed: {exc}\")\n raise\n\n return self.rollup_data(file_list, processed_data)\n" + "value": "import base64\nimport time\nfrom concurrent.futures import Future, ThreadPoolExecutor\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\nfrom docling_core.types.doc import DoclingDocument\nfrom pydantic import ValidationError\n\nfrom langflow.base.data import BaseFileComponent\nfrom langflow.inputs import IntInput, NestedDictInput, StrInput\nfrom langflow.inputs.inputs import FloatInput\nfrom langflow.schema import Data\n\n\nclass DoclingRemoteComponent(BaseFileComponent):\n display_name = \"Docling Serve\"\n description = \"Uses Docling to process input documents connecting to your instance of Docling Serve.\"\n documentation = \"https://docling-project.github.io/docling/\"\n trace_type = \"tool\"\n icon = \"Docling\"\n name = \"DoclingRemote\"\n\n MAX_500_RETRIES = 5\n\n # https://docling-project.github.io/docling/usage/supported_formats/\n VALID_EXTENSIONS = [\n \"adoc\",\n \"asciidoc\",\n \"asc\",\n \"bmp\",\n \"csv\",\n \"dotx\",\n \"dotm\",\n \"docm\",\n \"docx\",\n \"htm\",\n \"html\",\n \"jpeg\",\n \"json\",\n \"md\",\n \"pdf\",\n \"png\",\n \"potx\",\n \"ppsx\",\n \"pptm\",\n \"potm\",\n \"ppsm\",\n \"pptx\",\n \"tiff\",\n \"txt\",\n \"xls\",\n \"xlsx\",\n \"xhtml\",\n \"xml\",\n \"webp\",\n ]\n\n inputs = [\n *BaseFileComponent._base_inputs,\n StrInput(\n name=\"api_url\",\n display_name=\"Server address\",\n info=\"URL of the Docling Serve instance.\",\n required=True,\n ),\n IntInput(\n name=\"max_concurrency\",\n display_name=\"Concurrency\",\n info=\"Maximum number of concurrent requests for the server.\",\n advanced=True,\n value=2,\n ),\n FloatInput(\n name=\"max_poll_timeout\",\n display_name=\"Maximum poll time\",\n info=\"Maximum waiting time for the document conversion to complete.\",\n advanced=True,\n value=3600,\n ),\n NestedDictInput(\n name=\"api_headers\",\n display_name=\"HTTP headers\",\n advanced=True,\n required=False,\n info=(\"Optional dictionary of additional headers required for connecting to Docling Serve.\"),\n ),\n NestedDictInput(\n name=\"docling_serve_opts\",\n display_name=\"Docling options\",\n advanced=True,\n required=False,\n info=(\n \"Optional dictionary of additional options. \"\n \"See https://github.com/docling-project/docling-serve/blob/main/docs/usage.md for more information.\"\n ),\n ),\n ]\n\n outputs = [\n *BaseFileComponent._base_outputs,\n ]\n\n def process_files(self, file_list: list[BaseFileComponent.BaseFile]) -> list[BaseFileComponent.BaseFile]:\n base_url = f\"{self.api_url}/v1alpha\"\n\n def _convert_document(client: httpx.Client, file_path: Path, options: dict[str, Any]) -> Data | None:\n encoded_doc = base64.b64encode(file_path.read_bytes()).decode()\n payload = {\n \"options\": options,\n \"file_sources\": [{\"base64_string\": encoded_doc, \"filename\": file_path.name}],\n }\n\n response = client.post(f\"{base_url}/convert/source/async\", json=payload)\n response.raise_for_status()\n task = response.json()\n\n http_failures = 0\n retry_status_start = 500\n retry_status_end = 600\n start_wait_time = time.monotonic()\n while task[\"task_status\"] not in (\"success\", \"failure\"):\n # Check if processing exceeds the maximum poll timeout\n processing_time = time.monotonic() - start_wait_time\n if processing_time >= self.max_poll_timeout:\n msg = (\n f\"Processing time {processing_time=} exceeds the maximum poll timeout {self.max_poll_timeout=}.\"\n \"Please increase the max_poll_timeout parameter or review why the processing \"\n \"takes long on the server.\"\n )\n self.log(msg)\n raise RuntimeError(msg)\n\n # Call for a new status update\n time.sleep(2)\n response = client.get(f\"{base_url}/status/poll/{task['task_id']}\")\n\n # Check if the status call gets into 5xx errors and retry\n if retry_status_start <= response.status_code < retry_status_end:\n http_failures += 1\n if http_failures > self.MAX_500_RETRIES:\n self.log(f\"The status requests got a http response {response.status_code} too many times.\")\n return None\n continue\n\n # Update task status\n task = response.json()\n\n result_resp = client.get(f\"{base_url}/result/{task['task_id']}\")\n result_resp.raise_for_status()\n result = result_resp.json()\n\n if \"json_content\" not in result[\"document\"] or result[\"document\"][\"json_content\"] is None:\n self.log(\"No JSON DoclingDocument found in the result.\")\n return None\n\n try:\n doc = DoclingDocument.model_validate(result[\"document\"][\"json_content\"])\n return Data(data={\"doc\": doc, \"file_path\": str(file_path)})\n except ValidationError as e:\n self.log(f\"Error validating the document. {e}\")\n return None\n\n docling_options = {\n \"to_formats\": [\"json\"],\n \"image_export_mode\": \"placeholder\",\n \"return_as_file\": False,\n **(self.docling_serve_opts or {}),\n }\n\n processed_data: list[Data | None] = []\n with (\n httpx.Client(headers=self.api_headers) as client,\n ThreadPoolExecutor(max_workers=self.max_concurrency) as executor,\n ):\n futures: list[tuple[int, Future]] = []\n for i, file in enumerate(file_list):\n if file.path is None:\n processed_data.append(None)\n continue\n\n futures.append((i, executor.submit(_convert_document, client, file.path, docling_options)))\n\n for _index, future in futures:\n try:\n result_data = future.result()\n processed_data.append(result_data)\n except (httpx.HTTPStatusError, httpx.RequestError, KeyError, ValueError) as exc:\n self.log(f\"Docling remote processing failed: {exc}\")\n raise\n\n return self.rollup_data(file_list, processed_data)\n" }, "delete_server_file_after_processing": { "_input_type": "BoolInput", @@ -1732,6 +1156,7 @@ "info": "Optional dictionary of additional options. See https://github.com/docling-project/docling-serve/blob/main/docs/usage.md for more information.", "list": false, "list_add_label": "Add More", + "load_from_db": false, "name": "docling_serve_opts", "placeholder": "", "required": false, @@ -1939,18 +1364,20 @@ "dragging": false, "id": "DoclingRemote-78KoX", "measured": { - "height": 475, + "height": 472, "width": 320 }, "position": { "x": 974.2998232996713, "y": 1337.9345348080217 }, - "selected": true, + "selected": false, "type": "genericNode" }, { "data": { + "description": "Export DoclingDocument to markdown, html or other formats.", + "display_name": "Export DoclingDocument", "id": "ExportDoclingDocument-xFoCI", "node": { "base_classes": [ @@ -1975,9 +1402,8 @@ "frozen": false, "icon": "Docling", "legacy": false, - "lf_version": "1.6.0", "metadata": { - "code_hash": "4de16ddd37ac", + "code_hash": "451c9673bd4c", "dependencies": { "dependencies": [ { @@ -1985,13 +1411,13 @@ "version": "2.45.0" }, { - "name": "lfx", + "name": "langflow", "version": null } ], "total_dependencies": 2 }, - "module": "lfx.components.docling.export_docling_document.ExportDoclingDocumentComponent" + "module": "custom_components.export_doclingdocument" }, "minimized": false, "output_types": [], @@ -2003,6 +1429,8 @@ "group_outputs": false, "method": "export_document", "name": "data", + "options": null, + "required_inputs": null, "selected": "Data", "tool_mode": true, "types": [ @@ -2017,6 +1445,9 @@ "group_outputs": false, "method": "as_dataframe", "name": "dataframe", + "options": null, + "required_inputs": null, + "selected": "DataFrame", "tool_mode": true, "types": [ "DataFrame" @@ -2043,7 +1474,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from typing import Any\n\nfrom docling_core.types.doc import ImageRefMode\n\nfrom lfx.base.data.docling_utils import extract_docling_documents\nfrom lfx.custom import Component\nfrom lfx.io import DropdownInput, HandleInput, MessageTextInput, Output, StrInput\nfrom lfx.schema import Data, DataFrame\n\n\nclass ExportDoclingDocumentComponent(Component):\n display_name: str = \"Export DoclingDocument\"\n description: str = \"Export DoclingDocument to markdown, html or other formats.\"\n documentation = \"https://docling-project.github.io/docling/\"\n icon = \"Docling\"\n name = \"ExportDoclingDocument\"\n\n inputs = [\n HandleInput(\n name=\"data_inputs\",\n display_name=\"Data or DataFrame\",\n info=\"The data with documents to export.\",\n input_types=[\"Data\", \"DataFrame\"],\n required=True,\n ),\n DropdownInput(\n name=\"export_format\",\n display_name=\"Export format\",\n options=[\"Markdown\", \"HTML\", \"Plaintext\", \"DocTags\"],\n info=\"Select the export format to convert the input.\",\n value=\"Markdown\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"image_mode\",\n display_name=\"Image export mode\",\n options=[\"placeholder\", \"embedded\"],\n info=(\n \"Specify how images are exported in the output. Placeholder will replace the images with a string, \"\n \"whereas Embedded will include them as base64 encoded images.\"\n ),\n value=\"placeholder\",\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"\",\n advanced=True,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder betweek pages in the markdown output.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Exported data\", name=\"data\", method=\"export_document\"),\n Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:\n if field_name == \"export_format\" and field_value == \"Markdown\":\n build_config[\"md_image_placeholder\"][\"show\"] = True\n build_config[\"md_page_break_placeholder\"][\"show\"] = True\n build_config[\"image_mode\"][\"show\"] = True\n elif field_name == \"export_format\" and field_value == \"HTML\":\n build_config[\"md_image_placeholder\"][\"show\"] = False\n build_config[\"md_page_break_placeholder\"][\"show\"] = False\n build_config[\"image_mode\"][\"show\"] = True\n elif field_name == \"export_format\" and field_value in {\"Plaintext\", \"DocTags\"}:\n build_config[\"md_image_placeholder\"][\"show\"] = False\n build_config[\"md_page_break_placeholder\"][\"show\"] = False\n build_config[\"image_mode\"][\"show\"] = False\n\n return build_config\n\n def export_document(self) -> list[Data]:\n documents = extract_docling_documents(self.data_inputs, self.doc_key)\n\n results: list[Data] = []\n try:\n image_mode = ImageRefMode(self.image_mode)\n for doc in documents:\n content = \"\"\n if self.export_format == \"Markdown\":\n content = doc.export_to_markdown(\n image_mode=image_mode,\n image_placeholder=self.md_image_placeholder,\n page_break_placeholder=self.md_page_break_placeholder,\n )\n elif self.export_format == \"HTML\":\n content = doc.export_to_html(image_mode=image_mode)\n elif self.export_format == \"Plaintext\":\n content = doc.export_to_text()\n elif self.export_format == \"DocTags\":\n content = doc.export_to_doctags()\n\n results.append(Data(text=content))\n except Exception as e:\n msg = f\"Error splitting text: {e}\"\n raise TypeError(msg) from e\n\n return results\n\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.export_document())\n" + "value": "from typing import Any\n\nfrom docling_core.types.doc import ImageRefMode\n\nfrom langflow.base.data.docling_utils import extract_docling_documents\nfrom langflow.custom import Component\nfrom langflow.io import DropdownInput, HandleInput, MessageTextInput, Output, StrInput\nfrom langflow.schema import Data, DataFrame\n\n\nclass ExportDoclingDocumentComponent(Component):\n display_name: str = \"Export DoclingDocument\"\n description: str = \"Export DoclingDocument to markdown, html or other formats.\"\n documentation = \"https://docling-project.github.io/docling/\"\n icon = \"Docling\"\n name = \"ExportDoclingDocument\"\n\n inputs = [\n HandleInput(\n name=\"data_inputs\",\n display_name=\"Data or DataFrame\",\n info=\"The data with documents to export.\",\n input_types=[\"Data\", \"DataFrame\"],\n required=True,\n ),\n DropdownInput(\n name=\"export_format\",\n display_name=\"Export format\",\n options=[\"Markdown\", \"HTML\", \"Plaintext\", \"DocTags\"],\n info=\"Select the export format to convert the input.\",\n value=\"Markdown\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"image_mode\",\n display_name=\"Image export mode\",\n options=[\"placeholder\", \"embedded\"],\n info=(\n \"Specify how images are exported in the output. Placeholder will replace the images with a string, \"\n \"whereas Embedded will include them as base64 encoded images.\"\n ),\n value=\"placeholder\",\n ),\n StrInput(\n name=\"md_image_placeholder\",\n display_name=\"Image placeholder\",\n info=\"Specify the image placeholder for markdown exports.\",\n value=\"\",\n advanced=True,\n ),\n StrInput(\n name=\"md_page_break_placeholder\",\n display_name=\"Page break placeholder\",\n info=\"Add this placeholder betweek pages in the markdown output.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"doc_key\",\n display_name=\"Doc Key\",\n info=\"The key to use for the DoclingDocument column.\",\n value=\"doc\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Exported data\", name=\"data\", method=\"export_document\"),\n Output(display_name=\"DataFrame\", name=\"dataframe\", method=\"as_dataframe\"),\n ]\n\n def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:\n if field_name == \"export_format\" and field_value == \"Markdown\":\n build_config[\"md_image_placeholder\"][\"show\"] = True\n build_config[\"md_page_break_placeholder\"][\"show\"] = True\n build_config[\"image_mode\"][\"show\"] = True\n elif field_name == \"export_format\" and field_value == \"HTML\":\n build_config[\"md_image_placeholder\"][\"show\"] = False\n build_config[\"md_page_break_placeholder\"][\"show\"] = False\n build_config[\"image_mode\"][\"show\"] = True\n elif field_name == \"export_format\" and field_value in {\"Plaintext\", \"DocTags\"}:\n build_config[\"md_image_placeholder\"][\"show\"] = False\n build_config[\"md_page_break_placeholder\"][\"show\"] = False\n build_config[\"image_mode\"][\"show\"] = False\n\n return build_config\n\n def export_document(self) -> list[Data]:\n documents = extract_docling_documents(self.data_inputs, self.doc_key)\n\n results: list[Data] = []\n try:\n image_mode = ImageRefMode(self.image_mode)\n for doc in documents:\n content = \"\"\n if self.export_format == \"Markdown\":\n content = doc.export_to_markdown(\n image_mode=image_mode,\n image_placeholder=self.md_image_placeholder,\n page_break_placeholder=self.md_page_break_placeholder,\n )\n elif self.export_format == \"HTML\":\n content = doc.export_to_html(image_mode=image_mode)\n elif self.export_format == \"Plaintext\":\n content = doc.export_to_text()\n elif self.export_format == \"DocTags\":\n content = doc.export_to_doctags()\n\n results.append(Data(text=content))\n except Exception as e:\n msg = f\"Error splitting text: {e}\"\n raise TypeError(msg) from e\n\n return results\n\n def as_dataframe(self) -> DataFrame:\n return DataFrame(self.export_document())\n" }, "data_inputs": { "_input_type": "HandleInput", @@ -2188,7 +1619,7 @@ "dragging": false, "id": "ExportDoclingDocument-xFoCI", "measured": { - "height": 347, + "height": 344, "width": 320 }, "position": { @@ -2197,19 +1628,328 @@ }, "selected": false, "type": "genericNode" + }, + { + "data": { + "id": "EmbeddingModel-cxG9r", + "node": { + "base_classes": [ + "Embeddings" + ], + "beta": false, + "conditional_paths": [], + "custom_fields": {}, + "description": "Generate embeddings using a specified provider.", + "display_name": "Embedding Model", + "documentation": "https://docs.langflow.org/components-embedding-models", + "edited": false, + "field_order": [ + "provider", + "model", + "api_key", + "api_base", + "dimensions", + "chunk_size", + "request_timeout", + "max_retries", + "show_progress_bar", + "model_kwargs" + ], + "frozen": false, + "icon": "binary", + "last_updated": "2025-09-24T16:02:07.998Z", + "legacy": false, + "metadata": { + "code_hash": "93faf11517da", + "dependencies": { + "dependencies": [ + { + "name": "langchain_openai", + "version": "0.3.23" + }, + { + "name": "langflow", + "version": null + } + ], + "total_dependencies": 2 + }, + "module": "langflow.components.models.embedding_model.EmbeddingModelComponent" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Embedding Model", + "group_outputs": false, + "method": "build_embeddings", + "name": "embeddings", + "options": null, + "required_inputs": null, + "selected": "Embeddings", + "tool_mode": true, + "types": [ + "Embeddings" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "api_base": { + "_input_type": "MessageTextInput", + "advanced": true, + "display_name": "API Base URL", + "dynamic": false, + "info": "Base URL for the API. Leave empty for default.", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "name": "api_base", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "type": "str", + "value": "" + }, + "api_key": { + "_input_type": "SecretStrInput", + "advanced": false, + "display_name": "OpenAI API Key", + "dynamic": false, + "info": "Model Provider API key", + "input_types": [], + "load_from_db": true, + "name": "api_key", + "password": true, + "placeholder": "", + "real_time_refresh": true, + "required": true, + "show": true, + "title_case": false, + "type": "str", + "value": "" + }, + "chunk_size": { + "_input_type": "IntInput", + "advanced": true, + "display_name": "Chunk Size", + "dynamic": false, + "info": "", + "list": false, + "list_add_label": "Add More", + "name": "chunk_size", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "int", + "value": 1000 + }, + "code": { + "advanced": true, + "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 typing import Any\n\nfrom langchain_openai import OpenAIEmbeddings\n\nfrom langflow.base.embeddings.model import LCEmbeddingsModel\nfrom langflow.base.models.openai_constants import OPENAI_EMBEDDING_MODEL_NAMES\nfrom langflow.field_typing import Embeddings\nfrom langflow.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n SecretStrInput,\n)\nfrom langflow.schema.dotdict import dotdict\n\n\nclass EmbeddingModelComponent(LCEmbeddingsModel):\n display_name = \"Embedding Model\"\n description = \"Generate embeddings using a specified provider.\"\n documentation: str = \"https://docs.langflow.org/components-embedding-models\"\n icon = \"binary\"\n name = \"EmbeddingModel\"\n category = \"models\"\n\n inputs = [\n DropdownInput(\n name=\"provider\",\n display_name=\"Model Provider\",\n options=[\"OpenAI\"],\n value=\"OpenAI\",\n info=\"Select the embedding model provider\",\n real_time_refresh=True,\n options_metadata=[{\"icon\": \"OpenAI\"}],\n ),\n DropdownInput(\n name=\"model\",\n display_name=\"Model Name\",\n options=OPENAI_EMBEDDING_MODEL_NAMES,\n value=OPENAI_EMBEDDING_MODEL_NAMES[0],\n info=\"Select the embedding model to use\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"OpenAI API Key\",\n info=\"Model Provider API key\",\n required=True,\n show=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"api_base\",\n display_name=\"API Base URL\",\n info=\"Base URL for the API. Leave empty for default.\",\n advanced=True,\n ),\n IntInput(\n name=\"dimensions\",\n display_name=\"Dimensions\",\n info=\"The number of dimensions the resulting output embeddings should have. \"\n \"Only supported by certain models.\",\n advanced=True,\n ),\n IntInput(name=\"chunk_size\", display_name=\"Chunk Size\", advanced=True, value=1000),\n FloatInput(name=\"request_timeout\", display_name=\"Request Timeout\", advanced=True),\n IntInput(name=\"max_retries\", display_name=\"Max Retries\", advanced=True, value=3),\n BoolInput(name=\"show_progress_bar\", display_name=\"Show Progress Bar\", advanced=True),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n ]\n\n def build_embeddings(self) -> Embeddings:\n provider = self.provider\n model = self.model\n api_key = self.api_key\n api_base = self.api_base\n dimensions = self.dimensions\n chunk_size = self.chunk_size\n request_timeout = self.request_timeout\n max_retries = self.max_retries\n show_progress_bar = self.show_progress_bar\n model_kwargs = self.model_kwargs or {}\n\n if provider == \"OpenAI\":\n if not api_key:\n msg = \"OpenAI API key is required when using OpenAI provider\"\n raise ValueError(msg)\n return OpenAIEmbeddings(\n model=model,\n dimensions=dimensions or None,\n base_url=api_base or None,\n api_key=api_key,\n chunk_size=chunk_size,\n max_retries=max_retries,\n timeout=request_timeout or None,\n show_progress_bar=show_progress_bar,\n model_kwargs=model_kwargs,\n )\n msg = f\"Unknown provider: {provider}\"\n raise ValueError(msg)\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n if field_name == \"provider\" and field_value == \"OpenAI\":\n build_config[\"model\"][\"options\"] = OPENAI_EMBEDDING_MODEL_NAMES\n build_config[\"model\"][\"value\"] = OPENAI_EMBEDDING_MODEL_NAMES[0]\n build_config[\"api_key\"][\"display_name\"] = \"OpenAI API Key\"\n build_config[\"api_base\"][\"display_name\"] = \"OpenAI API Base URL\"\n return build_config\n" + }, + "dimensions": { + "_input_type": "IntInput", + "advanced": true, + "display_name": "Dimensions", + "dynamic": false, + "info": "The number of dimensions the resulting output embeddings should have. Only supported by certain models.", + "list": false, + "list_add_label": "Add More", + "name": "dimensions", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "int", + "value": "" + }, + "max_retries": { + "_input_type": "IntInput", + "advanced": true, + "display_name": "Max Retries", + "dynamic": false, + "info": "", + "list": false, + "list_add_label": "Add More", + "name": "max_retries", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "int", + "value": 3 + }, + "model": { + "_input_type": "DropdownInput", + "advanced": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Model Name", + "dynamic": false, + "info": "Select the embedding model to use", + "name": "model", + "options": [ + "text-embedding-3-small", + "text-embedding-3-large", + "text-embedding-ada-002" + ], + "options_metadata": [], + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "str", + "value": "text-embedding-3-small" + }, + "model_kwargs": { + "_input_type": "DictInput", + "advanced": true, + "display_name": "Model Kwargs", + "dynamic": false, + "info": "Additional keyword arguments to pass to the model.", + "list": false, + "list_add_label": "Add More", + "name": "model_kwargs", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "type": "dict", + "value": {} + }, + "provider": { + "_input_type": "DropdownInput", + "advanced": false, + "combobox": false, + "dialog_inputs": {}, + "display_name": "Model Provider", + "dynamic": false, + "info": "Select the embedding model provider", + "name": "provider", + "options": [ + "OpenAI" + ], + "options_metadata": [ + { + "icon": "OpenAI" + } + ], + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": true, + "title_case": false, + "toggle": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "str", + "value": "OpenAI" + }, + "request_timeout": { + "_input_type": "FloatInput", + "advanced": true, + "display_name": "Request Timeout", + "dynamic": false, + "info": "", + "list": false, + "list_add_label": "Add More", + "name": "request_timeout", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "float", + "value": "" + }, + "show_progress_bar": { + "_input_type": "BoolInput", + "advanced": true, + "display_name": "Show Progress Bar", + "dynamic": false, + "info": "", + "list": false, + "list_add_label": "Add More", + "name": "show_progress_bar", + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "type": "bool", + "value": false + } + }, + "tool_mode": false + }, + "showNode": true, + "type": "EmbeddingModel" + }, + "dragging": false, + "id": "EmbeddingModel-cxG9r", + "measured": { + "height": 366, + "width": 320 + }, + "position": { + "x": 1743.8608432729177, + "y": 1808.780792406514 + }, + "selected": false, + "type": "genericNode" } ], "viewport": { - "x": -708.9707113557265, - "y": -965.7967428241175, - "zoom": 0.7967811989815704 + "x": -767.6929603556041, + "y": -1196.6455082358875, + "zoom": 0.9277466102702023 } }, "description": "Load your data for chat context with Retrieval Augmented Generation.", "endpoint_name": null, "id": "1402618b-e6d1-4ff2-9a11-d6ce71186915", "is_component": false, - "last_tested_version": "1.6.0", + "last_tested_version": "1.5.0.post2", "name": "OpenSearch Ingestion Flow Docling Serve", "tags": [ "openai", diff --git a/frontend/components.json b/frontend/components.json index 8e7f1638..53d2101e 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -10,9 +10,13 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "components", "utils": "lib/utils", "ui": "components/ui" + }, + "registries": { + "@magicui": "https://magicui.design/r/{name}.json" } -} \ No newline at end of file +} diff --git a/frontend/components/delete-session-modal.tsx b/frontend/components/delete-session-modal.tsx new file mode 100644 index 00000000..7b57a44f --- /dev/null +++ b/frontend/components/delete-session-modal.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface DeleteSessionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + sessionTitle: string; + isDeleting?: boolean; +} + +export function DeleteSessionModal({ + isOpen, + onClose, + onConfirm, + sessionTitle, + isDeleting = false, +}: DeleteSessionModalProps) { + return ( + + + + + + Delete Conversation + + + Are you sure you want to delete "{sessionTitle}"? This + action cannot be undone and will permanently remove the conversation + and all its messages. + + + + + + + + + ); +} diff --git a/frontend/components/logo/ibm-logo.tsx b/frontend/components/logo/ibm-logo.tsx index 6f7fc2cd..44b6e08c 100644 --- a/frontend/components/logo/ibm-logo.tsx +++ b/frontend/components/logo/ibm-logo.tsx @@ -11,7 +11,7 @@ export default function IBMLogo(props: React.SVGProps) { IBM Logo ); diff --git a/frontend/components/logo/openai-logo.tsx b/frontend/components/logo/openai-logo.tsx index 639c130e..330211b9 100644 --- a/frontend/components/logo/openai-logo.tsx +++ b/frontend/components/logo/openai-logo.tsx @@ -23,7 +23,7 @@ export default function OpenAILogo(props: React.SVGProps) { diff --git a/frontend/components/navigation-layout.tsx b/frontend/components/navigation-layout.tsx index fae8da62..d7a564a7 100644 --- a/frontend/components/navigation-layout.tsx +++ b/frontend/components/navigation-layout.tsx @@ -1,8 +1,12 @@ -"use client" +"use client"; -import { Navigation } from "@/components/navigation"; -import { ModeToggle } from "@/components/mode-toggle"; +import { usePathname } from "next/navigation"; +import { useGetConversationsQuery } from "@/app/api/queries/useGetConversationsQuery"; import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown"; +import { ModeToggle } from "@/components/mode-toggle"; +import { Navigation } from "@/components/navigation"; +import { useAuth } from "@/contexts/auth-context"; +import { useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; interface NavigationLayoutProps { @@ -11,11 +15,35 @@ interface NavigationLayoutProps { export function NavigationLayout({ children }: NavigationLayoutProps) { const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); - + const pathname = usePathname(); + const { isAuthenticated, isNoAuthMode } = useAuth(); + const { + endpoint, + refreshTrigger, + refreshConversations, + startNewConversation, + } = useChat(); + + // Only fetch conversations on chat page + const isOnChatPage = pathname === "/" || pathname === "/chat"; + const { data: conversations = [], isLoading: isConversationsLoading } = + useGetConversationsQuery(endpoint, refreshTrigger, { + enabled: isOnChatPage && (isAuthenticated || isNoAuthMode), + }); + + const handleNewConversation = () => { + refreshConversations(); + startNewConversation(); + }; + return (
- +
@@ -31,7 +59,7 @@ export function NavigationLayout({ children }: NavigationLayoutProps) { {/* Search component could go here */}
-
- {children} -
+
{children}
); -} \ No newline at end of file +} diff --git a/frontend/components/navigation.tsx b/frontend/components/navigation.tsx index b651ef6a..339b7d22 100644 --- a/frontend/components/navigation.tsx +++ b/frontend/components/navigation.tsx @@ -1,24 +1,35 @@ "use client"; -import { useChat } from "@/contexts/chat-context"; -import { cn } from "@/lib/utils"; import { + EllipsisVertical, FileText, Library, MessageSquare, + MoreHorizontal, Plus, Settings2, + Trash2, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { EndpointType } from "@/contexts/chat-context"; -import { useLoadingStore } from "@/stores/loadingStore"; -import { KnowledgeFilterList } from "./knowledge-filter-list"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useDeleteSessionMutation } from "@/app/api/queries/useDeleteSessionMutation"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { type EndpointType, useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { cn } from "@/lib/utils"; +import { useLoadingStore } from "@/stores/loadingStore"; +import { DeleteSessionModal } from "./delete-session-modal"; +import { KnowledgeFilterList } from "./knowledge-filter-list"; -interface RawConversation { +// Re-export the types for backward compatibility +export interface RawConversation { response_id: string; title: string; endpoint: string; @@ -35,7 +46,7 @@ interface RawConversation { [key: string]: unknown; } -interface ChatConversation { +export interface ChatConversation { response_id: string; title: string; endpoint: EndpointType; @@ -52,11 +63,20 @@ interface ChatConversation { [key: string]: unknown; } -export function Navigation() { +interface NavigationProps { + conversations?: ChatConversation[]; + isConversationsLoading?: boolean; + onNewConversation?: () => void; +} + +export function Navigation({ + conversations = [], + isConversationsLoading = false, + onNewConversation, +}: NavigationProps = {}) { const pathname = usePathname(); const { endpoint, - refreshTrigger, loadConversation, currentConversationId, setCurrentConversationId, @@ -70,18 +90,64 @@ export function Navigation() { const { loading } = useLoadingStore(); - const [conversations, setConversations] = useState([]); - const [loadingConversations, setLoadingConversations] = useState(false); const [loadingNewConversation, setLoadingNewConversation] = useState(false); const [previousConversationCount, setPreviousConversationCount] = useState(0); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [conversationToDelete, setConversationToDelete] = + useState(null); const fileInputRef = useRef(null); const { selectedFilter, setSelectedFilter } = useKnowledgeFilter(); + // Delete session mutation + const deleteSessionMutation = useDeleteSessionMutation({ + onSuccess: () => { + toast.success("Conversation deleted successfully"); + + // If we deleted the current conversation, select another one + if ( + conversationToDelete && + currentConversationId === conversationToDelete.response_id + ) { + // Filter out the deleted conversation and find the next one + const remainingConversations = conversations.filter( + (conv) => conv.response_id !== conversationToDelete.response_id, + ); + + if (remainingConversations.length > 0) { + // Load the first available conversation (most recent) + loadConversation(remainingConversations[0]); + } else { + // No conversations left, start a new one + setCurrentConversationId(null); + if (onNewConversation) { + onNewConversation(); + } else { + refreshConversations(); + startNewConversation(); + } + } + } + + setDeleteModalOpen(false); + setConversationToDelete(null); + }, + onError: (error) => { + toast.error(`Failed to delete conversation: ${error.message}`); + }, + }); + const handleNewConversation = () => { setLoadingNewConversation(true); - refreshConversations(); - startNewConversation(); + + // Use the prop callback if provided, otherwise use the context method + if (onNewConversation) { + onNewConversation(); + } else { + refreshConversations(); + startNewConversation(); + } + if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("newConversation")); } @@ -98,7 +164,7 @@ export function Navigation() { window.dispatchEvent( new CustomEvent("fileUploadStart", { detail: { filename: file.name }, - }) + }), ); try { @@ -122,7 +188,7 @@ export function Navigation() { filename: file.name, error: "Failed to process document", }, - }) + }), ); // Trigger loading end event @@ -142,7 +208,7 @@ export function Navigation() { window.dispatchEvent( new CustomEvent("fileUploaded", { detail: { file, result }, - }) + }), ); // Trigger loading end event @@ -156,7 +222,7 @@ export function Navigation() { window.dispatchEvent( new CustomEvent("fileUploadError", { detail: { filename: file.name, error: "Failed to process document" }, - }) + }), ); } }; @@ -176,6 +242,41 @@ export function Navigation() { } }; + const handleDeleteConversation = ( + conversation: ChatConversation, + event?: React.MouseEvent, + ) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + setConversationToDelete(conversation); + setDeleteModalOpen(true); + }; + + const handleContextMenuAction = ( + action: string, + conversation: ChatConversation, + ) => { + switch (action) { + case "delete": + handleDeleteConversation(conversation); + break; + // Add more actions here in the future (rename, duplicate, etc.) + default: + break; + } + }; + + const confirmDeleteConversation = () => { + if (conversationToDelete) { + deleteSessionMutation.mutate({ + sessionId: conversationToDelete.response_id, + endpoint: endpoint, + }); + } + }; + const routes = [ { label: "Chat", @@ -200,91 +301,6 @@ export function Navigation() { const isOnChatPage = pathname === "/" || pathname === "/chat"; const isOnKnowledgePage = pathname.startsWith("/knowledge"); - const createDefaultPlaceholder = useCallback(() => { - return { - response_id: "new-conversation-" + Date.now(), - title: "New conversation", - endpoint: endpoint, - messages: [ - { - role: "assistant", - content: "How can I assist?", - timestamp: new Date().toISOString(), - }, - ], - created_at: new Date().toISOString(), - last_activity: new Date().toISOString(), - total_messages: 1, - } as ChatConversation; - }, [endpoint]); - - const fetchConversations = useCallback(async () => { - setLoadingConversations(true); - try { - // Fetch from the selected endpoint only - const apiEndpoint = - endpoint === "chat" ? "/api/chat/history" : "/api/langflow/history"; - - const response = await fetch(apiEndpoint); - if (response.ok) { - const history = await response.json(); - const rawConversations = history.conversations || []; - - // Cast conversations to proper type and ensure endpoint is correct - const conversations: ChatConversation[] = rawConversations.map( - (conv: RawConversation) => ({ - ...conv, - endpoint: conv.endpoint as EndpointType, - }) - ); - - // Sort conversations by last activity (most recent first) - conversations.sort((a: ChatConversation, b: ChatConversation) => { - const aTime = new Date( - a.last_activity || a.created_at || 0 - ).getTime(); - const bTime = new Date( - b.last_activity || b.created_at || 0 - ).getTime(); - return bTime - aTime; - }); - - setConversations(conversations); - - // If no conversations exist and no placeholder is shown, create a default placeholder - if (conversations.length === 0 && !placeholderConversation) { - setPlaceholderConversation(createDefaultPlaceholder()); - } - } else { - setConversations([]); - - // Also create placeholder when request fails and no conversations exist - if (!placeholderConversation) { - setPlaceholderConversation(createDefaultPlaceholder()); - } - } - - // Conversation documents are now managed in chat context - } catch (error) { - console.error(`Failed to fetch ${endpoint} conversations:`, error); - setConversations([]); - } finally { - setLoadingConversations(false); - } - }, [ - endpoint, - placeholderConversation, - setPlaceholderConversation, - createDefaultPlaceholder, - ]); - - // Fetch chat conversations when on chat page, endpoint changes, or refresh is triggered - useEffect(() => { - if (isOnChatPage) { - fetchConversations(); - } - }, [isOnChatPage, endpoint, refreshTrigger, fetchConversations]); - // Clear placeholder when conversation count increases (new conversation was created) useEffect(() => { const currentCount = conversations.length; @@ -326,7 +342,7 @@ export function Navigation() { "text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all", route.active ? "bg-accent text-accent-foreground shadow-sm" - : "text-foreground hover:text-accent-foreground" + : "text-foreground hover:text-accent-foreground", )} >
@@ -335,7 +351,7 @@ export function Navigation() { "h-4 w-4 mr-3 shrink-0", route.active ? "text-accent-foreground" - : "text-muted-foreground group-hover:text-foreground" + : "text-muted-foreground group-hover:text-foreground", )} /> {route.label} @@ -366,6 +382,7 @@ export function Navigation() { Conversations )} {/* Show regular conversations */} @@ -412,9 +430,10 @@ export function Navigation() {
) : ( conversations.map((conversation) => ( -
-
- {conversation.title} -
-
- {conversation.total_messages} messages -
- {conversation.last_activity && ( -
- {new Date( - conversation.last_activity - ).toLocaleDateString()} +
+
+
+ {conversation.title} +
- )} -
+ + + + + e.stopPropagation()} + > + { + e.stopPropagation(); + handleContextMenuAction( + "delete", + conversation, + ); + }} + className="cursor-pointer text-destructive focus:text-destructive" + > + + Delete conversation + + + +
+ )) )} @@ -456,6 +507,7 @@ export function Navigation() { Conversation knowledge
)} + + {/* Delete Session Modal */} + { + setDeleteModalOpen(false); + setConversationToDelete(null); + }} + onConfirm={confirmDeleteConversation} + sessionTitle={conversationToDelete?.title || ""} + isDeleting={deleteSessionMutation.isPending} + /> ); } diff --git a/frontend/components/ui/dot-pattern.tsx b/frontend/components/ui/dot-pattern.tsx new file mode 100644 index 00000000..aa4b2028 --- /dev/null +++ b/frontend/components/ui/dot-pattern.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { motion } from "motion/react"; +import type React from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +/** + * DotPattern Component Props + * + * @param {number} [width=16] - The horizontal spacing between dots + * @param {number} [height=16] - The vertical spacing between dots + * @param {number} [x=0] - The x-offset of the entire pattern + * @param {number} [y=0] - The y-offset of the entire pattern + * @param {number} [cx=1] - The x-offset of individual dots + * @param {number} [cy=1] - The y-offset of individual dots + * @param {number} [cr=1] - The radius of each dot + * @param {string} [className] - Additional CSS classes to apply to the SVG container + * @param {boolean} [glow=false] - Whether dots should have a glowing animation effect + */ +interface DotPatternProps extends React.SVGProps { + width?: number; + height?: number; + x?: number; + y?: number; + cx?: number; + cy?: number; + cr?: number; + className?: string; + glow?: boolean; + [key: string]: unknown; +} + +/** + * DotPattern Component + * + * A React component that creates an animated or static dot pattern background using SVG. + * The pattern automatically adjusts to fill its container and can optionally display glowing dots. + * + * @component + * + * @see DotPatternProps for the props interface. + * + * @example + * // Basic usage + * + * + * // With glowing effect and custom spacing + * + * + * @notes + * - The component is client-side only ("use client") + * - Automatically responds to container size changes + * - When glow is enabled, dots will animate with random delays and durations + * - Uses Motion for animations + * - Dots color can be controlled via the text color utility classes + */ + +export function DotPattern({ + width = 16, + height = 16, + x = 0, + y = 0, + cx = 1, + cy = 1, + cr = 1, + className, + glow = false, + ...props +}: DotPatternProps) { + const id = useId(); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const { width, height } = containerRef.current.getBoundingClientRect(); + setDimensions({ width, height }); + } + }; + + updateDimensions(); + window.addEventListener("resize", updateDimensions); + return () => window.removeEventListener("resize", updateDimensions); + }, []); + + const dots = Array.from( + { + length: + Math.ceil(dimensions.width / width) * + Math.ceil(dimensions.height / height), + }, + (_, i) => { + const col = i % Math.ceil(dimensions.width / width); + const row = Math.floor(i / Math.ceil(dimensions.width / width)); + return { + x: col * width + cx, + y: row * height + cy, + delay: Math.random() * 5, + duration: Math.random() * 3 + 2, + }; + }, + ); + + return ( + + ); +} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index 5ba0eba0..04599fd0 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -1,3 +1,4 @@ +import { Eye, EyeOff } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -12,6 +13,11 @@ const Input = React.forwardRef( const [hasValue, setHasValue] = React.useState( Boolean(props.value || props.defaultValue), ); + const [showPassword, setShowPassword] = React.useState(false); + + const handleTogglePassword = () => { + setShowPassword(!showPassword); + }; const handleChange = (e: React.ChangeEvent) => { setHasValue(e.target.value.length > 0); @@ -23,8 +29,8 @@ const Input = React.forwardRef( return ( ); - } + }, ); Input.displayName = "Input"; diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index f9571262..b8e19381 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select" -import { Check, ChevronDown, ChevronUp } from "lucide-react" +import { Check, ChevronDown, ChevronUp, Lock } from "lucide-react" import { cn } from "@/lib/utils" @@ -15,18 +15,24 @@ const SelectValue = SelectPrimitive.Value const SelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +>(({ className, children, disabled, ...props }, ref) => ( span]:line-clamp-1", + disabled && "bg-muted", className )} + disabled={disabled} {...props} > {children} - + {disabled ? ( + + ) : ( + + )} )) diff --git a/frontend/public/images/background.png b/frontend/public/images/background.png deleted file mode 100644 index 66d44a8a..00000000 Binary files a/frontend/public/images/background.png and /dev/null differ diff --git a/frontend/src/app/api/queries/useDeleteSessionMutation.ts b/frontend/src/app/api/queries/useDeleteSessionMutation.ts new file mode 100644 index 00000000..996e8a44 --- /dev/null +++ b/frontend/src/app/api/queries/useDeleteSessionMutation.ts @@ -0,0 +1,57 @@ +import { + type MutationOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import type { EndpointType } from "@/contexts/chat-context"; + +interface DeleteSessionParams { + sessionId: string; + endpoint: EndpointType; +} + +interface DeleteSessionResponse { + success: boolean; + message: string; +} + +export const useDeleteSessionMutation = ( + options?: Omit< + MutationOptions, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ sessionId }: DeleteSessionParams) => { + const response = await fetch(`/api/sessions/${sessionId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.error || `Failed to delete session: ${response.status}`, + ); + } + + return response.json(); + }, + onSettled: (_data, _error, variables) => { + // Invalidate conversations query to refresh the list + // Use a slight delay to ensure the success callback completes first + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: ["conversations", variables.endpoint], + }); + + // Also invalidate any specific conversation queries + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + }, 0); + }, + ...options, + }); +}; diff --git a/frontend/src/app/api/queries/useGetConversationsQuery.ts b/frontend/src/app/api/queries/useGetConversationsQuery.ts new file mode 100644 index 00000000..f7e579b3 --- /dev/null +++ b/frontend/src/app/api/queries/useGetConversationsQuery.ts @@ -0,0 +1,105 @@ +import { + type UseQueryOptions, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { EndpointType } from "@/contexts/chat-context"; + +export interface RawConversation { + response_id: string; + title: string; + endpoint: string; + messages: Array<{ + role: string; + content: string; + timestamp?: string; + response_id?: string; + }>; + created_at?: string; + last_activity?: string; + previous_response_id?: string; + total_messages: number; + [key: string]: unknown; +} + +export interface ChatConversation { + response_id: string; + title: string; + endpoint: EndpointType; + messages: Array<{ + role: string; + content: string; + timestamp?: string; + response_id?: string; + }>; + created_at?: string; + last_activity?: string; + previous_response_id?: string; + total_messages: number; + [key: string]: unknown; +} + +export interface ConversationHistoryResponse { + conversations: RawConversation[]; + [key: string]: unknown; +} + +export const useGetConversationsQuery = ( + endpoint: EndpointType, + refreshTrigger?: number, + options?: Omit, +) => { + const queryClient = useQueryClient(); + + async function getConversations(): Promise { + try { + // Fetch from the selected endpoint only + const apiEndpoint = + endpoint === "chat" ? "/api/chat/history" : "/api/langflow/history"; + + const response = await fetch(apiEndpoint); + + if (!response.ok) { + console.error(`Failed to fetch conversations: ${response.status}`); + return []; + } + + const history: ConversationHistoryResponse = await response.json(); + const rawConversations = history.conversations || []; + + // Cast conversations to proper type and ensure endpoint is correct + const conversations: ChatConversation[] = rawConversations.map( + (conv: RawConversation) => ({ + ...conv, + endpoint: conv.endpoint as EndpointType, + }), + ); + + // Sort conversations by last activity (most recent first) + conversations.sort((a: ChatConversation, b: ChatConversation) => { + const aTime = new Date(a.last_activity || a.created_at || 0).getTime(); + const bTime = new Date(b.last_activity || b.created_at || 0).getTime(); + return bTime - aTime; + }); + + return conversations; + } catch (error) { + console.error(`Failed to fetch ${endpoint} conversations:`, error); + return []; + } + } + + const queryResult = useQuery( + { + queryKey: ["conversations", endpoint, refreshTrigger], + placeholderData: (prev) => prev, + queryFn: getConversations, + staleTime: 0, // Always consider data stale to ensure fresh data on trigger changes + gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + ...options, + }, + queryClient, + ); + + return queryResult; +}; diff --git a/frontend/src/app/api/queries/useGetModelsQuery.ts b/frontend/src/app/api/queries/useGetModelsQuery.ts index 4ce55bd3..3a5eb77e 100644 --- a/frontend/src/app/api/queries/useGetModelsQuery.ts +++ b/frontend/src/app/api/queries/useGetModelsQuery.ts @@ -90,7 +90,6 @@ export const useGetOllamaModelsQuery = ( queryKey: ["models", "ollama", params], queryFn: getOllamaModels, retry: 2, - enabled: !!params?.endpoint, // Only run if endpoint is provided staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache results ...options, diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index c2347f1b..1639a4be 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -6,7 +6,9 @@ import { Suspense, useEffect } from "react"; import GoogleLogo from "@/components/logo/google-logo"; import Logo from "@/components/logo/logo"; import { Button } from "@/components/ui/button"; +import { DotPattern } from "@/components/ui/dot-pattern"; import { useAuth } from "@/contexts/auth-context"; +import { cn } from "@/lib/utils"; import { useGetSettingsQuery } from "../api/queries/useGetSettingsQuery"; function LoginPageContent() { @@ -53,15 +55,19 @@ function LoginPageContent() { } return ( -
-
+
+ +

Welcome to OpenRAG

@@ -72,7 +78,7 @@ function LoginPageContent() { Continue with Google

-
+

Systems Operational

Privacy Policy

diff --git a/frontend/src/app/onboarding/components/advanced.tsx b/frontend/src/app/onboarding/components/advanced.tsx index bb0089d5..20764aed 100644 --- a/frontend/src/app/onboarding/components/advanced.tsx +++ b/frontend/src/app/onboarding/components/advanced.tsx @@ -47,8 +47,7 @@ export function AdvancedOnboarding({ {hasEmbeddingModels && ( @@ -63,8 +62,7 @@ export function AdvancedOnboarding({ {hasLanguageModels && ( @@ -79,7 +77,7 @@ export function AdvancedOnboarding({ {(hasLanguageModels || hasEmbeddingModels) && } diff --git a/frontend/src/app/onboarding/components/ibm-onboarding.tsx b/frontend/src/app/onboarding/components/ibm-onboarding.tsx index 550f9d6b..63e3fe6a 100644 --- a/frontend/src/app/onboarding/components/ibm-onboarding.tsx +++ b/frontend/src/app/onboarding/components/ibm-onboarding.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; import IBMLogo from "@/components/logo/ibm-logo"; import { useDebouncedValue } from "@/lib/debounce"; import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; @@ -7,6 +8,7 @@ import { useGetIBMModelsQuery } from "../../api/queries/useGetModelsQuery"; import { useModelSelection } from "../hooks/useModelSelection"; import { useUpdateSettings } from "../hooks/useUpdateSettings"; import { AdvancedOnboarding } from "./advanced"; +import { ModelSelector } from "./model-selector"; export function IBMOnboarding({ setSettings, @@ -17,10 +19,42 @@ export function IBMOnboarding({ sampleDataset: boolean; setSampleDataset: (dataset: boolean) => void; }) { - const [endpoint, setEndpoint] = useState(""); + const [endpoint, setEndpoint] = useState("https://us-south.ml.cloud.ibm.com"); const [apiKey, setApiKey] = useState(""); const [projectId, setProjectId] = useState(""); + const options = [ + { + value: "https://us-south.ml.cloud.ibm.com", + label: "https://us-south.ml.cloud.ibm.com", + default: true, + }, + { + value: "https://eu-de.ml.cloud.ibm.com", + label: "https://eu-de.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://eu-gb.ml.cloud.ibm.com", + label: "https://eu-gb.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://au-syd.ml.cloud.ibm.com", + label: "https://au-syd.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://jp-tok.ml.cloud.ibm.com", + label: "https://jp-tok.ml.cloud.ibm.com", + default: false, + }, + { + value: "https://ca-tor.ml.cloud.ibm.com", + label: "https://ca-tor.ml.cloud.ibm.com", + default: false, + }, + ]; const debouncedEndpoint = useDebouncedValue(endpoint, 500); const debouncedApiKey = useDebouncedValue(apiKey, 500); const debouncedProjectId = useDebouncedValue(projectId, 500); @@ -68,19 +102,26 @@ export function IBMOnboarding({ return ( <>
- setEndpoint(e.target.value)} - /> + > + + - Invalid configuration or connection failed + Connection failed. Check your configuration.

)} - {modelsData && - (modelsData.language_models?.length > 0 || - modelsData.embedding_models?.length > 0) && ( -

- Configuration is valid -

- )}
} diff --git a/frontend/src/app/onboarding/components/model-selector.tsx b/frontend/src/app/onboarding/components/model-selector.tsx index 7a74bed2..dfed52ee 100644 --- a/frontend/src/app/onboarding/components/model-selector.tsx +++ b/frontend/src/app/onboarding/components/model-selector.tsx @@ -21,6 +21,9 @@ export function ModelSelector({ value, onValueChange, icon, + placeholder = "Select model...", + searchPlaceholder = "Search model...", + noOptionsPlaceholder = "No models available", }: { options: { value: string; @@ -29,6 +32,9 @@ export function ModelSelector({ }[]; value: string; icon?: React.ReactNode; + placeholder?: string; + searchPlaceholder?: string; + noOptionsPlaceholder?: string; onValueChange: (value: string) => void; }) { const [open, setOpen] = useState(false); @@ -50,7 +56,7 @@ export function ModelSelector({ > {value ? (
-
{icon}
+ {icon &&
{icon}
} {options.find((framework) => framework.value === value)?.label} {options.find((framework) => framework.value === value) ?.default && ( @@ -60,18 +66,18 @@ export function ModelSelector({ )}
) : options.length === 0 ? ( - "No models available" + noOptionsPlaceholder ) : ( - "Select model..." + placeholder )} - + - No model found. + {noOptionsPlaceholder} {options.map((option) => ( void; }) { - const [endpoint, setEndpoint] = useState(""); + const [endpoint, setEndpoint] = useState("http://localhost:11434"); + const [showConnecting, setShowConnecting] = useState(false); const debouncedEndpoint = useDebouncedValue(endpoint, 500); // Fetch models from API when endpoint is provided (debounced) @@ -41,6 +42,25 @@ export function OllamaOnboarding({ embeddingModels, } = useModelSelection(modelsData); + // Handle delayed display of connecting state + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (debouncedEndpoint && isLoadingModels) { + timeoutId = setTimeout(() => { + setShowConnecting(true); + }, 500); + } else { + setShowConnecting(false); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [debouncedEndpoint, isLoadingModels]); + const handleSampleDatasetChange = (dataset: boolean) => { setSampleDataset(dataset); }; @@ -57,74 +77,75 @@ export function OllamaOnboarding({ ); // Check validation state based on models query - const isConnecting = debouncedEndpoint && isLoadingModels; const hasConnectionError = debouncedEndpoint && modelsError; const hasNoModels = modelsData && !modelsData.language_models?.length && !modelsData.embedding_models?.length; - const isValidConnection = - modelsData && - (modelsData.language_models?.length > 0 || - modelsData.embedding_models?.length > 0); return ( <>
setEndpoint(e.target.value)} /> - {isConnecting && ( + {showConnecting && (

Connecting to Ollama server...

)} {hasConnectionError && (

- Can’t reach Ollama at {debouncedEndpoint}. Update the endpoint or + Can’t reach Ollama at {debouncedEndpoint}. Update the base URL or start the server.

)} {hasNoModels && (

- No models found. Please install some models on your Ollama server. -

- )} - {isValidConnection && ( -

- Connected successfully + No models found. Install embedding and agent models on your Ollama + server.

)}
} + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No embedding models detected. Install an embedding model to continue." + } value={embeddingModel} onValueChange={setEmbeddingModel} /> } + noOptionsPlaceholder={ + isLoadingModels + ? "Loading models..." + : "No language models detected. Install a language model to continue." + } value={languageModel} onValueChange={setLanguageModel} /> diff --git a/frontend/src/app/onboarding/components/openai-onboarding.tsx b/frontend/src/app/onboarding/components/openai-onboarding.tsx index cf18fb53..236097a4 100644 --- a/frontend/src/app/onboarding/components/openai-onboarding.tsx +++ b/frontend/src/app/onboarding/components/openai-onboarding.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { LabelInput } from "@/components/label-input"; +import { LabelWrapper } from "@/components/label-wrapper"; import OpenAILogo from "@/components/logo/openai-logo"; +import { Switch } from "@/components/ui/switch"; import { useDebouncedValue } from "@/lib/debounce"; import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation"; import { useGetOpenAIModelsQuery } from "../../api/queries/useGetModelsQuery"; @@ -18,6 +20,7 @@ export function OpenAIOnboarding({ setSampleDataset: (dataset: boolean) => void; }) { const [apiKey, setApiKey] = useState(""); + const [getFromEnv, setGetFromEnv] = useState(true); const debouncedApiKey = useDebouncedValue(apiKey, 500); // Fetch models from API when API key is provided @@ -26,7 +29,12 @@ export function OpenAIOnboarding({ isLoading: isLoadingModels, error: modelsError, } = useGetOpenAIModelsQuery( - debouncedApiKey ? { apiKey: debouncedApiKey } : undefined, + getFromEnv + ? { apiKey: "" } + : debouncedApiKey + ? { apiKey: debouncedApiKey } + : undefined, + { enabled: debouncedApiKey !== "" || getFromEnv }, ); // Use custom hook for model selection logic const { @@ -41,6 +49,15 @@ export function OpenAIOnboarding({ setSampleDataset(dataset); }; + const handleGetFromEnvChange = (fromEnv: boolean) => { + setGetFromEnv(fromEnv); + if (fromEnv) { + setApiKey(""); + } + setLanguageModel(""); + setEmbeddingModel(""); + }; + // Update settings when values change useUpdateSettings( "openai", @@ -53,33 +70,41 @@ export function OpenAIOnboarding({ ); return ( <> -
- setApiKey(e.target.value)} - /> - {isLoadingModels && ( -

- Validating API key... -

+
+ + + + {!getFromEnv && ( +
+ setApiKey(e.target.value)} + /> + {isLoadingModels && ( +

+ Validating API key... +

+ )} + {modelsError && ( +

+ Invalid OpenAI API key. Verify or replace the key. +

+ )} +
)} - {modelsError && ( -

- Invalid API key -

- )} - {modelsData && - (modelsData.language_models?.length > 0 || - modelsData.embedding_models?.length > 0) && ( -

- API Key is valid -

- )}
} diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx index c58abfea..a82e5fab 100644 --- a/frontend/src/app/onboarding/page.tsx +++ b/frontend/src/app/onboarding/page.tsx @@ -4,8 +4,8 @@ import { useRouter } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { - type OnboardingVariables, - useOnboardingMutation, + type OnboardingVariables, + useOnboardingMutation, } from "@/app/api/mutations/useOnboardingMutation"; import IBMLogo from "@/components/logo/ibm-logo"; import OllamaLogo from "@/components/logo/ollama-logo"; @@ -13,198 +13,208 @@ import OpenAILogo from "@/components/logo/openai-logo"; import { ProtectedRoute } from "@/components/protected-route"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardFooter, - CardHeader, + Card, + CardContent, + CardFooter, + CardHeader, } from "@/components/ui/card"; +import { DotPattern } from "@/components/ui/dot-pattern"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { useGetSettingsQuery } from "../api/queries/useGetSettingsQuery"; import { IBMOnboarding } from "./components/ibm-onboarding"; import { OllamaOnboarding } from "./components/ollama-onboarding"; import { OpenAIOnboarding } from "./components/openai-onboarding"; function OnboardingPage() { - const { data: settingsDb, isLoading: isSettingsLoading } = - useGetSettingsQuery(); + const { data: settingsDb, isLoading: isSettingsLoading } = + useGetSettingsQuery(); - const redirect = "/"; + const redirect = "/"; - const router = useRouter(); + const router = useRouter(); - // Redirect if already authenticated or in no-auth mode - useEffect(() => { - if (!isSettingsLoading && settingsDb && settingsDb.edited) { - router.push(redirect); - } - }, [isSettingsLoading, settingsDb, router]); + // Redirect if already authenticated or in no-auth mode + useEffect(() => { + if (!isSettingsLoading && settingsDb && settingsDb.edited) { + router.push(redirect); + } + }, [isSettingsLoading, settingsDb, router]); - const [modelProvider, setModelProvider] = useState("openai"); + const [modelProvider, setModelProvider] = useState("openai"); - const [sampleDataset, setSampleDataset] = useState(true); + const [sampleDataset, setSampleDataset] = useState(true); - const handleSetModelProvider = (provider: string) => { - setModelProvider(provider); - setSettings({ - model_provider: provider, - embedding_model: "", - llm_model: "", - }); - }; + const handleSetModelProvider = (provider: string) => { + setModelProvider(provider); + setSettings({ + model_provider: provider, + embedding_model: "", + llm_model: "", + }); + }; - const [settings, setSettings] = useState({ - model_provider: modelProvider, - embedding_model: "", - llm_model: "", - }); + const [settings, setSettings] = useState({ + model_provider: modelProvider, + embedding_model: "", + llm_model: "", + }); - // Mutations - const onboardingMutation = useOnboardingMutation({ - onSuccess: (data) => { - toast.success("Onboarding completed successfully!"); - console.log("Onboarding completed successfully", data); - router.push(redirect); - }, - onError: (error) => { - toast.error("Failed to complete onboarding", { - description: error.message, - }); - }, - }); + // Mutations + const onboardingMutation = useOnboardingMutation({ + onSuccess: (data) => { + toast.success("Onboarding completed successfully!"); + console.log("Onboarding completed successfully", data); + router.push(redirect); + }, + onError: (error) => { + toast.error("Failed to complete onboarding", { + description: error.message, + }); + }, + }); - const handleComplete = () => { - if ( - !settings.model_provider || - !settings.llm_model || - !settings.embedding_model - ) { - toast.error("Please complete all required fields"); - return; - } + const handleComplete = () => { + if ( + !settings.model_provider || + !settings.llm_model || + !settings.embedding_model + ) { + toast.error("Please complete all required fields"); + return; + } - // Prepare onboarding data - const onboardingData: OnboardingVariables = { - model_provider: settings.model_provider, - llm_model: settings.llm_model, - embedding_model: settings.embedding_model, - sample_data: sampleDataset, - }; + // Prepare onboarding data + const onboardingData: OnboardingVariables = { + model_provider: settings.model_provider, + llm_model: settings.llm_model, + embedding_model: settings.embedding_model, + sample_data: sampleDataset, + }; - // Add API key if available - if (settings.api_key) { - onboardingData.api_key = settings.api_key; - } + // Add API key if available + if (settings.api_key) { + onboardingData.api_key = settings.api_key; + } - // Add endpoint if available - if (settings.endpoint) { - onboardingData.endpoint = settings.endpoint; - } + // Add endpoint if available + if (settings.endpoint) { + onboardingData.endpoint = settings.endpoint; + } - // Add project_id if available - if (settings.project_id) { - onboardingData.project_id = settings.project_id; - } + // Add project_id if available + if (settings.project_id) { + onboardingData.project_id = settings.project_id; + } - onboardingMutation.mutate(onboardingData); - }; + onboardingMutation.mutate(onboardingData); + }; - const isComplete = !!settings.llm_model && !!settings.embedding_model; + const isComplete = !!settings.llm_model && !!settings.embedding_model; - return ( -
-
-
-

- Configure your models -

-

[description of task]

-
- - - - - - - OpenAI - - - - IBM - - - - Ollama - - - - - - - - - - - - - - - - - - - - - - {!isComplete ? "Please fill in all required fields" : ""} - - - - -
-
- ); + return ( +
+ + +
+
+

+ Connect a model provider +

+
+ + + + + + + OpenAI + + + + IBM + + + + Ollama + + + + + + + + + + + + + + + + + + +
+ +
+
+ {!isComplete && ( + + Please fill in all required fields + + )} +
+
+
+
+
+ ); } export default function ProtectedOnboardingPage() { - return ( - - Loading onboarding...
}> - - - - ); + return ( + + Loading onboarding...
}> + + + + ); } diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index f49ff393..91575423 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -35,10 +35,11 @@ import { Textarea } from "@/components/ui/textarea"; import { useAuth } from "@/contexts/auth-context"; import { useTask } from "@/contexts/task-context"; import { useDebounce } from "@/lib/debounce"; +import { DEFAULT_AGENT_SETTINGS, DEFAULT_KNOWLEDGE_SETTINGS, UI_CONSTANTS } from "@/lib/constants"; import { getFallbackModels, type ModelProvider } from "./helpers/model-helpers"; import { ModelSelectItems } from "./helpers/model-select-item"; -const MAX_SYSTEM_PROMPT_CHARS = 2000; +const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS; interface GoogleDriveFile { id: string; @@ -529,8 +530,17 @@ function KnowledgeSourcesPage() { fetch(`/api/reset-flow/retrieval`, { method: "POST", }) - .then((response) => response.json()) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }) .then(() => { + // Only reset form values if the API call was successful + setSystemPrompt(DEFAULT_AGENT_SETTINGS.system_prompt); + // Trigger model update to default model + handleModelChange(DEFAULT_AGENT_SETTINGS.llm_model); closeDialog(); // Close after successful completion }) .catch((error) => { @@ -543,8 +553,17 @@ function KnowledgeSourcesPage() { fetch(`/api/reset-flow/ingest`, { method: "POST", }) - .then((response) => response.json()) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }) .then(() => { + // Only reset form values if the API call was successful + setChunkSize(DEFAULT_KNOWLEDGE_SETTINGS.chunk_size); + setChunkOverlap(DEFAULT_KNOWLEDGE_SETTINGS.chunk_overlap); + setProcessingMode(DEFAULT_KNOWLEDGE_SETTINGS.processing_mode); closeDialog(); // Close after successful completion }) .catch((error) => { @@ -764,8 +783,9 @@ function KnowledgeSourcesPage() { "text-embedding-ada-002" } onValueChange={handleEmbeddingModelChange} + disabled={true} > - + diff --git a/frontend/src/components/layout-wrapper.tsx b/frontend/src/components/layout-wrapper.tsx index 9be42730..79d4b095 100644 --- a/frontend/src/components/layout-wrapper.tsx +++ b/frontend/src/components/layout-wrapper.tsx @@ -2,28 +2,48 @@ import { Bell, Loader2 } from "lucide-react"; import { usePathname } from "next/navigation"; +import { useGetConversationsQuery } from "@/app/api/queries/useGetConversationsQuery"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel"; +import Logo from "@/components/logo/logo"; import { Navigation } from "@/components/navigation"; import { TaskNotificationMenu } from "@/components/task-notification-menu"; import { Button } from "@/components/ui/button"; import { UserNav } from "@/components/user-nav"; import { useAuth } from "@/contexts/auth-context"; +import { useChat } from "@/contexts/chat-context"; import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; // import { GitHubStarButton } from "@/components/github-star-button" // import { DiscordLink } from "@/components/discord-link" import { useTask } from "@/contexts/task-context"; -import Logo from "@/components/logo/logo"; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const { tasks, isMenuOpen, toggleMenu } = useTask(); const { isPanelOpen } = useKnowledgeFilter(); const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); + const { + endpoint, + refreshTrigger, + refreshConversations, + startNewConversation, + } = useChat(); const { isLoading: isSettingsLoading, data: settings } = useGetSettingsQuery({ enabled: isAuthenticated || isNoAuthMode, }); + // Only fetch conversations on chat page + const isOnChatPage = pathname === "/" || pathname === "/chat"; + const { data: conversations = [], isLoading: isConversationsLoading } = + useGetConversationsQuery(endpoint, refreshTrigger, { + enabled: isOnChatPage && (isAuthenticated || isNoAuthMode), + }); + + const handleNewConversation = () => { + refreshConversations(); + startNewConversation(); + }; + // List of paths that should not show navigation const authPaths = ["/login", "/auth/callback", "/onboarding"]; const isAuthPage = authPaths.includes(pathname); @@ -33,7 +53,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { (task) => task.status === "pending" || task.status === "running" || - task.status === "processing" + task.status === "processing", ); // Show loading state when backend isn't ready @@ -99,7 +119,11 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
- +
bool: + """Delete a conversation for a user from both memory and persistent storage""" + deleted = False + + try: + # Delete from in-memory storage + if user_id in active_conversations and response_id in active_conversations[user_id]: + del active_conversations[user_id][response_id] + logger.debug(f"Deleted conversation {response_id} from memory for user {user_id}") + deleted = True + + # Delete from persistent storage + conversation_deleted = conversation_persistence.delete_conversation_thread(user_id, response_id) + if conversation_deleted: + logger.debug(f"Deleted conversation {response_id} from persistent storage for user {user_id}") + deleted = True + + # Release session ownership + try: + from services.session_ownership_service import session_ownership_service + session_ownership_service.release_session(user_id, response_id) + logger.debug(f"Released session ownership for {response_id} for user {user_id}") + except Exception as e: + logger.warning(f"Failed to release session ownership: {e}") + + return deleted + except Exception as e: + logger.error(f"Error deleting conversation {response_id} for user {user_id}: {e}") + return False diff --git a/src/api/chat.py b/src/api/chat.py index b9dea5ef..58492118 100644 --- a/src/api/chat.py +++ b/src/api/chat.py @@ -155,3 +155,27 @@ async def langflow_history_endpoint(request: Request, chat_service, session_mana return JSONResponse( {"error": f"Failed to get langflow history: {str(e)}"}, status_code=500 ) + + +async def delete_session_endpoint(request: Request, chat_service, session_manager): + """Delete a chat session""" + user = request.state.user + user_id = user.user_id + session_id = request.path_params["session_id"] + + try: + # Delete from both local storage and Langflow + result = await chat_service.delete_session(user_id, session_id) + + if result.get("success"): + return JSONResponse({"message": "Session deleted successfully"}) + else: + return JSONResponse( + {"error": result.get("error", "Failed to delete session")}, + status_code=500 + ) + except Exception as e: + logger.error(f"Error deleting session: {e}") + return JSONResponse( + {"error": f"Failed to delete session: {str(e)}"}, status_code=500 + ) diff --git a/src/api/settings.py b/src/api/settings.py index 560eb400..3e242c4b 100644 --- a/src/api/settings.py +++ b/src/api/settings.py @@ -47,9 +47,6 @@ def get_docling_preset_configs(): } - - - async def get_settings(request, session_manager): """Get application settings""" try: @@ -182,6 +179,7 @@ async def update_settings(request, session_manager): "chunk_size", "chunk_overlap", "doclingPresets", + "embedding_model", } # Check for invalid fields @@ -202,11 +200,61 @@ async def update_settings(request, session_manager): current_config.agent.llm_model = body["llm_model"] config_updated = True + # Also update the chat flow with the new model + try: + flows_service = _get_flows_service() + await flows_service.update_chat_flow_model(body["llm_model"]) + logger.info( + f"Successfully updated chat flow model to '{body['llm_model']}'" + ) + except Exception as e: + logger.error(f"Failed to update chat flow model: {str(e)}") + # Don't fail the entire settings update if flow update fails + # The config will still be saved + if "system_prompt" in body: current_config.agent.system_prompt = body["system_prompt"] config_updated = True + # Also update the chat flow with the new system prompt + try: + flows_service = _get_flows_service() + await flows_service.update_chat_flow_system_prompt( + body["system_prompt"] + ) + logger.info(f"Successfully updated chat flow system prompt") + except Exception as e: + logger.error(f"Failed to update chat flow system prompt: {str(e)}") + # Don't fail the entire settings update if flow update fails + # The config will still be saved + # Update knowledge settings + if "embedding_model" in body: + if ( + not isinstance(body["embedding_model"], str) + or not body["embedding_model"].strip() + ): + return JSONResponse( + {"error": "embedding_model must be a non-empty string"}, + status_code=400, + ) + current_config.knowledge.embedding_model = body["embedding_model"].strip() + config_updated = True + + # Also update the ingest flow with the new embedding model + try: + flows_service = _get_flows_service() + await flows_service.update_ingest_flow_embedding_model( + body["embedding_model"].strip() + ) + logger.info( + f"Successfully updated ingest flow embedding model to '{body['embedding_model'].strip()}'" + ) + except Exception as e: + logger.error(f"Failed to update ingest flow embedding model: {str(e)}") + # Don't fail the entire settings update if flow update fails + # The config will still be saved + if "doclingPresets" in body: preset_configs = get_docling_preset_configs() valid_presets = list(preset_configs.keys()) @@ -222,8 +270,13 @@ async def update_settings(request, session_manager): # Also update the flow with the new docling preset try: - await _update_flow_docling_preset(body["doclingPresets"], preset_configs[body["doclingPresets"]]) - logger.info(f"Successfully updated docling preset in flow to '{body['doclingPresets']}'") + flows_service = _get_flows_service() + await flows_service.update_flow_docling_preset( + body["doclingPresets"], preset_configs[body["doclingPresets"]] + ) + logger.info( + f"Successfully updated docling preset in flow to '{body['doclingPresets']}'" + ) except Exception as e: logger.error(f"Failed to update docling preset in flow: {str(e)}") # Don't fail the entire settings update if flow update fails @@ -237,6 +290,18 @@ async def update_settings(request, session_manager): current_config.knowledge.chunk_size = body["chunk_size"] config_updated = True + # Also update the ingest flow with the new chunk size + try: + flows_service = _get_flows_service() + await flows_service.update_ingest_flow_chunk_size(body["chunk_size"]) + logger.info( + f"Successfully updated ingest flow chunk size to {body['chunk_size']}" + ) + except Exception as e: + logger.error(f"Failed to update ingest flow chunk size: {str(e)}") + # Don't fail the entire settings update if flow update fails + # The config will still be saved + if "chunk_overlap" in body: if not isinstance(body["chunk_overlap"], int) or body["chunk_overlap"] < 0: return JSONResponse( @@ -246,6 +311,20 @@ async def update_settings(request, session_manager): current_config.knowledge.chunk_overlap = body["chunk_overlap"] config_updated = True + # Also update the ingest flow with the new chunk overlap + try: + flows_service = _get_flows_service() + await flows_service.update_ingest_flow_chunk_overlap( + body["chunk_overlap"] + ) + logger.info( + f"Successfully updated ingest flow chunk overlap to {body['chunk_overlap']}" + ) + except Exception as e: + logger.error(f"Failed to update ingest flow chunk overlap: {str(e)}") + # Don't fail the entire settings update if flow update fails + # The config will still be saved + if not config_updated: return JSONResponse( {"error": "No valid fields provided for update"}, status_code=400 @@ -524,48 +603,11 @@ async def onboarding(request, flows_service): ) -async def _update_flow_docling_preset(preset: str, preset_config: dict): - """Helper function to update docling preset in the ingest flow""" - if not LANGFLOW_INGEST_FLOW_ID: - raise ValueError("LANGFLOW_INGEST_FLOW_ID is not configured") +def _get_flows_service(): + """Helper function to get flows service instance""" + from services.flows_service import FlowsService - # Get the current flow data from Langflow - response = await clients.langflow_request( - "GET", f"/api/v1/flows/{LANGFLOW_INGEST_FLOW_ID}" - ) - - if response.status_code != 200: - raise Exception(f"Failed to get ingest flow: HTTP {response.status_code} - {response.text}") - - flow_data = response.json() - - # Find the target node in the flow using environment variable - nodes = flow_data.get("data", {}).get("nodes", []) - target_node = None - target_node_index = None - - for i, node in enumerate(nodes): - if node.get("id") == DOCLING_COMPONENT_ID: - target_node = node - target_node_index = i - break - - if target_node is None: - raise Exception(f"Docling component '{DOCLING_COMPONENT_ID}' not found in ingest flow") - - # Update the docling_serve_opts value directly in the existing node - if (target_node.get("data", {}).get("node", {}).get("template", {}).get("docling_serve_opts")): - flow_data["data"]["nodes"][target_node_index]["data"]["node"]["template"]["docling_serve_opts"]["value"] = preset_config - else: - raise Exception(f"docling_serve_opts field not found in node '{DOCLING_COMPONENT_ID}'") - - # Update the flow via PATCH request - patch_response = await clients.langflow_request( - "PATCH", f"/api/v1/flows/{LANGFLOW_INGEST_FLOW_ID}", json=flow_data - ) - - if patch_response.status_code != 200: - raise Exception(f"Failed to update ingest flow: HTTP {patch_response.status_code} - {patch_response.text}") + return FlowsService() async def update_docling_preset(request, session_manager): @@ -577,8 +619,7 @@ async def update_docling_preset(request, session_manager): # Validate preset parameter if "preset" not in body: return JSONResponse( - {"error": "preset parameter is required"}, - status_code=400 + {"error": "preset parameter is required"}, status_code=400 ) preset = body["preset"] @@ -587,28 +628,31 @@ async def update_docling_preset(request, session_manager): if preset not in preset_configs: valid_presets = list(preset_configs.keys()) return JSONResponse( - {"error": f"Invalid preset '{preset}'. Valid presets: {', '.join(valid_presets)}"}, - status_code=400 + { + "error": f"Invalid preset '{preset}'. Valid presets: {', '.join(valid_presets)}" + }, + status_code=400, ) # Get the preset configuration preset_config = preset_configs[preset] # Use the helper function to update the flow - await _update_flow_docling_preset(preset, preset_config) + flows_service = _get_flows_service() + await flows_service.update_flow_docling_preset(preset, preset_config) logger.info(f"Successfully updated docling preset to '{preset}' in ingest flow") - return JSONResponse({ - "message": f"Successfully updated docling preset to '{preset}'", - "preset": preset, - "preset_config": preset_config - }) + return JSONResponse( + { + "message": f"Successfully updated docling preset to '{preset}'", + "preset": preset, + "preset_config": preset_config, + } + ) except Exception as e: logger.error("Failed to update docling preset", error=str(e)) return JSONResponse( - {"error": f"Failed to update docling preset: {str(e)}"}, - status_code=500 + {"error": f"Failed to update docling preset: {str(e)}"}, status_code=500 ) - diff --git a/src/main.py b/src/main.py index f78e07bc..90add401 100644 --- a/src/main.py +++ b/src/main.py @@ -392,8 +392,6 @@ async def startup_tasks(services): """Startup tasks""" logger.info("Starting startup tasks") await init_index() - # Sample data ingestion is now handled by the onboarding endpoint when sample_data=True - logger.info("Sample data ingestion moved to onboarding endpoint") async def initialize_services(): @@ -786,6 +784,18 @@ async def create_app(): ), methods=["GET"], ), + # Session deletion endpoint + Route( + "/sessions/{session_id}", + require_auth(services["session_manager"])( + partial( + chat.delete_session_endpoint, + chat_service=services["chat_service"], + session_manager=services["session_manager"], + ) + ), + methods=["DELETE"], + ), # Authentication endpoints Route( "/auth/init", @@ -927,7 +937,8 @@ async def create_app(): "/settings", require_auth(services["session_manager"])( partial( - settings.update_settings, session_manager=services["session_manager"] + settings.update_settings, + session_manager=services["session_manager"], ) ), methods=["POST"], @@ -939,7 +950,7 @@ async def create_app(): partial( models.get_openai_models, models_service=services["models_service"], - session_manager=services["session_manager"] + session_manager=services["session_manager"], ) ), methods=["GET"], @@ -950,7 +961,7 @@ async def create_app(): partial( models.get_ollama_models, models_service=services["models_service"], - session_manager=services["session_manager"] + session_manager=services["session_manager"], ) ), methods=["GET"], @@ -961,7 +972,7 @@ async def create_app(): partial( models.get_ibm_models, models_service=services["models_service"], - session_manager=services["session_manager"] + session_manager=services["session_manager"], ) ), methods=["GET", "POST"], @@ -970,10 +981,7 @@ async def create_app(): Route( "/onboarding", require_auth(services["session_manager"])( - partial( - settings.onboarding, - flows_service=services["flows_service"] - ) + partial(settings.onboarding, flows_service=services["flows_service"]) ), methods=["POST"], ), @@ -983,7 +991,7 @@ async def create_app(): require_auth(services["session_manager"])( partial( settings.update_docling_preset, - session_manager=services["session_manager"] + session_manager=services["session_manager"], ) ), methods=["PATCH"], diff --git a/src/services/chat_service.py b/src/services/chat_service.py index 5ffe30f9..32536f4b 100644 --- a/src/services/chat_service.py +++ b/src/services/chat_service.py @@ -484,3 +484,55 @@ class ChatService: "total_conversations": len(all_conversations), } + async def delete_session(self, user_id: str, session_id: str): + """Delete a session from both local storage and Langflow""" + try: + # Delete from local conversation storage + from agent import delete_user_conversation + local_deleted = delete_user_conversation(user_id, session_id) + + # Delete from Langflow using the monitor API + langflow_deleted = await self._delete_langflow_session(session_id) + + success = local_deleted or langflow_deleted + error_msg = None + + if not success: + error_msg = "Session not found in local storage or Langflow" + + return { + "success": success, + "local_deleted": local_deleted, + "langflow_deleted": langflow_deleted, + "error": error_msg + } + + except Exception as e: + logger.error(f"Error deleting session {session_id} for user {user_id}: {e}") + return { + "success": False, + "error": str(e) + } + + async def _delete_langflow_session(self, session_id: str): + """Delete a session from Langflow using the monitor API""" + try: + response = await clients.langflow_request( + "DELETE", + f"/api/v1/monitor/messages/session/{session_id}" + ) + + if response.status_code == 200 or response.status_code == 204: + logger.info(f"Successfully deleted session {session_id} from Langflow") + return True + else: + logger.warning( + f"Failed to delete session {session_id} from Langflow: " + f"{response.status_code} - {response.text}" + ) + return False + + except Exception as e: + logger.error(f"Error deleting session {session_id} from Langflow: {e}") + return False + diff --git a/src/services/conversation_persistence_service.py b/src/services/conversation_persistence_service.py index fa5717c1..c6b62c24 100644 --- a/src/services/conversation_persistence_service.py +++ b/src/services/conversation_persistence_service.py @@ -86,12 +86,14 @@ class ConversationPersistenceService: user_conversations = self.get_user_conversations(user_id) return user_conversations.get(response_id, {}) - def delete_conversation_thread(self, user_id: str, response_id: str): + def delete_conversation_thread(self, user_id: str, response_id: str) -> bool: """Delete a specific conversation thread""" if user_id in self._conversations and response_id in self._conversations[user_id]: del self._conversations[user_id][response_id] self._save_conversations() logger.debug(f"Deleted conversation {response_id} for user {user_id}") + return True + return False def clear_user_conversations(self, user_id: str): """Clear all conversations for a user""" diff --git a/src/services/flows_service.py b/src/services/flows_service.py index 4c3872ca..0d7a7bc8 100644 --- a/src/services/flows_service.py +++ b/src/services/flows_service.py @@ -400,6 +400,123 @@ class FlowsService: return node return None + def _find_node_in_flow(self, flow_data, node_id=None, display_name=None): + """ + Helper function to find a node in flow data by ID or display name. + Returns tuple of (node, node_index) or (None, None) if not found. + """ + nodes = flow_data.get("data", {}).get("nodes", []) + + for i, node in enumerate(nodes): + node_data = node.get("data", {}) + node_template = node_data.get("node", {}) + + # Check by ID if provided + if node_id and node_data.get("id") == node_id: + return node, i + + # Check by display_name if provided + if display_name and node_template.get("display_name") == display_name: + return node, i + + return None, None + + async def _update_flow_field(self, flow_id: str, field_name: str, field_value: str, node_display_name: str = None, node_id: str = None): + """ + Generic helper function to update any field in any Langflow component. + + Args: + flow_id: The ID of the flow to update + field_name: The name of the field to update (e.g., 'model_name', 'system_message', 'docling_serve_opts') + field_value: The new value to set + node_display_name: The display name to search for (optional) + node_id: The node ID to search for (optional, used as fallback or primary) + """ + if not flow_id: + raise ValueError("flow_id is required") + + # Get the current flow data from Langflow + response = await clients.langflow_request( + "GET", f"/api/v1/flows/{flow_id}" + ) + + if response.status_code != 200: + raise Exception(f"Failed to get flow: HTTP {response.status_code} - {response.text}") + + flow_data = response.json() + + # Find the target component by display name first, then by ID as fallback + target_node, target_node_index = None, None + if node_display_name: + target_node, target_node_index = self._find_node_in_flow(flow_data, display_name=node_display_name) + + if target_node is None and node_id: + target_node, target_node_index = self._find_node_in_flow(flow_data, node_id=node_id) + + if target_node is None: + identifier = node_display_name or node_id + raise Exception(f"Component '{identifier}' not found in flow {flow_id}") + + # Update the field value directly in the existing node + template = target_node.get("data", {}).get("node", {}).get("template", {}) + if template.get(field_name): + flow_data["data"]["nodes"][target_node_index]["data"]["node"]["template"][field_name]["value"] = field_value + else: + identifier = node_display_name or node_id + raise Exception(f"{field_name} field not found in {identifier} component") + + # Update the flow via PATCH request + patch_response = await clients.langflow_request( + "PATCH", f"/api/v1/flows/{flow_id}", json=flow_data + ) + + if patch_response.status_code != 200: + raise Exception(f"Failed to update flow: HTTP {patch_response.status_code} - {patch_response.text}") + + async def update_chat_flow_model(self, model_name: str): + """Helper function to update the model in the chat flow""" + if not LANGFLOW_CHAT_FLOW_ID: + raise ValueError("LANGFLOW_CHAT_FLOW_ID is not configured") + await self._update_flow_field(LANGFLOW_CHAT_FLOW_ID, "model_name", model_name, + node_display_name="Language Model") + + async def update_chat_flow_system_prompt(self, system_prompt: str): + """Helper function to update the system prompt in the chat flow""" + if not LANGFLOW_CHAT_FLOW_ID: + raise ValueError("LANGFLOW_CHAT_FLOW_ID is not configured") + await self._update_flow_field(LANGFLOW_CHAT_FLOW_ID, "system_prompt", system_prompt, + node_display_name="Agent") + + async def update_flow_docling_preset(self, preset: str, preset_config: dict): + """Helper function to update docling preset in the ingest flow""" + if not LANGFLOW_INGEST_FLOW_ID: + raise ValueError("LANGFLOW_INGEST_FLOW_ID is not configured") + + from config.settings import DOCLING_COMPONENT_ID + await self._update_flow_field(LANGFLOW_INGEST_FLOW_ID, "docling_serve_opts", preset_config, + node_id=DOCLING_COMPONENT_ID) + + async def update_ingest_flow_chunk_size(self, chunk_size: int): + """Helper function to update chunk size in the ingest flow""" + if not LANGFLOW_INGEST_FLOW_ID: + raise ValueError("LANGFLOW_INGEST_FLOW_ID is not configured") + await self._update_flow_field(LANGFLOW_INGEST_FLOW_ID, "chunk_size", chunk_size, + node_display_name="Split Text") + + async def update_ingest_flow_chunk_overlap(self, chunk_overlap: int): + """Helper function to update chunk overlap in the ingest flow""" + if not LANGFLOW_INGEST_FLOW_ID: + raise ValueError("LANGFLOW_INGEST_FLOW_ID is not configured") + await self._update_flow_field(LANGFLOW_INGEST_FLOW_ID, "chunk_overlap", chunk_overlap, + node_display_name="Split Text") + + async def update_ingest_flow_embedding_model(self, embedding_model: str): + """Helper function to update embedding model in the ingest flow""" + if not LANGFLOW_INGEST_FLOW_ID: + raise ValueError("LANGFLOW_INGEST_FLOW_ID is not configured") + await self._update_flow_field(LANGFLOW_INGEST_FLOW_ID, "model", embedding_model, + node_display_name="Embedding Model") + def _replace_node_in_flow(self, flow_data, old_id, new_node): """Replace a node in the flow data""" nodes = flow_data.get("data", {}).get("nodes", []) diff --git a/src/services/session_ownership_service.py b/src/services/session_ownership_service.py index 220a6d96..d700c5c3 100644 --- a/src/services/session_ownership_service.py +++ b/src/services/session_ownership_service.py @@ -74,6 +74,20 @@ class SessionOwnershipService: """Filter a list of sessions to only include those owned by the user""" user_sessions = self.get_user_sessions(user_id) return [session for session in session_ids if session in user_sessions] + + def release_session(self, user_id: str, session_id: str) -> bool: + """Release a session from a user (delete ownership record)""" + if session_id in self.ownership_data: + # Verify the user owns this session before deleting + if self.ownership_data[session_id].get("user_id") == user_id: + del self.ownership_data[session_id] + self._save_ownership_data() + logger.debug(f"Released session {session_id} from user {user_id}") + return True + else: + logger.warning(f"User {user_id} tried to release session {session_id} they don't own") + return False + return False def get_ownership_stats(self) -> Dict[str, any]: """Get statistics about session ownership"""