Merge branch 'zustand/io/migration' of github.com:logspace-ai/langflow into zustand/io/migration
This commit is contained in:
commit
e120bfe869
44 changed files with 813 additions and 314 deletions
|
|
@ -83,7 +83,8 @@ The CustomComponent class serves as the foundation for creating custom component
|
||||||
| _`file_types: List[str]`_ | This is a requirement if the _`field_type`_ is _file_. Defines which file types will be accepted. For example, _json_, _yaml_ or _yml_. |
|
| _`file_types: List[str]`_ | This is a requirement if the _`field_type`_ is _file_. Defines which file types will be accepted. For example, _json_, _yaml_ or _yml_. |
|
||||||
| _`range_spec: langflow.field_typing.RangeSpec`_ | This is a requirement if the _`field_type`_ is _`float`_. Defines the range of values accepted and the step size. If none is defined, the default is _`[-1, 1, 0.1]`_. |
|
| _`range_spec: langflow.field_typing.RangeSpec`_ | This is a requirement if the _`field_type`_ is _`float`_. Defines the range of values accepted and the step size. If none is defined, the default is _`[-1, 1, 0.1]`_. |
|
||||||
| _`title_case: bool`_ | Formats the name of the field when _`display_name`_ is not defined. Set it to False to keep the name as you set it in the _`build`_ method. |
|
| _`title_case: bool`_ | Formats the name of the field when _`display_name`_ is not defined. Set it to False to keep the name as you set it in the _`build`_ method. |
|
||||||
| _`refresh: bool`_ | If set to True a button will appear to the right of the field, and when clicked, it will call the _`update_build_config`_ method which takes in the _`build_config`_, the name of the field (_`field_name`_) and the latest value of the field (_`field_value`_). This is useful when you want to update the _`build_config`_ based on the value of the field. |
|
| _`refresh_button: bool`_ | If set to True a button will appear to the right of the field, and when clicked, it will call the _`update_build_config`_ method which takes in the _`build_config`_, the name of the field (_`field_name`_) and the latest value of the field (_`field_value`_). This is useful when you want to update the _`build_config`_ based on the value of the field. |
|
||||||
|
| _`real_time_refresh: bool`_ | If set to True, the _`update_build_config`_ method will be called every time the field value changes. |
|
||||||
|
|
||||||
<Admonition type="info" label="Tip">
|
<Admonition type="info" label="Tip">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from langchain.prompts import PromptTemplate
|
|
||||||
from pydantic import BaseModel, field_validator, model_serializer
|
from pydantic import BaseModel, field_validator, model_serializer
|
||||||
|
|
||||||
from langflow.interface.utils import extract_input_variables_from_prompt
|
|
||||||
from langflow.template.frontend_node.base import FrontendNode
|
from langflow.template.frontend_node.base import FrontendNode
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,22 +78,6 @@ INVALID_NAMES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_prompt(template: str):
|
|
||||||
input_variables = extract_input_variables_from_prompt(template)
|
|
||||||
|
|
||||||
# Check if there are invalid characters in the input_variables
|
|
||||||
input_variables = check_input_variables(input_variables)
|
|
||||||
if any(var in INVALID_NAMES for var in input_variables):
|
|
||||||
raise ValueError(f"Invalid input variables. None of the variables can be named {', '.join(input_variables)}. ")
|
|
||||||
|
|
||||||
try:
|
|
||||||
PromptTemplate(template=template, input_variables=input_variables)
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError(f"Invalid prompt: {exc}") from exc
|
|
||||||
|
|
||||||
return input_variables
|
|
||||||
|
|
||||||
|
|
||||||
def is_json_like(var):
|
def is_json_like(var):
|
||||||
if var.startswith("{{") and var.endswith("}}"):
|
if var.startswith("{{") and var.endswith("}}"):
|
||||||
# If it is a double brance variable
|
# If it is a double brance variable
|
||||||
|
|
@ -121,7 +103,9 @@ def fix_variable(var, invalid_chars, wrong_variables):
|
||||||
# Handle variables starting with a number
|
# Handle variables starting with a number
|
||||||
if var[0].isdigit():
|
if var[0].isdigit():
|
||||||
invalid_chars.append(var[0])
|
invalid_chars.append(var[0])
|
||||||
new_var, invalid_chars, wrong_variables = fix_variable(var[1:], invalid_chars, wrong_variables)
|
new_var, invalid_chars, wrong_variables = fix_variable(
|
||||||
|
var[1:], invalid_chars, wrong_variables
|
||||||
|
)
|
||||||
|
|
||||||
# Temporarily replace {{ and }} to avoid treating them as invalid
|
# Temporarily replace {{ and }} to avoid treating them as invalid
|
||||||
new_var = new_var.replace("{{", "ᴛᴇᴍᴘᴏᴘᴇɴ").replace("}}", "ᴛᴇᴍᴘᴄʟᴏsᴇ")
|
new_var = new_var.replace("{{", "ᴛᴇᴍᴘᴏᴘᴇɴ").replace("}}", "ᴛᴇᴍᴘᴄʟᴏsᴇ")
|
||||||
|
|
@ -148,7 +132,9 @@ def check_variable(var, invalid_chars, wrong_variables, empty_variables):
|
||||||
return wrong_variables, empty_variables
|
return wrong_variables, empty_variables
|
||||||
|
|
||||||
|
|
||||||
def check_for_errors(input_variables, fixed_variables, wrong_variables, empty_variables):
|
def check_for_errors(
|
||||||
|
input_variables, fixed_variables, wrong_variables, empty_variables
|
||||||
|
):
|
||||||
if any(var for var in input_variables if var not in fixed_variables):
|
if any(var for var in input_variables if var not in fixed_variables):
|
||||||
error_message = (
|
error_message = (
|
||||||
f"Error: Input variables contain invalid characters or formats. \n"
|
f"Error: Input variables contain invalid characters or formats. \n"
|
||||||
|
|
@ -173,11 +159,17 @@ def check_input_variables(input_variables):
|
||||||
if is_json_like(var):
|
if is_json_like(var):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_var, wrong_variables, empty_variables = fix_variable(var, invalid_chars, wrong_variables)
|
new_var, wrong_variables, empty_variables = fix_variable(
|
||||||
wrong_variables, empty_variables = check_variable(var, INVALID_CHARACTERS, wrong_variables, empty_variables)
|
var, invalid_chars, wrong_variables
|
||||||
|
)
|
||||||
|
wrong_variables, empty_variables = check_variable(
|
||||||
|
var, INVALID_CHARACTERS, wrong_variables, empty_variables
|
||||||
|
)
|
||||||
fixed_variables.append(new_var)
|
fixed_variables.append(new_var)
|
||||||
variables_to_check.append(var)
|
variables_to_check.append(var)
|
||||||
|
|
||||||
check_for_errors(variables_to_check, fixed_variables, wrong_variables, empty_variables)
|
check_for_errors(
|
||||||
|
variables_to_check, fixed_variables, wrong_variables, empty_variables
|
||||||
|
)
|
||||||
|
|
||||||
return fixed_variables
|
return fixed_variables
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ async def build_vertex(
|
||||||
current_user=Depends(get_current_active_user),
|
current_user=Depends(get_current_active_user),
|
||||||
):
|
):
|
||||||
"""Build a vertex instead of the entire graph."""
|
"""Build a vertex instead of the entire graph."""
|
||||||
{"inputs": {"input_value": "some value"}}
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
next_vertices_ids = []
|
next_vertices_ids = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -101,8 +101,12 @@ async def build_vertex(
|
||||||
cache = chat_service.get_cache(flow_id)
|
cache = chat_service.get_cache(flow_id)
|
||||||
if not cache:
|
if not cache:
|
||||||
# If there's no cache
|
# If there's no cache
|
||||||
logger.warning(f"No cache found for {flow_id}. Building graph starting at {vertex_id}")
|
logger.warning(
|
||||||
graph = build_and_cache_graph(flow_id=flow_id, session=next(get_session()), chat_service=chat_service)
|
f"No cache found for {flow_id}. Building graph starting at {vertex_id}"
|
||||||
|
)
|
||||||
|
graph = build_and_cache_graph(
|
||||||
|
flow_id=flow_id, session=next(get_session()), chat_service=chat_service
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
graph = cache.get("result")
|
graph = cache.get("result")
|
||||||
result_data_response = ResultDataResponse(results={})
|
result_data_response = ResultDataResponse(results={})
|
||||||
|
|
@ -122,7 +126,9 @@ async def build_vertex(
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"No result found for vertex {vertex_id}")
|
raise ValueError(f"No result found for vertex {vertex_id}")
|
||||||
next_vertices_ids = vertex.successors_ids
|
next_vertices_ids = vertex.successors_ids
|
||||||
next_vertices_ids = [v for v in next_vertices_ids if graph.should_run_vertex(v)]
|
next_vertices_ids = [
|
||||||
|
v for v in next_vertices_ids if graph.should_run_vertex(v)
|
||||||
|
]
|
||||||
|
|
||||||
result_data_response = ResultDataResponse(**result_dict.model_dump())
|
result_data_response = ResultDataResponse(**result_dict.model_dump())
|
||||||
|
|
||||||
|
|
@ -205,7 +211,9 @@ async def build_vertex_stream(
|
||||||
else:
|
else:
|
||||||
graph = cache.get("result")
|
graph = cache.get("result")
|
||||||
else:
|
else:
|
||||||
session_data = await session_service.load_session(session_id, flow_id=flow_id)
|
session_data = await session_service.load_session(
|
||||||
|
session_id, flow_id=flow_id
|
||||||
|
)
|
||||||
graph, artifacts = session_data if session_data else (None, None)
|
graph, artifacts = session_data if session_data else (None, None)
|
||||||
if not graph:
|
if not graph:
|
||||||
raise ValueError(f"No graph found for {flow_id}.")
|
raise ValueError(f"No graph found for {flow_id}.")
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,14 @@ from langflow.api.v1.base import (
|
||||||
CodeValidationResponse,
|
CodeValidationResponse,
|
||||||
PromptValidationResponse,
|
PromptValidationResponse,
|
||||||
ValidatePromptRequest,
|
ValidatePromptRequest,
|
||||||
|
)
|
||||||
|
from langflow.base.prompts.utils import (
|
||||||
|
add_new_variables_to_template,
|
||||||
|
get_old_custom_fields,
|
||||||
|
remove_old_variables_from_template,
|
||||||
|
update_input_variables_field,
|
||||||
validate_prompt,
|
validate_prompt,
|
||||||
)
|
)
|
||||||
from langflow.template.field.prompt import DefaultPromptField
|
|
||||||
from langflow.utils.validate import validate_code
|
from langflow.utils.validate import validate_code
|
||||||
|
|
||||||
# build router
|
# build router
|
||||||
|
|
@ -37,13 +42,28 @@ def post_validate_prompt(prompt_request: ValidatePromptRequest):
|
||||||
input_variables=input_variables,
|
input_variables=input_variables,
|
||||||
frontend_node=None,
|
frontend_node=None,
|
||||||
)
|
)
|
||||||
old_custom_fields = get_old_custom_fields(prompt_request)
|
old_custom_fields = get_old_custom_fields(
|
||||||
|
prompt_request.custom_fields, prompt_request.name
|
||||||
|
)
|
||||||
|
|
||||||
add_new_variables_to_template(input_variables, prompt_request)
|
add_new_variables_to_template(
|
||||||
|
input_variables,
|
||||||
|
prompt_request.custom_fields,
|
||||||
|
prompt_request.frontend_node.template,
|
||||||
|
prompt_request.name,
|
||||||
|
)
|
||||||
|
|
||||||
remove_old_variables_from_template(old_custom_fields, input_variables, prompt_request)
|
remove_old_variables_from_template(
|
||||||
|
old_custom_fields,
|
||||||
|
input_variables,
|
||||||
|
prompt_request.custom_fields,
|
||||||
|
prompt_request.frontend_node.template,
|
||||||
|
prompt_request.name,
|
||||||
|
)
|
||||||
|
|
||||||
update_input_variables_field(input_variables, prompt_request)
|
update_input_variables_field(
|
||||||
|
input_variables, prompt_request.frontend_node.template
|
||||||
|
)
|
||||||
|
|
||||||
return PromptValidationResponse(
|
return PromptValidationResponse(
|
||||||
input_variables=input_variables,
|
input_variables=input_variables,
|
||||||
|
|
@ -52,61 +72,3 @@ def post_validate_prompt(prompt_request: ValidatePromptRequest):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
def get_old_custom_fields(prompt_request):
|
|
||||||
try:
|
|
||||||
if len(prompt_request.frontend_node.custom_fields) == 1 and prompt_request.name == "":
|
|
||||||
# If there is only one custom field and the name is empty string
|
|
||||||
# then we are dealing with the first prompt request after the node was created
|
|
||||||
prompt_request.name = list(prompt_request.frontend_node.custom_fields.keys())[0]
|
|
||||||
|
|
||||||
old_custom_fields = prompt_request.frontend_node.custom_fields[prompt_request.name]
|
|
||||||
if old_custom_fields is None:
|
|
||||||
old_custom_fields = []
|
|
||||||
|
|
||||||
old_custom_fields = old_custom_fields.copy()
|
|
||||||
except KeyError:
|
|
||||||
old_custom_fields = []
|
|
||||||
prompt_request.frontend_node.custom_fields[prompt_request.name] = []
|
|
||||||
return old_custom_fields
|
|
||||||
|
|
||||||
|
|
||||||
def add_new_variables_to_template(input_variables, prompt_request):
|
|
||||||
for variable in input_variables:
|
|
||||||
try:
|
|
||||||
template_field = DefaultPromptField(name=variable, display_name=variable)
|
|
||||||
if variable in prompt_request.frontend_node.template:
|
|
||||||
# Set the new field with the old value
|
|
||||||
template_field.value = prompt_request.frontend_node.template[variable]["value"]
|
|
||||||
|
|
||||||
prompt_request.frontend_node.template[variable] = template_field.to_dict()
|
|
||||||
|
|
||||||
# Check if variable is not already in the list before appending
|
|
||||||
if variable not in prompt_request.frontend_node.custom_fields[prompt_request.name]:
|
|
||||||
prompt_request.frontend_node.custom_fields[prompt_request.name].append(variable)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(exc)
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def remove_old_variables_from_template(old_custom_fields, input_variables, prompt_request):
|
|
||||||
for variable in old_custom_fields:
|
|
||||||
if variable not in input_variables:
|
|
||||||
try:
|
|
||||||
# Remove the variable from custom_fields associated with the given name
|
|
||||||
if variable in prompt_request.frontend_node.custom_fields[prompt_request.name]:
|
|
||||||
prompt_request.frontend_node.custom_fields[prompt_request.name].remove(variable)
|
|
||||||
|
|
||||||
# Remove the variable from the template
|
|
||||||
prompt_request.frontend_node.template.pop(variable, None)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(exc)
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def update_input_variables_field(input_variables, prompt_request):
|
|
||||||
if "input_variables" in prompt_request.frontend_node.template:
|
|
||||||
prompt_request.frontend_node.template["input_variables"]["value"] = input_variables
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from langchain.prompts import PromptTemplate
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from langflow.api.v1.base import INVALID_NAMES, check_input_variables
|
||||||
|
from langflow.interface.utils import extract_input_variables_from_prompt
|
||||||
from langflow.schema import Record
|
from langflow.schema import Record
|
||||||
|
from langflow.template.field.prompt import DefaultPromptField
|
||||||
|
|
||||||
|
|
||||||
def dict_values_to_string(d: dict) -> dict:
|
def dict_values_to_string(d: dict) -> dict:
|
||||||
|
|
@ -53,3 +59,83 @@ def document_to_string(document: Document) -> str:
|
||||||
str: The document as a string.
|
str: The document as a string.
|
||||||
"""
|
"""
|
||||||
return document.page_content
|
return document.page_content
|
||||||
|
|
||||||
|
|
||||||
|
def validate_prompt(prompt_template: str, silent_errors: bool = False) -> list[str]:
|
||||||
|
input_variables = extract_input_variables_from_prompt(prompt_template)
|
||||||
|
|
||||||
|
# Check if there are invalid characters in the input_variables
|
||||||
|
input_variables = check_input_variables(input_variables)
|
||||||
|
if any(var in INVALID_NAMES for var in input_variables):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid input variables. None of the variables can be named {', '.join(input_variables)}. "
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
PromptTemplate(template=prompt_template, input_variables=input_variables)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Invalid prompt: {exc}")
|
||||||
|
if not silent_errors:
|
||||||
|
raise ValueError(f"Invalid prompt: {exc}") from exc
|
||||||
|
|
||||||
|
return input_variables
|
||||||
|
|
||||||
|
|
||||||
|
def get_old_custom_fields(custom_fields, name):
|
||||||
|
try:
|
||||||
|
if len(custom_fields) == 1 and name == "":
|
||||||
|
# If there is only one custom field and the name is empty string
|
||||||
|
# then we are dealing with the first prompt request after the node was created
|
||||||
|
name = list(custom_fields.keys())[0]
|
||||||
|
|
||||||
|
old_custom_fields = custom_fields[name]
|
||||||
|
if not old_custom_fields:
|
||||||
|
old_custom_fields = []
|
||||||
|
|
||||||
|
old_custom_fields = old_custom_fields.copy()
|
||||||
|
except KeyError:
|
||||||
|
old_custom_fields = []
|
||||||
|
custom_fields[name] = []
|
||||||
|
return old_custom_fields
|
||||||
|
|
||||||
|
|
||||||
|
def add_new_variables_to_template(input_variables, custom_fields, template, name):
|
||||||
|
for variable in input_variables:
|
||||||
|
try:
|
||||||
|
template_field = DefaultPromptField(name=variable, display_name=variable)
|
||||||
|
if variable in template:
|
||||||
|
# Set the new field with the old value
|
||||||
|
template_field.value = template[variable]["value"]
|
||||||
|
|
||||||
|
template[variable] = template_field.to_dict()
|
||||||
|
|
||||||
|
# Check if variable is not already in the list before appending
|
||||||
|
if variable not in custom_fields[name]:
|
||||||
|
custom_fields[name].append(variable)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def remove_old_variables_from_template(
|
||||||
|
old_custom_fields, input_variables, custom_fields, template, name
|
||||||
|
):
|
||||||
|
for variable in old_custom_fields:
|
||||||
|
if variable not in input_variables:
|
||||||
|
try:
|
||||||
|
# Remove the variable from custom_fields associated with the given name
|
||||||
|
if variable in custom_fields[name]:
|
||||||
|
custom_fields[name].remove(variable)
|
||||||
|
|
||||||
|
# Remove the variable from the template
|
||||||
|
template.pop(variable, None)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def update_input_variables_field(input_variables, template):
|
||||||
|
if "input_variables" in template:
|
||||||
|
template["input_variables"]["value"] = input_variables
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
from langflow import CustomComponent
|
||||||
from langflow.schema import Record
|
from langflow.schema import Record
|
||||||
from langflow.services.database.models.base import orjson_dumps
|
|
||||||
|
|
||||||
|
|
||||||
class APIRequest(CustomComponent):
|
class APIRequest(CustomComponent):
|
||||||
|
|
@ -52,35 +51,44 @@ class APIRequest(CustomComponent):
|
||||||
timeout: int = 5,
|
timeout: int = 5,
|
||||||
) -> Record:
|
) -> Record:
|
||||||
method = method.upper()
|
method = method.upper()
|
||||||
if method not in ["GET", "POST", "PATCH", "PUT"]:
|
if method not in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
|
||||||
raise ValueError(f"Unsupported method: {method}")
|
raise ValueError(f"Unsupported method: {method}")
|
||||||
|
|
||||||
data = body if body else None
|
data = body if body else None
|
||||||
data = json.dumps(data)
|
data = json.dumps(data)
|
||||||
try:
|
try:
|
||||||
response = await client.request(method, url, headers=headers, content=data, timeout=timeout)
|
response = await client.request(
|
||||||
|
method, url, headers=headers, content=data, timeout=timeout
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response_json = response.json()
|
result = response.json()
|
||||||
result = orjson_dumps(response_json, indent_2=False)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
result = response.text
|
result = response.text
|
||||||
return Record(
|
return Record(
|
||||||
text=result,
|
|
||||||
data={
|
data={
|
||||||
"source": url,
|
"source": url,
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
|
"result": result,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return Record(
|
return Record(
|
||||||
text="Request Timed Out",
|
data={
|
||||||
data={"source": url, "headers": headers, "status_code": 408},
|
"source": url,
|
||||||
|
"headers": headers,
|
||||||
|
"status_code": 408,
|
||||||
|
"error": "Request timed out",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return Record(
|
return Record(
|
||||||
text=str(exc),
|
data={
|
||||||
data={"source": url, "headers": headers, "status_code": 500},
|
"source": url,
|
||||||
|
"headers": headers,
|
||||||
|
"status_code": 500,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def build(
|
async def build(
|
||||||
|
|
@ -88,15 +96,23 @@ class APIRequest(CustomComponent):
|
||||||
method: str,
|
method: str,
|
||||||
url: List[str],
|
url: List[str],
|
||||||
headers: Optional[dict] = None,
|
headers: Optional[dict] = None,
|
||||||
body: Optional[dict] = None,
|
body: Optional[List[Record]] = None,
|
||||||
timeout: int = 5,
|
timeout: int = 5,
|
||||||
) -> List[Record]:
|
) -> List[Record]:
|
||||||
if headers is None:
|
if headers is None:
|
||||||
headers = {}
|
headers = {}
|
||||||
urls = url if isinstance(url, list) else [url]
|
urls = url if isinstance(url, list) else [url]
|
||||||
bodies = body if isinstance(body, list) else [body] if body else [None] * len(urls)
|
bodies = []
|
||||||
|
if body:
|
||||||
|
if isinstance(body, list):
|
||||||
|
bodies = [b.data for b in body]
|
||||||
|
else:
|
||||||
|
bodies = [body.data]
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*[self.make_request(client, method, u, headers, rec, timeout) for u, rec in zip(urls, bodies)]
|
*[
|
||||||
|
self.make_request(client, method, u, headers, rec, timeout)
|
||||||
|
for u, rec in zip(urls, bodies)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ class TextToRecordComponent(CustomComponent):
|
||||||
"display_name": "Mode",
|
"display_name": "Mode",
|
||||||
"options": ["Text", "Number"],
|
"options": ["Text", "Number"],
|
||||||
"info": "The mode to use for creating the record.",
|
"info": "The mode to use for creating the record.",
|
||||||
"refresh": True,
|
"real_time_refresh": True,
|
||||||
"input_types": [],
|
"input_types": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ class UUIDGeneratorComponent(CustomComponent):
|
||||||
display_name = "Unique ID Generator"
|
display_name = "Unique ID Generator"
|
||||||
description = "Generates a unique ID."
|
description = "Generates a unique ID."
|
||||||
|
|
||||||
def update_build_config(self, build_config: dict, field_name: Text, field_value: Any):
|
def update_build_config(
|
||||||
|
self, build_config: dict, field_name: Text, field_value: Any
|
||||||
|
):
|
||||||
if field_name == "unique_id":
|
if field_name == "unique_id":
|
||||||
build_config[field_name]["value"] = str(uuid.uuid4())
|
build_config[field_name]["value"] = str(uuid.uuid4())
|
||||||
return build_config
|
return build_config
|
||||||
|
|
@ -18,7 +20,7 @@ class UUIDGeneratorComponent(CustomComponent):
|
||||||
return {
|
return {
|
||||||
"unique_id": {
|
"unique_id": {
|
||||||
"display_name": "Value",
|
"display_name": "Value",
|
||||||
"refresh": True,
|
"real_time_refresh": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
|
||||||
from langflow.schema import Record
|
|
||||||
from langflow.template.field.base import TemplateField
|
|
||||||
|
|
||||||
|
|
||||||
class RecordComponent(CustomComponent):
|
|
||||||
display_name = "Record Numbers"
|
|
||||||
description = "A component to create a record from key-value pairs."
|
|
||||||
field_order = ["n_keys"]
|
|
||||||
|
|
||||||
def update_build_config(self, build_config: dict, field_name: str, field_value: Any):
|
|
||||||
if field_value is None:
|
|
||||||
return
|
|
||||||
elif int(field_value) == 0:
|
|
||||||
keep = ["n_keys", "code"]
|
|
||||||
for key in build_config.copy():
|
|
||||||
if key in keep:
|
|
||||||
continue
|
|
||||||
del build_config[key]
|
|
||||||
build_config[field_name]["value"] = int(field_value)
|
|
||||||
|
|
||||||
# Add new fields depending on the field value
|
|
||||||
for i in range(int(field_value)):
|
|
||||||
field = TemplateField(
|
|
||||||
name=f"Key and Value {i}",
|
|
||||||
field_type="dict",
|
|
||||||
display_name="",
|
|
||||||
info="The key for the record.",
|
|
||||||
input_types=["Text"],
|
|
||||||
)
|
|
||||||
build_config[field.name] = field.to_dict()
|
|
||||||
|
|
||||||
def build_config(self):
|
|
||||||
return {
|
|
||||||
"n_keys": {
|
|
||||||
"display_name": "Number of Fields",
|
|
||||||
"refresh": True,
|
|
||||||
"info": "The number of keys to create in the record.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def build(self, n_keys: int, **kwargs) -> Record:
|
|
||||||
data = {k: v for d in kwargs.values() for k, v in d.items()}
|
|
||||||
record = Record(data=data)
|
|
||||||
return record
|
|
||||||
|
|
@ -9,6 +9,7 @@ class PromptComponent(CustomComponent):
|
||||||
display_name: str = "Prompt"
|
display_name: str = "Prompt"
|
||||||
description: str = "A component for creating prompts using templates"
|
description: str = "A component for creating prompts using templates"
|
||||||
beta = True
|
beta = True
|
||||||
|
icon = "terminal-square"
|
||||||
|
|
||||||
def build_config(self):
|
def build_config(self):
|
||||||
return {
|
return {
|
||||||
|
|
@ -7,7 +7,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class AmazonBedrockComponent(LCModelComponent):
|
class AmazonBedrockComponent(LCModelComponent):
|
||||||
display_name: str = "Amazon Bedrock Model"
|
display_name: str = "Amazon Bedrock"
|
||||||
description: str = "Generate text using LLM model from Amazon Bedrock."
|
description: str = "Generate text using LLM model from Amazon Bedrock."
|
||||||
icon = "Amazon"
|
icon = "Amazon"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class AnthropicLLM(LCModelComponent):
|
class AnthropicLLM(LCModelComponent):
|
||||||
display_name: str = "AnthropicModel"
|
display_name: str = "Anthropic"
|
||||||
description: str = "Generate text using Anthropic Chat&Completion large language models."
|
description: str = "Generate text using Anthropic Chat&Completion large language models."
|
||||||
icon = "Anthropic"
|
icon = "Anthropic"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class AzureChatOpenAIComponent(LCModelComponent):
|
class AzureChatOpenAIComponent(LCModelComponent):
|
||||||
display_name: str = "AzureOpenAI Model"
|
display_name: str = "AzureOpenAI"
|
||||||
description: str = "Generate text using LLM model from Azure OpenAI."
|
description: str = "Generate text using LLM model from Azure OpenAI."
|
||||||
documentation: str = "https://python.langchain.com/docs/integrations/llms/azure_openai"
|
documentation: str = "https://python.langchain.com/docs/integrations/llms/azure_openai"
|
||||||
beta = False
|
beta = False
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class QianfanChatEndpointComponent(LCModelComponent):
|
class QianfanChatEndpointComponent(LCModelComponent):
|
||||||
display_name: str = "QianfanChat Model"
|
display_name: str = "QianfanChat"
|
||||||
description: str = (
|
description: str = (
|
||||||
"Generate text using Baidu Qianfan chat models. Get more detail from "
|
"Generate text using Baidu Qianfan chat models. Get more detail from "
|
||||||
"https://python.langchain.com/docs/integrations/chat/baidu_qianfan_endpoint."
|
"https://python.langchain.com/docs/integrations/chat/baidu_qianfan_endpoint."
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class CTransformersComponent(LCModelComponent):
|
class CTransformersComponent(LCModelComponent):
|
||||||
display_name = "CTransformersModel"
|
display_name = "CTransformers"
|
||||||
description = "Generate text using CTransformers LLM models"
|
description = "Generate text using CTransformers LLM models"
|
||||||
documentation = "https://python.langchain.com/docs/modules/model_io/models/llms/integrations/ctransformers"
|
documentation = "https://python.langchain.com/docs/modules/model_io/models/llms/integrations/ctransformers"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class CohereComponent(LCModelComponent):
|
class CohereComponent(LCModelComponent):
|
||||||
display_name = "CohereModel"
|
display_name = "Cohere"
|
||||||
description = "Generate text using Cohere large language models."
|
description = "Generate text using Cohere large language models."
|
||||||
documentation = "https://python.langchain.com/docs/modules/model_io/models/llms/integrations/cohere"
|
documentation = "https://python.langchain.com/docs/modules/model_io/models/llms/integrations/cohere"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from langflow.field_typing import RangeSpec, Text
|
||||||
|
|
||||||
|
|
||||||
class GoogleGenerativeAIComponent(LCModelComponent):
|
class GoogleGenerativeAIComponent(LCModelComponent):
|
||||||
display_name: str = "Google Generative AIModel"
|
display_name: str = "Google Generative AI"
|
||||||
description: str = "Generate text using Google Generative AI to generate text."
|
description: str = "Generate text using Google Generative AI to generate text."
|
||||||
icon = "GoogleGenerativeAI"
|
icon = "GoogleGenerativeAI"
|
||||||
icon = "Google"
|
icon = "Google"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class HuggingFaceEndpointsComponent(LCModelComponent):
|
class HuggingFaceEndpointsComponent(LCModelComponent):
|
||||||
display_name: str = "Hugging Face Inference API models"
|
display_name: str = "Hugging Face Inference API"
|
||||||
description: str = "Generate text using LLM model from Hugging Face Inference API."
|
description: str = "Generate text using LLM model from Hugging Face Inference API."
|
||||||
icon = "HuggingFace"
|
icon = "HuggingFace"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class LlamaCppComponent(LCModelComponent):
|
class LlamaCppComponent(LCModelComponent):
|
||||||
display_name = "LlamaCppModel"
|
display_name = "LlamaCpp"
|
||||||
description = "Generate text using llama.cpp model."
|
description = "Generate text using llama.cpp model."
|
||||||
documentation = "https://python.langchain.com/docs/modules/model_io/models/llms/integrations/llamacpp"
|
documentation = "https://python.langchain.com/docs/modules/model_io/models/llms/integrations/llamacpp"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class ChatOllamaComponent(LCModelComponent):
|
class ChatOllamaComponent(LCModelComponent):
|
||||||
display_name = "ChatOllamaModel"
|
display_name = "ChatOllama"
|
||||||
description = "Generate text using Local LLM for chat with Ollama."
|
description = "Generate text using Local LLM for chat with Ollama."
|
||||||
icon = "Ollama"
|
icon = "Ollama"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from langflow.field_typing import NestedDict, Text
|
||||||
|
|
||||||
|
|
||||||
class OpenAIModelComponent(LCModelComponent):
|
class OpenAIModelComponent(LCModelComponent):
|
||||||
display_name = "OpenAI Model"
|
display_name = "OpenAI"
|
||||||
description = "Generates text using OpenAI's models."
|
description = "Generates text using OpenAI's models."
|
||||||
icon = "OpenAI"
|
icon = "OpenAI"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
class ChatVertexAIComponent(LCModelComponent):
|
class ChatVertexAIComponent(LCModelComponent):
|
||||||
display_name = "ChatVertexAIModel"
|
display_name = "ChatVertexAI"
|
||||||
description = "Generate text using Vertex AI Chat large language models API."
|
description = "Generate text using Vertex AI Chat large language models API."
|
||||||
icon = "VertexAI"
|
icon = "VertexAI"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ class Graph:
|
||||||
"""Returns the state of the graph."""
|
"""Returns the state of the graph."""
|
||||||
return self.state_manager.get_state(name, run_id=self._run_id)
|
return self.state_manager.get_state(name, run_id=self._run_id)
|
||||||
|
|
||||||
def update_state(self, name: str, record: Union[str, Record], caller: Optional[str] = None) -> None:
|
def update_state(
|
||||||
|
self, name: str, record: Union[str, Record], caller: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Updates the state of the graph."""
|
"""Updates the state of the graph."""
|
||||||
if caller:
|
if caller:
|
||||||
# If there is a caller which is a vertex_id, I want to activate
|
# If there is a caller which is a vertex_id, I want to activate
|
||||||
|
|
@ -108,7 +110,9 @@ class Graph:
|
||||||
def reset_activated_vertices(self):
|
def reset_activated_vertices(self):
|
||||||
self.activated_vertices = []
|
self.activated_vertices = []
|
||||||
|
|
||||||
def append_state(self, name: str, record: Union[str, Record], caller: Optional[str] = None) -> None:
|
def append_state(
|
||||||
|
self, name: str, record: Union[str, Record], caller: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Appends the state of the graph."""
|
"""Appends the state of the graph."""
|
||||||
if caller:
|
if caller:
|
||||||
self.activate_state_vertices(name, caller)
|
self.activate_state_vertices(name, caller)
|
||||||
|
|
@ -156,7 +160,10 @@ class Graph:
|
||||||
"""Runs the graph with the given inputs."""
|
"""Runs the graph with the given inputs."""
|
||||||
for vertex_id in self._is_input_vertices:
|
for vertex_id in self._is_input_vertices:
|
||||||
vertex = self.get_vertex(vertex_id)
|
vertex = self.get_vertex(vertex_id)
|
||||||
if input_components and (vertex_id not in input_components or vertex.display_name not in input_components):
|
if input_components and (
|
||||||
|
vertex_id not in input_components
|
||||||
|
or vertex.display_name not in input_components
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if vertex is None:
|
if vertex is None:
|
||||||
raise ValueError(f"Vertex {vertex_id} not found")
|
raise ValueError(f"Vertex {vertex_id} not found")
|
||||||
|
|
@ -179,9 +186,13 @@ class Graph:
|
||||||
if vertex is None:
|
if vertex is None:
|
||||||
raise ValueError(f"Vertex {vertex_id} not found")
|
raise ValueError(f"Vertex {vertex_id} not found")
|
||||||
|
|
||||||
if not vertex.result and not stream and hasattr(vertex, "consume_async_generator"):
|
if (
|
||||||
|
not vertex.result
|
||||||
|
and not stream
|
||||||
|
and hasattr(vertex, "consume_async_generator")
|
||||||
|
):
|
||||||
await vertex.consume_async_generator()
|
await vertex.consume_async_generator()
|
||||||
if vertex.display_name in outputs or vertex.id in outputs:
|
if not outputs or (vertex.display_name in outputs or vertex.id in outputs):
|
||||||
vertex_outputs.append(vertex.result)
|
vertex_outputs.append(vertex.result)
|
||||||
return vertex_outputs
|
return vertex_outputs
|
||||||
|
|
||||||
|
|
@ -189,8 +200,8 @@ class Graph:
|
||||||
self,
|
self,
|
||||||
inputs: Dict[str, Union[str, list[str]]],
|
inputs: Dict[str, Union[str, list[str]]],
|
||||||
outputs: list[str],
|
outputs: list[str],
|
||||||
stream: bool,
|
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
stream: Optional[bool] = False,
|
||||||
) -> List[Optional["ResultData"]]:
|
) -> List[Optional["ResultData"]]:
|
||||||
"""Runs the graph with the given inputs."""
|
"""Runs the graph with the given inputs."""
|
||||||
|
|
||||||
|
|
@ -257,7 +268,9 @@ class Graph:
|
||||||
def build_parent_child_map(self):
|
def build_parent_child_map(self):
|
||||||
parent_child_map = defaultdict(list)
|
parent_child_map = defaultdict(list)
|
||||||
for vertex in self.vertices:
|
for vertex in self.vertices:
|
||||||
parent_child_map[vertex.id] = [child.id for child in self.get_successors(vertex)]
|
parent_child_map[vertex.id] = [
|
||||||
|
child.id for child in self.get_successors(vertex)
|
||||||
|
]
|
||||||
return parent_child_map
|
return parent_child_map
|
||||||
|
|
||||||
def increment_run_count(self):
|
def increment_run_count(self):
|
||||||
|
|
@ -442,7 +455,11 @@ class Graph:
|
||||||
"""Updates the edges of a vertex."""
|
"""Updates the edges of a vertex."""
|
||||||
# Vertex has edges, so we need to update the edges
|
# Vertex has edges, so we need to update the edges
|
||||||
for edge in vertex.edges:
|
for edge in vertex.edges:
|
||||||
if edge not in self.edges and edge.source_id in self.vertex_map and edge.target_id in self.vertex_map:
|
if (
|
||||||
|
edge not in self.edges
|
||||||
|
and edge.source_id in self.vertex_map
|
||||||
|
and edge.target_id in self.vertex_map
|
||||||
|
):
|
||||||
self.edges.append(edge)
|
self.edges.append(edge)
|
||||||
|
|
||||||
def _build_graph(self) -> None:
|
def _build_graph(self) -> None:
|
||||||
|
|
@ -467,7 +484,11 @@ class Graph:
|
||||||
return
|
return
|
||||||
self.vertices.remove(vertex)
|
self.vertices.remove(vertex)
|
||||||
self.vertex_map.pop(vertex_id)
|
self.vertex_map.pop(vertex_id)
|
||||||
self.edges = [edge for edge in self.edges if edge.source_id != vertex_id and edge.target_id != vertex_id]
|
self.edges = [
|
||||||
|
edge
|
||||||
|
for edge in self.edges
|
||||||
|
if edge.source_id != vertex_id and edge.target_id != vertex_id
|
||||||
|
]
|
||||||
|
|
||||||
def _build_vertex_params(self) -> None:
|
def _build_vertex_params(self) -> None:
|
||||||
"""Identifies and handles the LLM vertex within the graph."""
|
"""Identifies and handles the LLM vertex within the graph."""
|
||||||
|
|
@ -488,7 +509,9 @@ class Graph:
|
||||||
return
|
return
|
||||||
for vertex in self.vertices:
|
for vertex in self.vertices:
|
||||||
if not self._validate_vertex(vertex):
|
if not self._validate_vertex(vertex):
|
||||||
raise ValueError(f"{vertex.display_name} is not connected to any other components")
|
raise ValueError(
|
||||||
|
f"{vertex.display_name} is not connected to any other components"
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_vertex(self, vertex: Vertex) -> bool:
|
def _validate_vertex(self, vertex: Vertex) -> bool:
|
||||||
"""Validates a vertex."""
|
"""Validates a vertex."""
|
||||||
|
|
@ -550,7 +573,9 @@ class Graph:
|
||||||
name=f"{vertex.display_name} Run {vertex_task_run_count.get(vertex_id, 0)}",
|
name=f"{vertex.display_name} Run {vertex_task_run_count.get(vertex_id, 0)}",
|
||||||
)
|
)
|
||||||
tasks.append(task)
|
tasks.append(task)
|
||||||
vertex_task_run_count[vertex_id] = vertex_task_run_count.get(vertex_id, 0) + 1
|
vertex_task_run_count[vertex_id] = (
|
||||||
|
vertex_task_run_count.get(vertex_id, 0) + 1
|
||||||
|
)
|
||||||
logger.debug(f"Running layer {layer_index} with {len(tasks)} tasks")
|
logger.debug(f"Running layer {layer_index} with {len(tasks)} tasks")
|
||||||
await self._execute_tasks(tasks)
|
await self._execute_tasks(tasks)
|
||||||
logger.debug("Graph processing complete")
|
logger.debug("Graph processing complete")
|
||||||
|
|
@ -592,7 +617,9 @@ class Graph:
|
||||||
def dfs(vertex):
|
def dfs(vertex):
|
||||||
if state[vertex] == 1:
|
if state[vertex] == 1:
|
||||||
# We have a cycle
|
# We have a cycle
|
||||||
raise ValueError("Graph contains a cycle, cannot perform topological sort")
|
raise ValueError(
|
||||||
|
"Graph contains a cycle, cannot perform topological sort"
|
||||||
|
)
|
||||||
if state[vertex] == 0:
|
if state[vertex] == 0:
|
||||||
state[vertex] = 1
|
state[vertex] = 1
|
||||||
for edge in vertex.edges:
|
for edge in vertex.edges:
|
||||||
|
|
@ -616,7 +643,10 @@ class Graph:
|
||||||
|
|
||||||
def get_predecessors(self, vertex):
|
def get_predecessors(self, vertex):
|
||||||
"""Returns the predecessors of a vertex."""
|
"""Returns the predecessors of a vertex."""
|
||||||
return [self.get_vertex(source_id) for source_id in self.predecessor_map.get(vertex.id, [])]
|
return [
|
||||||
|
self.get_vertex(source_id)
|
||||||
|
for source_id in self.predecessor_map.get(vertex.id, [])
|
||||||
|
]
|
||||||
|
|
||||||
def get_all_successors(self, vertex, recursive=True, flat=True):
|
def get_all_successors(self, vertex, recursive=True, flat=True):
|
||||||
# Recursively get the successors of the current vertex
|
# Recursively get the successors of the current vertex
|
||||||
|
|
@ -657,7 +687,10 @@ class Graph:
|
||||||
|
|
||||||
def get_successors(self, vertex):
|
def get_successors(self, vertex):
|
||||||
"""Returns the successors of a vertex."""
|
"""Returns the successors of a vertex."""
|
||||||
return [self.get_vertex(target_id) for target_id in self.successor_map.get(vertex.id, [])]
|
return [
|
||||||
|
self.get_vertex(target_id)
|
||||||
|
for target_id in self.successor_map.get(vertex.id, [])
|
||||||
|
]
|
||||||
|
|
||||||
def get_vertex_neighbors(self, vertex: Vertex) -> Dict[Vertex, int]:
|
def get_vertex_neighbors(self, vertex: Vertex) -> Dict[Vertex, int]:
|
||||||
"""Returns the neighbors of a vertex."""
|
"""Returns the neighbors of a vertex."""
|
||||||
|
|
@ -703,7 +736,9 @@ class Graph:
|
||||||
edges_added.add((source.id, target.id))
|
edges_added.add((source.id, target.id))
|
||||||
return edges
|
return edges
|
||||||
|
|
||||||
def _get_vertex_class(self, node_type: str, node_base_type: str, node_id: str) -> Type[Vertex]:
|
def _get_vertex_class(
|
||||||
|
self, node_type: str, node_base_type: str, node_id: str
|
||||||
|
) -> Type[Vertex]:
|
||||||
"""Returns the node class based on the node type."""
|
"""Returns the node class based on the node type."""
|
||||||
# First we check for the node_base_type
|
# First we check for the node_base_type
|
||||||
node_name = node_id.split("-")[0]
|
node_name = node_id.split("-")[0]
|
||||||
|
|
@ -736,14 +771,18 @@ class Graph:
|
||||||
vertex_type: str = vertex_data["type"] # type: ignore
|
vertex_type: str = vertex_data["type"] # type: ignore
|
||||||
vertex_base_type: str = vertex_data["node"]["template"]["_type"] # type: ignore
|
vertex_base_type: str = vertex_data["node"]["template"]["_type"] # type: ignore
|
||||||
|
|
||||||
VertexClass = self._get_vertex_class(vertex_type, vertex_base_type, vertex_data["id"])
|
VertexClass = self._get_vertex_class(
|
||||||
|
vertex_type, vertex_base_type, vertex_data["id"]
|
||||||
|
)
|
||||||
vertex_instance = VertexClass(vertex, graph=self)
|
vertex_instance = VertexClass(vertex, graph=self)
|
||||||
vertex_instance.set_top_level(self.top_level_vertices)
|
vertex_instance.set_top_level(self.top_level_vertices)
|
||||||
vertices.append(vertex_instance)
|
vertices.append(vertex_instance)
|
||||||
|
|
||||||
return vertices
|
return vertices
|
||||||
|
|
||||||
def get_children_by_vertex_type(self, vertex: Vertex, vertex_type: str) -> List[Vertex]:
|
def get_children_by_vertex_type(
|
||||||
|
self, vertex: Vertex, vertex_type: str
|
||||||
|
) -> List[Vertex]:
|
||||||
"""Returns the children of a vertex based on the vertex type."""
|
"""Returns the children of a vertex based on the vertex type."""
|
||||||
children = []
|
children = []
|
||||||
vertex_types = [vertex.data["type"]]
|
vertex_types = [vertex.data["type"]]
|
||||||
|
|
@ -755,7 +794,9 @@ class Graph:
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
vertex_ids = [vertex.id for vertex in self.vertices]
|
vertex_ids = [vertex.id for vertex in self.vertices]
|
||||||
edges_repr = "\n".join([f"{edge.source_id} --> {edge.target_id}" for edge in self.edges])
|
edges_repr = "\n".join(
|
||||||
|
[f"{edge.source_id} --> {edge.target_id}" for edge in self.edges]
|
||||||
|
)
|
||||||
return f"Graph:\nNodes: {vertex_ids}\nConnections:\n{edges_repr}"
|
return f"Graph:\nNodes: {vertex_ids}\nConnections:\n{edges_repr}"
|
||||||
|
|
||||||
def sort_up_to_vertex(self, vertex_id: str, is_start: bool = False) -> List[Vertex]:
|
def sort_up_to_vertex(self, vertex_id: str, is_start: bool = False) -> List[Vertex]:
|
||||||
|
|
@ -823,7 +864,8 @@ class Graph:
|
||||||
vertex.id
|
vertex.id
|
||||||
for vertex in vertices
|
for vertex in vertices
|
||||||
# if filter_graphs then only vertex.is_input will be considered
|
# if filter_graphs then only vertex.is_input will be considered
|
||||||
if self.in_degree_map[vertex.id] == 0 and (not filter_graphs or vertex.is_input)
|
if self.in_degree_map[vertex.id] == 0
|
||||||
|
and (not filter_graphs or vertex.is_input)
|
||||||
)
|
)
|
||||||
layers: List[List[str]] = []
|
layers: List[List[str]] = []
|
||||||
visited = set(queue)
|
visited = set(queue)
|
||||||
|
|
@ -897,7 +939,9 @@ class Graph:
|
||||||
|
|
||||||
return refined_layers
|
return refined_layers
|
||||||
|
|
||||||
def sort_chat_inputs_first(self, vertices_layers: List[List[str]]) -> List[List[str]]:
|
def sort_chat_inputs_first(
|
||||||
|
self, vertices_layers: List[List[str]]
|
||||||
|
) -> List[List[str]]:
|
||||||
chat_inputs_first = []
|
chat_inputs_first = []
|
||||||
for layer in vertices_layers:
|
for layer in vertices_layers:
|
||||||
for vertex_id in layer:
|
for vertex_id in layer:
|
||||||
|
|
@ -938,7 +982,9 @@ class Graph:
|
||||||
first_layer = vertices_layers[0]
|
first_layer = vertices_layers[0]
|
||||||
# save the only the rest
|
# save the only the rest
|
||||||
self.vertices_layers = vertices_layers[1:]
|
self.vertices_layers = vertices_layers[1:]
|
||||||
self.vertices_to_run = {vertex_id for vertex_id in chain.from_iterable(vertices_layers)}
|
self.vertices_to_run = {
|
||||||
|
vertex_id for vertex_id in chain.from_iterable(vertices_layers)
|
||||||
|
}
|
||||||
# Return just the first layer
|
# Return just the first layer
|
||||||
return first_layer
|
return first_layer
|
||||||
|
|
||||||
|
|
@ -949,11 +995,15 @@ class Graph:
|
||||||
self.vertices_to_run.remove(vertex_id)
|
self.vertices_to_run.remove(vertex_id)
|
||||||
return should_run
|
return should_run
|
||||||
|
|
||||||
def sort_interface_components_first(self, vertices_layers: List[List[str]]) -> List[List[str]]:
|
def sort_interface_components_first(
|
||||||
|
self, vertices_layers: List[List[str]]
|
||||||
|
) -> List[List[str]]:
|
||||||
"""Sorts the vertices in the graph so that vertices containing ChatInput or ChatOutput come first."""
|
"""Sorts the vertices in the graph so that vertices containing ChatInput or ChatOutput come first."""
|
||||||
|
|
||||||
def contains_interface_component(vertex):
|
def contains_interface_component(vertex):
|
||||||
return any(component.value in vertex for component in InterfaceComponentTypes)
|
return any(
|
||||||
|
component.value in vertex for component in InterfaceComponentTypes
|
||||||
|
)
|
||||||
|
|
||||||
# Sort each inner list so that vertices containing ChatInput or ChatOutput come first
|
# Sort each inner list so that vertices containing ChatInput or ChatOutput come first
|
||||||
sorted_vertices = [
|
sorted_vertices = [
|
||||||
|
|
@ -965,16 +1015,22 @@ class Graph:
|
||||||
]
|
]
|
||||||
return sorted_vertices
|
return sorted_vertices
|
||||||
|
|
||||||
def sort_by_avg_build_time(self, vertices_layers: List[List[str]]) -> List[List[str]]:
|
def sort_by_avg_build_time(
|
||||||
|
self, vertices_layers: List[List[str]]
|
||||||
|
) -> List[List[str]]:
|
||||||
"""Sorts the vertices in the graph so that vertices with the lowest average build time come first."""
|
"""Sorts the vertices in the graph so that vertices with the lowest average build time come first."""
|
||||||
|
|
||||||
def sort_layer_by_avg_build_time(vertices_ids: List[str]) -> List[str]:
|
def sort_layer_by_avg_build_time(vertices_ids: List[str]) -> List[str]:
|
||||||
"""Sorts the vertices in the graph so that vertices with the lowest average build time come first."""
|
"""Sorts the vertices in the graph so that vertices with the lowest average build time come first."""
|
||||||
if len(vertices_ids) == 1:
|
if len(vertices_ids) == 1:
|
||||||
return vertices_ids
|
return vertices_ids
|
||||||
vertices_ids.sort(key=lambda vertex_id: self.get_vertex(vertex_id).avg_build_time)
|
vertices_ids.sort(
|
||||||
|
key=lambda vertex_id: self.get_vertex(vertex_id).avg_build_time
|
||||||
|
)
|
||||||
|
|
||||||
return vertices_ids
|
return vertices_ids
|
||||||
|
|
||||||
sorted_vertices = [sort_layer_by_avg_build_time(layer) for layer in vertices_layers]
|
sorted_vertices = [
|
||||||
|
sort_layer_by_avg_build_time(layer) for layer in vertices_layers
|
||||||
|
]
|
||||||
return sorted_vertices
|
return sorted_vertices
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from loguru import logger
|
||||||
|
|
||||||
from langflow.graph.schema import (
|
from langflow.graph.schema import (
|
||||||
INPUT_COMPONENTS,
|
INPUT_COMPONENTS,
|
||||||
|
INPUT_FIELD_NAME,
|
||||||
OUTPUT_COMPONENTS,
|
OUTPUT_COMPONENTS,
|
||||||
InterfaceComponentTypes,
|
InterfaceComponentTypes,
|
||||||
ResultData,
|
ResultData,
|
||||||
|
|
@ -709,7 +710,8 @@ class Vertex:
|
||||||
self._reset()
|
self._reset()
|
||||||
|
|
||||||
if self._is_chat_input() and inputs is not None:
|
if self._is_chat_input() and inputs is not None:
|
||||||
self.update_raw_params(inputs)
|
inputs = {"input_value": inputs.get(INPUT_FIELD_NAME, "")}
|
||||||
|
self.update_raw_params(inputs, overwrite=True)
|
||||||
|
|
||||||
# Run steps
|
# Run steps
|
||||||
for step in self.steps:
|
for step in self.steps:
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ from emoji import demojize, purely_emoji
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from langflow.interface.types import get_all_components
|
||||||
from langflow.services.database.models.flow.model import Flow, FlowCreate
|
from langflow.services.database.models.flow.model import Flow, FlowCreate
|
||||||
from langflow.services.deps import session_scope
|
from langflow.services.deps import get_settings_service, session_scope
|
||||||
|
|
||||||
STARTER_FOLDER_NAME = "Starter Projects"
|
STARTER_FOLDER_NAME = "Starter Projects"
|
||||||
|
|
||||||
|
|
@ -17,6 +18,23 @@ STARTER_FOLDER_NAME = "Starter Projects"
|
||||||
# can use them as a starting point for their own projects.
|
# can use them as a starting point for their own projects.
|
||||||
|
|
||||||
|
|
||||||
|
def update_projects_components_with_latest_component_versions(
|
||||||
|
project_data, all_types_dict
|
||||||
|
):
|
||||||
|
|
||||||
|
# project data has a nodes key, which is a list of nodes
|
||||||
|
# we want to run through each node and see if it exists in the all_types_dict
|
||||||
|
# if so, we go into the template key and also get the template from all_types_dict
|
||||||
|
# and update it all
|
||||||
|
for node in project_data.get("nodes", []):
|
||||||
|
node_data = node.get("data").get("node")
|
||||||
|
if node_data.get("display_name") in all_types_dict:
|
||||||
|
latest_node = all_types_dict.get(node_data.get("display_name"))
|
||||||
|
latest_template = latest_node.get("template")
|
||||||
|
node_data["template"]["code"] = latest_template["code"]
|
||||||
|
return project_data
|
||||||
|
|
||||||
|
|
||||||
def load_starter_projects():
|
def load_starter_projects():
|
||||||
starter_projects = []
|
starter_projects = []
|
||||||
folder = Path(__file__).parent / "starter_projects"
|
folder = Path(__file__).parent / "starter_projects"
|
||||||
|
|
@ -115,6 +133,8 @@ def delete_start_projects(session):
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_starter_projects():
|
def create_or_update_starter_projects():
|
||||||
|
components_paths = get_settings_service().settings.COMPONENTS_PATH
|
||||||
|
all_types_dict = get_all_components(components_paths, as_dict=True)
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
starter_projects = load_starter_projects()
|
starter_projects = load_starter_projects()
|
||||||
delete_start_projects(session)
|
delete_start_projects(session)
|
||||||
|
|
@ -128,6 +148,9 @@ def create_or_update_starter_projects():
|
||||||
project_icon,
|
project_icon,
|
||||||
project_icon_bg_color,
|
project_icon_bg_color,
|
||||||
) = get_project_data(project)
|
) = get_project_data(project)
|
||||||
|
project_data = update_projects_components_with_latest_component_versions(
|
||||||
|
project_data, all_types_dict
|
||||||
|
)
|
||||||
if project_name and project_data:
|
if project_name and project_data:
|
||||||
for existing_project in get_all_flows_similar_to_project(
|
for existing_project in get_all_flows_similar_to_project(
|
||||||
session, project_name
|
session, project_name
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ from fastapi import HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from langflow.interface.custom.eval import eval_custom_component_code
|
from langflow.interface.custom.eval import eval_custom_component_code
|
||||||
from langflow.interface.custom.schema import CallableCodeDetails, ClassCodeDetails
|
from langflow.interface.custom.schema import (
|
||||||
|
CallableCodeDetails,
|
||||||
|
ClassCodeDetails,
|
||||||
|
MissingDefault,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CodeSyntaxError(HTTPException):
|
class CodeSyntaxError(HTTPException):
|
||||||
|
|
@ -95,7 +99,9 @@ class CodeParser:
|
||||||
elif isinstance(node, ast.ImportFrom):
|
elif isinstance(node, ast.ImportFrom):
|
||||||
for alias in node.names:
|
for alias in node.names:
|
||||||
if alias.asname:
|
if alias.asname:
|
||||||
self.data["imports"].append((node.module, f"{alias.name} as {alias.asname}"))
|
self.data["imports"].append(
|
||||||
|
(node.module, f"{alias.name} as {alias.asname}")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.data["imports"].append((node.module, alias.name))
|
self.data["imports"].append((node.module, alias.name))
|
||||||
|
|
||||||
|
|
@ -144,7 +150,9 @@ class CodeParser:
|
||||||
return_type = None
|
return_type = None
|
||||||
if node.returns:
|
if node.returns:
|
||||||
return_type_str = ast.unparse(node.returns)
|
return_type_str = ast.unparse(node.returns)
|
||||||
eval_env = self.construct_eval_env(return_type_str, tuple(self.data["imports"]))
|
eval_env = self.construct_eval_env(
|
||||||
|
return_type_str, tuple(self.data["imports"])
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return_type = eval(return_type_str, eval_env)
|
return_type = eval(return_type_str, eval_env)
|
||||||
|
|
@ -185,15 +193,23 @@ class CodeParser:
|
||||||
num_args = len(node.args.args)
|
num_args = len(node.args.args)
|
||||||
num_defaults = len(node.args.defaults)
|
num_defaults = len(node.args.defaults)
|
||||||
num_missing_defaults = num_args - num_defaults
|
num_missing_defaults = num_args - num_defaults
|
||||||
missing_defaults = [None] * num_missing_defaults
|
missing_defaults = [MissingDefault()] * num_missing_defaults
|
||||||
default_values = [ast.unparse(default).strip("'") if default else None for default in node.args.defaults]
|
default_values = [
|
||||||
|
ast.unparse(default).strip("'") if default else None
|
||||||
|
for default in node.args.defaults
|
||||||
|
]
|
||||||
# Now check all default values to see if there
|
# Now check all default values to see if there
|
||||||
# are any "None" values in the middle
|
# are any "None" values in the middle
|
||||||
default_values = [None if value == "None" else value for value in default_values]
|
default_values = [
|
||||||
|
None if value == "None" else value for value in default_values
|
||||||
|
]
|
||||||
|
|
||||||
defaults = missing_defaults + default_values
|
defaults = missing_defaults + default_values
|
||||||
|
|
||||||
args = [self.parse_arg(arg, default) for arg, default in zip(node.args.args, defaults)]
|
args = [
|
||||||
|
self.parse_arg(arg, default)
|
||||||
|
for arg, default in zip(node.args.args, defaults)
|
||||||
|
]
|
||||||
return args
|
return args
|
||||||
|
|
||||||
def parse_varargs(self, node: ast.FunctionDef) -> List[Dict[str, Any]]:
|
def parse_varargs(self, node: ast.FunctionDef) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -211,11 +227,17 @@ class CodeParser:
|
||||||
"""
|
"""
|
||||||
Parses the keyword-only arguments of a function or method node.
|
Parses the keyword-only arguments of a function or method node.
|
||||||
"""
|
"""
|
||||||
kw_defaults = [None] * (len(node.args.kwonlyargs) - len(node.args.kw_defaults)) + [
|
kw_defaults = [None] * (
|
||||||
ast.unparse(default) if default else None for default in node.args.kw_defaults
|
len(node.args.kwonlyargs) - len(node.args.kw_defaults)
|
||||||
|
) + [
|
||||||
|
ast.unparse(default) if default else None
|
||||||
|
for default in node.args.kw_defaults
|
||||||
]
|
]
|
||||||
|
|
||||||
args = [self.parse_arg(arg, default) for arg, default in zip(node.args.kwonlyargs, kw_defaults)]
|
args = [
|
||||||
|
self.parse_arg(arg, default)
|
||||||
|
for arg, default in zip(node.args.kwonlyargs, kw_defaults)
|
||||||
|
]
|
||||||
return args
|
return args
|
||||||
|
|
||||||
def parse_kwargs(self, node: ast.FunctionDef) -> List[Dict[str, Any]]:
|
def parse_kwargs(self, node: ast.FunctionDef) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -319,7 +341,9 @@ class CodeParser:
|
||||||
Extracts global variables from the code.
|
Extracts global variables from the code.
|
||||||
"""
|
"""
|
||||||
global_var = {
|
global_var = {
|
||||||
"targets": [t.id if hasattr(t, "id") else ast.dump(t) for t in node.targets],
|
"targets": [
|
||||||
|
t.id if hasattr(t, "id") else ast.dump(t) for t in node.targets
|
||||||
|
],
|
||||||
"value": ast.unparse(node.value),
|
"value": ast.unparse(node.value),
|
||||||
}
|
}
|
||||||
self.data["global_vars"].append(global_var)
|
self.data["global_vars"].append(global_var)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from langflow.interface.custom.code_parser.utils import (
|
||||||
)
|
)
|
||||||
from langflow.interface.custom.custom_component.component import Component
|
from langflow.interface.custom.custom_component.component import Component
|
||||||
from langflow.schema import Record
|
from langflow.schema import Record
|
||||||
|
from langflow.schema.dotdict import dotdict
|
||||||
from langflow.services.database.models.flow import Flow
|
from langflow.services.database.models.flow import Flow
|
||||||
from langflow.services.database.utils import session_getter
|
from langflow.services.database.utils import session_getter
|
||||||
from langflow.services.deps import (
|
from langflow.services.deps import (
|
||||||
|
|
@ -77,13 +78,17 @@ class CustomComponent(Component):
|
||||||
|
|
||||||
def update_state(self, name: str, value: Any):
|
def update_state(self, name: str, value: Any):
|
||||||
try:
|
try:
|
||||||
self.vertex.graph.update_state(name=name, record=value, caller=self.vertex.id)
|
self.vertex.graph.update_state(
|
||||||
|
name=name, record=value, caller=self.vertex.id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error updating state: {e}")
|
raise ValueError(f"Error updating state: {e}")
|
||||||
|
|
||||||
def append_state(self, name: str, value: Any):
|
def append_state(self, name: str, value: Any):
|
||||||
try:
|
try:
|
||||||
self.vertex.graph.append_state(name=name, record=value, caller=self.vertex.id)
|
self.vertex.graph.append_state(
|
||||||
|
name=name, record=value, caller=self.vertex.id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error appending state: {e}")
|
raise ValueError(f"Error appending state: {e}")
|
||||||
|
|
||||||
|
|
@ -134,7 +139,12 @@ class CustomComponent(Component):
|
||||||
def build_config(self):
|
def build_config(self):
|
||||||
return self.field_config
|
return self.field_config
|
||||||
|
|
||||||
def update_build_config(self, build_config: dict, field_name: str, field_value: Any):
|
def update_build_config(
|
||||||
|
self,
|
||||||
|
build_config: dotdict,
|
||||||
|
field_name: str,
|
||||||
|
field_value: Any,
|
||||||
|
):
|
||||||
build_config[field_name] = field_value
|
build_config[field_name] = field_value
|
||||||
return build_config
|
return build_config
|
||||||
|
|
||||||
|
|
@ -142,7 +152,9 @@ class CustomComponent(Component):
|
||||||
def tree(self):
|
def tree(self):
|
||||||
return self.get_code_tree(self.code or "")
|
return self.get_code_tree(self.code or "")
|
||||||
|
|
||||||
def to_records(self, data: Any, keys: Optional[List[str]] = None, silent_errors: bool = False) -> List[Record]:
|
def to_records(
|
||||||
|
self, data: Any, keys: Optional[List[str]] = None, silent_errors: bool = False
|
||||||
|
) -> List[Record]:
|
||||||
"""
|
"""
|
||||||
Converts input data into a list of Record objects.
|
Converts input data into a list of Record objects.
|
||||||
|
|
||||||
|
|
@ -191,7 +203,9 @@ class CustomComponent(Component):
|
||||||
|
|
||||||
return records
|
return records
|
||||||
|
|
||||||
def create_references_from_records(self, records: List[Record], include_data: bool = False) -> str:
|
def create_references_from_records(
|
||||||
|
self, records: List[Record], include_data: bool = False
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create references from a list of records.
|
Create references from a list of records.
|
||||||
|
|
||||||
|
|
@ -230,14 +244,20 @@ class CustomComponent(Component):
|
||||||
if not self.code:
|
if not self.code:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
component_classes = [cls for cls in self.tree["classes"] if self.code_class_base_inheritance in cls["bases"]]
|
component_classes = [
|
||||||
|
cls
|
||||||
|
for cls in self.tree["classes"]
|
||||||
|
if self.code_class_base_inheritance in cls["bases"]
|
||||||
|
]
|
||||||
if not component_classes:
|
if not component_classes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Assume the first Component class is the one we're interested in
|
# Assume the first Component class is the one we're interested in
|
||||||
component_class = component_classes[0]
|
component_class = component_classes[0]
|
||||||
build_methods = [
|
build_methods = [
|
||||||
method for method in component_class["methods"] if method["name"] == self.function_entrypoint_name
|
method
|
||||||
|
for method in component_class["methods"]
|
||||||
|
if method["name"] == self.function_entrypoint_name
|
||||||
]
|
]
|
||||||
|
|
||||||
return build_methods[0] if build_methods else {}
|
return build_methods[0] if build_methods else {}
|
||||||
|
|
@ -294,7 +314,9 @@ class CustomComponent(Component):
|
||||||
# Retrieve and decrypt the credential by name for the current user
|
# Retrieve and decrypt the credential by name for the current user
|
||||||
db_service = get_db_service()
|
db_service = get_db_service()
|
||||||
with session_getter(db_service) as session:
|
with session_getter(db_service) as session:
|
||||||
return credential_service.get_credential(user_id=self._user_id or "", name=name, session=session)
|
return credential_service.get_credential(
|
||||||
|
user_id=self._user_id or "", name=name, session=session
|
||||||
|
)
|
||||||
|
|
||||||
return get_credential
|
return get_credential
|
||||||
|
|
||||||
|
|
@ -304,7 +326,9 @@ class CustomComponent(Component):
|
||||||
credential_service = get_credential_service()
|
credential_service = get_credential_service()
|
||||||
db_service = get_db_service()
|
db_service = get_db_service()
|
||||||
with session_getter(db_service) as session:
|
with session_getter(db_service) as session:
|
||||||
return credential_service.list_credentials(user_id=self._user_id, session=session)
|
return credential_service.list_credentials(
|
||||||
|
user_id=self._user_id, session=session
|
||||||
|
)
|
||||||
|
|
||||||
def index(self, value: int = 0):
|
def index(self, value: int = 0):
|
||||||
"""Returns a function that returns the value at the given index in the iterable."""
|
"""Returns a function that returns the value at the given index in the iterable."""
|
||||||
|
|
@ -343,7 +367,11 @@ class CustomComponent(Component):
|
||||||
if not self._flows_records:
|
if not self._flows_records:
|
||||||
self.list_flows()
|
self.list_flows()
|
||||||
if not flow_id and self._flows_records:
|
if not flow_id and self._flows_records:
|
||||||
flow_ids = [flow.data["id"] for flow in self._flows_records if flow.data["name"] == flow_name]
|
flow_ids = [
|
||||||
|
flow.data["id"]
|
||||||
|
for flow in self._flows_records
|
||||||
|
if flow.data["name"] == flow_name
|
||||||
|
]
|
||||||
if not flow_ids:
|
if not flow_ids:
|
||||||
raise ValueError(f"Flow {flow_name} not found")
|
raise ValueError(f"Flow {flow_name} not found")
|
||||||
elif len(flow_ids) > 1:
|
elif len(flow_ids) > 1:
|
||||||
|
|
@ -365,7 +393,9 @@ class CustomComponent(Component):
|
||||||
db_service = get_db_service()
|
db_service = get_db_service()
|
||||||
with get_session(db_service) as session:
|
with get_session(db_service) as session:
|
||||||
flows = session.exec(
|
flows = session.exec(
|
||||||
select(Flow).where(Flow.user_id == self._user_id).where(Flow.is_component == False) # noqa
|
select(Flow)
|
||||||
|
.where(Flow.user_id == self._user_id)
|
||||||
|
.where(Flow.is_component == False) # noqa
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
flows_records = [flow.to_record() for flow in flows]
|
flows_records = [flow.to_record() for flow in flows]
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,12 @@ class CallableCodeDetails(BaseModel):
|
||||||
body: list
|
body: list
|
||||||
return_type: Optional[Any] = None
|
return_type: Optional[Any] = None
|
||||||
has_return: bool = False
|
has_return: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDefault:
|
||||||
|
"""
|
||||||
|
A class to represent a missing default value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "MISSING"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ from langflow.interface.custom.directory_reader.utils import (
|
||||||
merge_nested_dicts_with_renaming,
|
merge_nested_dicts_with_renaming,
|
||||||
)
|
)
|
||||||
from langflow.interface.custom.eval import eval_custom_component_code
|
from langflow.interface.custom.eval import eval_custom_component_code
|
||||||
|
from langflow.interface.custom.schema import MissingDefault
|
||||||
|
from langflow.schema import dotdict
|
||||||
from langflow.template.field.base import TemplateField
|
from langflow.template.field.base import TemplateField
|
||||||
from langflow.template.frontend_node.custom_components import (
|
from langflow.template.frontend_node.custom_components import (
|
||||||
CustomComponentFrontendNode,
|
CustomComponentFrontendNode,
|
||||||
|
|
@ -110,7 +112,7 @@ def extract_type_from_optional(field_type):
|
||||||
str: The extracted type, or an empty string if no type was found.
|
str: The extracted type, or an empty string if no type was found.
|
||||||
"""
|
"""
|
||||||
match = re.search(r"\[(.*?)\]$", field_type)
|
match = re.search(r"\[(.*?)\]$", field_type)
|
||||||
return match[1] if match else None
|
return match[1] if match else field_type
|
||||||
|
|
||||||
|
|
||||||
def get_field_properties(extra_field):
|
def get_field_properties(extra_field):
|
||||||
|
|
@ -118,7 +120,13 @@ def get_field_properties(extra_field):
|
||||||
field_name = extra_field["name"]
|
field_name = extra_field["name"]
|
||||||
field_type = extra_field.get("type", "str")
|
field_type = extra_field.get("type", "str")
|
||||||
field_value = extra_field.get("default", "")
|
field_value = extra_field.get("default", "")
|
||||||
field_required = "optional" not in field_type.lower()
|
# a required field is a field that does not contain
|
||||||
|
# optional in field_type
|
||||||
|
# and a field that does not have a default value
|
||||||
|
field_required = "optional" not in field_type.lower() and isinstance(
|
||||||
|
field_value, MissingDefault
|
||||||
|
)
|
||||||
|
field_value = field_value if not isinstance(field_value, MissingDefault) else None
|
||||||
|
|
||||||
if not field_required:
|
if not field_required:
|
||||||
field_type = extract_type_from_optional(field_type)
|
field_type = extract_type_from_optional(field_type)
|
||||||
|
|
@ -245,7 +253,7 @@ def add_extra_fields(frontend_node, field_config, function_args):
|
||||||
def get_field_dict(field: Union[TemplateField, dict]):
|
def get_field_dict(field: Union[TemplateField, dict]):
|
||||||
"""Get the field dictionary from a TemplateField or a dict"""
|
"""Get the field dictionary from a TemplateField or a dict"""
|
||||||
if isinstance(field, TemplateField):
|
if isinstance(field, TemplateField):
|
||||||
return field.model_dump(by_alias=True, exclude_none=True)
|
return dotdict(field.model_dump(by_alias=True, exclude_none=True))
|
||||||
return field
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -284,6 +292,7 @@ def run_build_config(
|
||||||
# Allow user to build TemplateField as well
|
# Allow user to build TemplateField as well
|
||||||
# as a dict with the same keys as TemplateField
|
# as a dict with the same keys as TemplateField
|
||||||
field_dict = get_field_dict(field)
|
field_dict = get_field_dict(field)
|
||||||
|
build_config[field_name] = field_dict
|
||||||
# This has to be done to set refresh if options or value are callable
|
# This has to be done to set refresh if options or value are callable
|
||||||
if update_field is not None and field_name != update_field:
|
if update_field is not None and field_name != update_field:
|
||||||
build_config = update_field_dict(
|
build_config = update_field_dict(
|
||||||
|
|
@ -320,7 +329,11 @@ def run_build_config(
|
||||||
return build_config, custom_instance
|
return build_config, custom_instance
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
||||||
logger.error(f"Error while building field config: {str(exc)}")
|
logger.error(f"Error while building field config: {str(exc)}")
|
||||||
|
if hasattr(exc, "detail") and "traceback" in exc.detail:
|
||||||
|
logger.error(exc.detail["traceback"])
|
||||||
|
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -345,6 +358,7 @@ def build_frontend_node(template_config):
|
||||||
|
|
||||||
|
|
||||||
def add_code_field(frontend_node: CustomComponentFrontendNode, raw_code, field_config):
|
def add_code_field(frontend_node: CustomComponentFrontendNode, raw_code, field_config):
|
||||||
|
|
||||||
code_field = TemplateField(
|
code_field = TemplateField(
|
||||||
dynamic=True,
|
dynamic=True,
|
||||||
required=True,
|
required=True,
|
||||||
|
|
@ -353,7 +367,7 @@ def add_code_field(frontend_node: CustomComponentFrontendNode, raw_code, field_c
|
||||||
value=raw_code,
|
value=raw_code,
|
||||||
password=False,
|
password=False,
|
||||||
name="code",
|
name="code",
|
||||||
advanced=field_config.pop("advanced", False),
|
advanced=True,
|
||||||
field_type="code",
|
field_type="code",
|
||||||
is_list=False,
|
is_list=False,
|
||||||
)
|
)
|
||||||
|
|
@ -404,7 +418,7 @@ def build_custom_component_template(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail={
|
detail={
|
||||||
"error": (
|
"error": (
|
||||||
"Invalid type convertion. Please check your code and try again."
|
f"Something went wrong while building the custom component. Hints: {str(exc)}"
|
||||||
),
|
),
|
||||||
"traceback": traceback.format_exc(),
|
"traceback": traceback.format_exc(),
|
||||||
},
|
},
|
||||||
|
|
@ -415,7 +429,6 @@ def create_component_template(component):
|
||||||
"""Create a template for a component."""
|
"""Create a template for a component."""
|
||||||
component_code = component["code"]
|
component_code = component["code"]
|
||||||
component_output_types = component["output_types"]
|
component_output_types = component["output_types"]
|
||||||
# remove
|
|
||||||
|
|
||||||
component_extractor = CustomComponent(code=component_code)
|
component_extractor = CustomComponent(code=component_code)
|
||||||
|
|
||||||
|
|
@ -431,9 +444,7 @@ def build_custom_components(components_paths: List[str]):
|
||||||
if not components_paths:
|
if not components_paths:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Building custom components from {components_paths}")
|
||||||
f"Building custom components from {components_paths}"
|
|
||||||
)
|
|
||||||
custom_components_from_file = {}
|
custom_components_from_file = {}
|
||||||
processed_paths = set()
|
processed_paths = set()
|
||||||
for path in components_paths:
|
for path in components_paths:
|
||||||
|
|
@ -464,18 +475,24 @@ def update_field_dict(
|
||||||
call: bool = False,
|
call: bool = False,
|
||||||
):
|
):
|
||||||
"""Update the field dictionary by calling options() or value() if they are callable"""
|
"""Update the field dictionary by calling options() or value() if they are callable"""
|
||||||
if "refresh" in field_dict:
|
if ("real_time_refresh" in field_dict or "refresh_button" in field_dict) and any(
|
||||||
|
(
|
||||||
|
field_dict.get("real_time_refresh", False),
|
||||||
|
field_dict.get("refresh_button", False),
|
||||||
|
)
|
||||||
|
):
|
||||||
if call:
|
if call:
|
||||||
try:
|
try:
|
||||||
|
dd_build_config = dotdict(build_config)
|
||||||
custom_component_instance.update_build_config(
|
custom_component_instance.update_build_config(
|
||||||
build_config, update_field, update_field_value
|
dd_build_config, update_field, update_field_value
|
||||||
)
|
)
|
||||||
|
build_config = dd_build_config
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error while running update_build_config: {str(exc)}")
|
logger.error(f"Error while running update_build_config: {str(exc)}")
|
||||||
raise UpdateBuildConfigError(
|
raise UpdateBuildConfigError(
|
||||||
f"Error while running update_build_config: {str(exc)}"
|
f"Error while running update_build_config: {str(exc)}"
|
||||||
) from exc
|
) from exc
|
||||||
field_dict["refresh"] = True
|
|
||||||
|
|
||||||
# Let's check if "range_spec" is a RangeSpec object
|
# Let's check if "range_spec" is a RangeSpec object
|
||||||
if "rangeSpec" in field_dict and isinstance(field_dict["rangeSpec"], RangeSpec):
|
if "rangeSpec" in field_dict and isinstance(field_dict["rangeSpec"], RangeSpec):
|
||||||
|
|
@ -483,8 +500,10 @@ def update_field_dict(
|
||||||
return build_config
|
return build_config
|
||||||
|
|
||||||
|
|
||||||
def sanitize_field_config(field_config: Dict):
|
def sanitize_field_config(field_config: Union[Dict, TemplateField]):
|
||||||
# If any of the already existing keys are in field_config, remove them
|
# If any of the already existing keys are in field_config, remove them
|
||||||
|
if isinstance(field_config, TemplateField):
|
||||||
|
field_config = field_config.to_dict()
|
||||||
for key in [
|
for key in [
|
||||||
"name",
|
"name",
|
||||||
"field_type",
|
"field_type",
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,17 @@ def get_all_types_dict(components_paths):
|
||||||
return merge_nested_dicts_with_renaming(
|
return merge_nested_dicts_with_renaming(
|
||||||
native_components, custom_components_from_file
|
native_components, custom_components_from_file
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_components(components_paths, as_dict=False):
|
||||||
|
"""Get all components names combining native and custom components."""
|
||||||
|
all_types_dict = get_all_types_dict(components_paths)
|
||||||
|
components = [] if not as_dict else {}
|
||||||
|
for category in all_types_dict.values():
|
||||||
|
for component in category.values():
|
||||||
|
component["name"] = component["display_name"]
|
||||||
|
if as_dict:
|
||||||
|
components[component["name"]] = component
|
||||||
|
else:
|
||||||
|
components.append(component)
|
||||||
|
return components
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from .dotdict import dotdict
|
||||||
from .schema import Record
|
from .schema import Record
|
||||||
|
|
||||||
__all__ = ["Record"]
|
__all__ = ["Record", "dotdict"]
|
||||||
|
|
|
||||||
71
src/backend/langflow/schema/dotdict.py
Normal file
71
src/backend/langflow/schema/dotdict.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
class dotdict(dict):
|
||||||
|
"""
|
||||||
|
dotdict allows accessing dictionary elements using dot notation (e.g., dict.key instead of dict['key']).
|
||||||
|
It automatically converts nested dictionaries into dotdict instances, enabling dot notation on them as well.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Only keys that are valid attribute names (e.g., strings that could be variable names) are accessible via dot notation.
|
||||||
|
- Keys which are not valid Python attribute names or collide with the dict method names (like 'items', 'keys')
|
||||||
|
should be accessed using the traditional dict['key'] notation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
"""
|
||||||
|
Override dot access to behave like dictionary lookup. Automatically convert nested dicts to dotdicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attr (str): Attribute to access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The value associated with 'attr' in the dictionary, converted to dotdict if it is a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AttributeError: If the attribute is not found in the dictionary.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = self[attr]
|
||||||
|
if isinstance(value, dict) and not isinstance(value, dotdict):
|
||||||
|
value = dotdict(value)
|
||||||
|
self[attr] = value # Update self to nest dotdict for future accesses
|
||||||
|
return value
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(f"'dotdict' object has no attribute '{attr}'")
|
||||||
|
|
||||||
|
def __setattr__(self, key, value):
|
||||||
|
"""
|
||||||
|
Override attribute setting to work as dictionary item assignment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): The key under which to store the value.
|
||||||
|
value: The value to store in the dictionary.
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict) and not isinstance(value, dotdict):
|
||||||
|
value = dotdict(value)
|
||||||
|
self[key] = value
|
||||||
|
|
||||||
|
def __delattr__(self, key):
|
||||||
|
"""
|
||||||
|
Override attribute deletion to work as dictionary item deletion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): The key of the item to delete from the dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AttributeError: If the key is not found in the dictionary.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
del self[key]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(f"'dotdict' object has no attribute '{key}'")
|
||||||
|
|
||||||
|
def __missing__(self, key):
|
||||||
|
"""
|
||||||
|
Handle missing keys by returning an empty dotdict. This allows chaining access without raising KeyError.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The missing key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An empty dotdict instance for the given missing key.
|
||||||
|
"""
|
||||||
|
return dotdict()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
from langchain_core.documents import Document # Assumed import
|
from langchain_core.documents import Document
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,17 @@ class TemplateField(BaseModel):
|
||||||
info: Optional[str] = ""
|
info: Optional[str] = ""
|
||||||
"""Additional information about the field to be shown in the tooltip. Defaults to an empty string."""
|
"""Additional information about the field to be shown in the tooltip. Defaults to an empty string."""
|
||||||
|
|
||||||
refresh: Optional[bool] = None
|
real_time_refresh: Optional[bool] = None
|
||||||
"""Specifies if the field should be refreshed. Defaults to False."""
|
"""Specifies if the field should have real time refresh. `refresh_button` must be False. Defaults to None."""
|
||||||
|
|
||||||
range_spec: Optional[RangeSpec] = Field(default=None, serialization_alias="rangeSpec")
|
refresh_button: Optional[bool] = None
|
||||||
|
"""Specifies if the field should have a refresh button. Defaults to False."""
|
||||||
|
refresh_button_text: Optional[str] = None
|
||||||
|
"""Specifies the text for the refresh button. Defaults to None."""
|
||||||
|
|
||||||
|
range_spec: Optional[RangeSpec] = Field(
|
||||||
|
default=None, serialization_alias="rangeSpec"
|
||||||
|
)
|
||||||
"""Range specification for the field. Defaults to None."""
|
"""Range specification for the field. Defaults to None."""
|
||||||
|
|
||||||
title_case: bool = False
|
title_case: bool = False
|
||||||
|
|
@ -117,6 +124,10 @@ class TemplateField(BaseModel):
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
raise ValueError("file_types must be a list")
|
raise ValueError("file_types must be a list")
|
||||||
return [
|
return [
|
||||||
(f".{file_type}" if isinstance(file_type, str) and not file_type.startswith(".") else file_type)
|
(
|
||||||
|
f".{file_type}"
|
||||||
|
if isinstance(file_type, str) and not file_type.startswith(".")
|
||||||
|
else file_type
|
||||||
|
)
|
||||||
for file_type in value
|
for file_type in value
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,36 @@ export default function ParameterComponent({
|
||||||
|
|
||||||
const takeSnapshot = useFlowsManagerStore((state) => state.takeSnapshot);
|
const takeSnapshot = useFlowsManagerStore((state) => state.takeSnapshot);
|
||||||
|
|
||||||
|
const handleRefreshButtonPress = async (name, data) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
let newTemplate = await handleUpdateValues(name, data);
|
||||||
|
if (newTemplate) {
|
||||||
|
setNode(data.id, (oldNode) => {
|
||||||
|
let newNode = cloneDeep(oldNode);
|
||||||
|
newNode.data = {
|
||||||
|
...newNode.data,
|
||||||
|
};
|
||||||
|
newNode.data.node.template = newTemplate;
|
||||||
|
return newNode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let responseError = error as ResponseErrorTypeAPI;
|
||||||
|
setErrorData({
|
||||||
|
title: "Error while updating the Component",
|
||||||
|
list: [responseError.response.data.detail.error ?? "Unknown error"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
renderTooltips();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
if (
|
if (
|
||||||
data.node?.template[name]?.refresh &&
|
(data.node?.template[name]?.real_time_refresh ||
|
||||||
|
data.node?.template[name]?.refresh_button) &&
|
||||||
// options can be undefined but not an empty array
|
// options can be undefined but not an empty array
|
||||||
(data.node?.template[name]?.options?.length ?? 0) === 0
|
(data.node?.template[name]?.options?.length ?? 0) === 0
|
||||||
) {
|
) {
|
||||||
|
|
@ -128,7 +154,8 @@ export default function ParameterComponent({
|
||||||
takeSnapshot();
|
takeSnapshot();
|
||||||
}
|
}
|
||||||
const shouldUpdate =
|
const shouldUpdate =
|
||||||
data.node?.template[name].refresh &&
|
data.node?.template[name].real_time_refresh &&
|
||||||
|
!data.node?.template[name].refresh_button &&
|
||||||
data.node!.template[name].value !== newValue;
|
data.node!.template[name].value !== newValue;
|
||||||
|
|
||||||
data.node!.template[name].value = newValue; // necessary to enable ctrl+z inside the input
|
data.node!.template[name].value = newValue; // necessary to enable ctrl+z inside the input
|
||||||
|
|
@ -154,7 +181,7 @@ export default function ParameterComponent({
|
||||||
...newNode.data,
|
...newNode.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.node?.template[name].refresh && newTemplate) {
|
if (data.node?.template[name].real_time_refresh && newTemplate) {
|
||||||
newNode.data.node.template = newTemplate;
|
newNode.data.node.template = newTemplate;
|
||||||
} else newNode.data.node.template[name].value = newValue;
|
} else newNode.data.node.template[name].value = newValue;
|
||||||
|
|
||||||
|
|
@ -458,7 +485,7 @@ export default function ParameterComponent({
|
||||||
}
|
}
|
||||||
onChange={handleOnNewValue}
|
onChange={handleOnNewValue}
|
||||||
/>
|
/>
|
||||||
{/* {data.node?.template[name].refresh && (
|
{/* {data.node?.template[name].refresh_button && (
|
||||||
<div className="w-1/6">
|
<div className="w-1/6">
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -466,26 +493,47 @@ export default function ParameterComponent({
|
||||||
name={name}
|
name={name}
|
||||||
data={data}
|
data={data}
|
||||||
className="extra-side-bar-buttons ml-2 mt-1"
|
className="extra-side-bar-buttons ml-2 mt-1"
|
||||||
handleUpdateValues={handleUpdateValues}
|
handleUpdateValues={handleRefreshButtonPress}
|
||||||
id={"refresh-button-" + name}
|
id={"refresh-button-" + name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
) : data.node?.template[name].multiline ? (
|
) : data.node?.template[name].multiline ? (
|
||||||
<TextAreaComponent
|
<div className="mt-2 flex w-full flex-col ">
|
||||||
disabled={disabled}
|
<div className="flex-grow">
|
||||||
value={data.node.template[name].value ?? ""}
|
<TextAreaComponent
|
||||||
onChange={handleOnNewValue}
|
disabled={disabled}
|
||||||
id={"textarea-" + data.node.template[name].name}
|
value={data.node.template[name].value ?? ""}
|
||||||
data-testid={"textarea-" + data.node.template[name].name}
|
onChange={handleOnNewValue}
|
||||||
/>
|
id={"textarea-" + data.node.template[name].name}
|
||||||
|
data-testid={"textarea-" + data.node.template[name].name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{data.node?.template[name].refresh_button && (
|
||||||
|
<div className="flex-grow">
|
||||||
|
<RefreshButton
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
data={data}
|
||||||
|
button_text={
|
||||||
|
data.node?.template[name].refresh_button_text ??
|
||||||
|
"Refresh"
|
||||||
|
}
|
||||||
|
className="extra-side-bar-buttons mt-1"
|
||||||
|
handleUpdateValues={handleRefreshButtonPress}
|
||||||
|
id={"refresh-button-" + name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex w-full items-center">
|
<div className="mt-2 flex w-full items-center">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex-grow " +
|
"flex-grow " +
|
||||||
(data.node?.template[name].refresh ? "w-5/6" : "")
|
(data.node?.template[name].refresh_button ? "w-5/6" : "")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InputComponent
|
<InputComponent
|
||||||
|
|
@ -496,15 +544,19 @@ export default function ParameterComponent({
|
||||||
onChange={handleOnNewValue}
|
onChange={handleOnNewValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{data.node?.template[name].refresh && (
|
{data.node?.template[name].refresh_button && (
|
||||||
<div className="w-1/6">
|
<div className="w-1/6">
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={name}
|
name={name}
|
||||||
data={data}
|
data={data}
|
||||||
|
button_text={
|
||||||
|
data.node?.template[name].refresh_button_text ??
|
||||||
|
"Refresh"
|
||||||
|
}
|
||||||
className="extra-side-bar-buttons ml-2 mt-1"
|
className="extra-side-bar-buttons ml-2 mt-1"
|
||||||
handleUpdateValues={handleUpdateValues}
|
handleUpdateValues={handleRefreshButtonPress}
|
||||||
id={"refresh-button-" + name}
|
id={"refresh-button-" + name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -535,7 +587,7 @@ export default function ParameterComponent({
|
||||||
) : left === true &&
|
) : left === true &&
|
||||||
type === "str" &&
|
type === "str" &&
|
||||||
(data.node?.template[name].options ||
|
(data.node?.template[name].options ||
|
||||||
data.node?.template[name]?.refresh) ? (
|
data.node?.template[name]?.real_time_refresh) ? (
|
||||||
// TODO: Improve CSS
|
// TODO: Improve CSS
|
||||||
<div className="mt-2 flex w-full items-center">
|
<div className="mt-2 flex w-full items-center">
|
||||||
<div className="w-5/6 flex-grow">
|
<div className="w-5/6 flex-grow">
|
||||||
|
|
@ -548,15 +600,18 @@ export default function ParameterComponent({
|
||||||
id={"dropdown-" + name}
|
id={"dropdown-" + name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{data.node?.template[name].refresh && (
|
{data.node?.template[name].refresh_button && (
|
||||||
<div className="w-1/6">
|
<div className="w-1/6">
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={name}
|
name={name}
|
||||||
data={data}
|
data={data}
|
||||||
|
button_text={
|
||||||
|
data.node?.template[name].refresh_button_text ?? "Refresh"
|
||||||
|
}
|
||||||
className="extra-side-bar-buttons ml-2 mt-1"
|
className="extra-side-bar-buttons ml-2 mt-1"
|
||||||
handleUpdateValues={handleUpdateValues}
|
handleUpdateValues={handleRefreshButtonPress}
|
||||||
id={"refresh-button-" + name}
|
id={"refresh-button-" + name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,53 @@
|
||||||
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
||||||
import { Suspense, forwardRef, lazy } from "react";
|
import { Suspense, forwardRef, lazy, memo } from "react";
|
||||||
import { IconComponentProps } from "../../types/components";
|
import { IconComponentProps } from "../../types/components";
|
||||||
import { nodeIconsLucide } from "../../utils/styleUtils";
|
import { nodeIconsLucide } from "../../utils/styleUtils";
|
||||||
|
|
||||||
const ForwardedIconComponent = forwardRef(
|
const ForwardedIconComponent = memo(
|
||||||
(
|
forwardRef(
|
||||||
{
|
(
|
||||||
name,
|
{
|
||||||
className,
|
name,
|
||||||
iconColor,
|
className,
|
||||||
stroke,
|
iconColor,
|
||||||
strokeWidth,
|
stroke,
|
||||||
id = "",
|
strokeWidth,
|
||||||
}: IconComponentProps,
|
id = "",
|
||||||
ref
|
}: IconComponentProps,
|
||||||
) => {
|
ref
|
||||||
let TargetIcon = nodeIconsLucide[name];
|
) => {
|
||||||
if (!TargetIcon) {
|
let TargetIcon = nodeIconsLucide[name];
|
||||||
// check if name exists in dynamicIconImports
|
if (!TargetIcon) {
|
||||||
if (!dynamicIconImports[name]) {
|
// check if name exists in dynamicIconImports
|
||||||
TargetIcon = nodeIconsLucide["unknown"];
|
if (!dynamicIconImports[name]) {
|
||||||
} else TargetIcon = lazy(dynamicIconImports[name]);
|
TargetIcon = nodeIconsLucide["unknown"];
|
||||||
}
|
} else TargetIcon = lazy(dynamicIconImports[name]);
|
||||||
|
}
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
strokeWidth: strokeWidth ?? 1.5,
|
strokeWidth: strokeWidth ?? 1.5,
|
||||||
...(stroke && { stroke: stroke }),
|
...(stroke && { stroke: stroke }),
|
||||||
...(iconColor && { color: iconColor, stroke: stroke }),
|
...(iconColor && { color: iconColor, stroke: stroke }),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!TargetIcon) {
|
if (!TargetIcon) {
|
||||||
return null; // Render nothing until the icon is loaded
|
return null; // Render nothing until the icon is loaded
|
||||||
|
}
|
||||||
|
const fallback = (
|
||||||
|
<div style={{ background: "#ddd", width: 24, height: 24 }} />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={fallback}>
|
||||||
|
<TargetIcon
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
ref={ref}
|
||||||
|
data-testid={id ? `${id}-${name}` : `icon-${name}`}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const fallback = (
|
)
|
||||||
<div style={{ background: "#ddd", width: 24, height: 24 }} />
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Suspense fallback={fallback}>
|
|
||||||
<TargetIcon
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
ref={ref}
|
|
||||||
data-testid={id ? `${id}-${name}` : `icon-${name}`}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default ForwardedIconComponent;
|
export default ForwardedIconComponent;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ function RefreshButton({
|
||||||
isLoading,
|
isLoading,
|
||||||
disabled,
|
disabled,
|
||||||
name,
|
name,
|
||||||
|
button_text,
|
||||||
data,
|
data,
|
||||||
handleUpdateValues,
|
handleUpdateValues,
|
||||||
className,
|
className,
|
||||||
|
|
@ -15,6 +16,7 @@ function RefreshButton({
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
|
button_text: string;
|
||||||
data: NodeDataType;
|
data: NodeDataType;
|
||||||
className?: string;
|
className?: string;
|
||||||
handleUpdateValues: (name: string, data: NodeDataType) => void;
|
handleUpdateValues: (name: string, data: NodeDataType) => void;
|
||||||
|
|
@ -43,6 +45,7 @@ function RefreshButton({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
id={id}
|
id={id}
|
||||||
>
|
>
|
||||||
|
<span className="mr-1">{button_text}</span>
|
||||||
<IconComponent
|
<IconComponent
|
||||||
name={isLoading ? "Loader2" : "RefreshCcw"}
|
name={isLoading ? "Loader2" : "RefreshCcw"}
|
||||||
className={iconClassName}
|
className={iconClassName}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// src/constants/constants.ts
|
// src/constants/constants.ts
|
||||||
|
|
||||||
import { languageMap } from "../types/components";
|
import { languageMap } from "../types/components";
|
||||||
|
import { FlowType } from "../types/flow";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* invalid characters for flow name
|
* invalid characters for flow name
|
||||||
|
|
@ -739,4 +740,75 @@ export const PRIORITY_SIDEBAR_ORDER = [
|
||||||
"helpers",
|
"helpers",
|
||||||
"experimental",
|
"experimental",
|
||||||
];
|
];
|
||||||
|
/*
|
||||||
|
Data ingestion
|
||||||
|
Basic Prompting
|
||||||
|
Chat com memória
|
||||||
|
Working with data (file/website)
|
||||||
|
API requests
|
||||||
|
Vector Store
|
||||||
|
Assistant
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EXAMPLES_MOCK:FlowType[] = [
|
||||||
|
{
|
||||||
|
name: "Working with data",
|
||||||
|
id: "Working with data Description",
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { zoom: 1, x: 1, y: 1 }
|
||||||
|
},
|
||||||
|
description: "This flow represents the first process in our application.",
|
||||||
|
folder: STARTER_FOLDER_NAME,
|
||||||
|
user_id: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Basic Prompting",
|
||||||
|
id: "Basic Prompting Description",
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { zoom: 1, x: 1, y: 1 }
|
||||||
|
},
|
||||||
|
description: "This flow represents the first process in our application.",
|
||||||
|
folder: STARTER_FOLDER_NAME,
|
||||||
|
user_id: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Chat with memory",
|
||||||
|
id: "Chat with memory Description",
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { zoom: 1, x: 1, y: 1 }
|
||||||
|
},
|
||||||
|
description: "This flow represents the first process in our application.",
|
||||||
|
folder: STARTER_FOLDER_NAME,
|
||||||
|
user_id: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "API requests",
|
||||||
|
id: "API requests Description",
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { zoom: 1, x: 1, y: 1 }
|
||||||
|
},
|
||||||
|
description: "This flow represents the first process in our application.",
|
||||||
|
folder: STARTER_FOLDER_NAME,
|
||||||
|
user_id: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Assistant",
|
||||||
|
id: "Assistant Description",
|
||||||
|
data: {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { zoom: 1, x: 1, y: 1 }
|
||||||
|
},
|
||||||
|
description: "This flow represents the first process in our application.",
|
||||||
|
folder: STARTER_FOLDER_NAME,
|
||||||
|
user_id: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
|
import { LinkIcon, SparklesIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import ShadTooltip from "../../../../components/ShadTooltipComponent";
|
import ShadTooltip from "../../../../components/ShadTooltipComponent";
|
||||||
import IconComponent from "../../../../components/genericIconComponent";
|
import IconComponent from "../../../../components/genericIconComponent";
|
||||||
import { Input } from "../../../../components/ui/input";
|
import { Input } from "../../../../components/ui/input";
|
||||||
import { Separator } from "../../../../components/ui/separator";
|
import { Separator } from "../../../../components/ui/separator";
|
||||||
import { UPLOAD_ERROR_ALERT } from "../../../../constants/alerts_constants";
|
|
||||||
import { PRIORITY_SIDEBAR_ORDER } from "../../../../constants/constants";
|
import { PRIORITY_SIDEBAR_ORDER } from "../../../../constants/constants";
|
||||||
import ApiModal from "../../../../modals/ApiModal";
|
|
||||||
import ExportModal from "../../../../modals/exportModal";
|
import ExportModal from "../../../../modals/exportModal";
|
||||||
import ShareModal from "../../../../modals/shareModal";
|
import ShareModal from "../../../../modals/shareModal";
|
||||||
import useAlertStore from "../../../../stores/alertStore";
|
import useAlertStore from "../../../../stores/alertStore";
|
||||||
|
|
@ -268,37 +267,37 @@ export default function ExtraSidebar(): JSX.Element {
|
||||||
<>
|
<>
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<div className="pt-0.5">
|
<div className="pt-0.5">
|
||||||
<div className="p-2 px-4 font-semibold" key={index}>
|
<div className="p-2 px-4 font-semibold text-sm" key={index}>
|
||||||
Native
|
Native Components
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{index === PRIORITY_SIDEBAR_ORDER.length - 1 && (
|
{index === PRIORITY_SIDEBAR_ORDER.length - 1 && (
|
||||||
<>
|
<>
|
||||||
<a target={"_blank"} href="https://langflow.store" className="components-disclosure-arrangement">
|
<a
|
||||||
|
target={"_blank"}
|
||||||
|
href="https://langflow.store"
|
||||||
|
className="components-disclosure-arrangement"
|
||||||
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* BUG ON THIS ICON */}
|
{/* BUG ON THIS ICON */}
|
||||||
<IconComponent
|
<SparklesIcon
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
name="sparkles"
|
className="text-primary w-[22px]"
|
||||||
className="text-primary"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="components-disclosure-title">
|
<span className="components-disclosure-title">
|
||||||
Discover More
|
Discover More
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="components-disclosure-div">
|
<div className="components-disclosure-div">
|
||||||
<div>
|
<div>
|
||||||
<IconComponent
|
<LinkIcon className="h-4 w-4 text-foreground" />
|
||||||
name="link"
|
|
||||||
className={`
|
|
||||||
h-4 w-4 text-foreground`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div className="p-2 px-4 font-semibold" key={index}>
|
<div className="p-2 px-4 font-semibold text-sm" key={index}>
|
||||||
Legacy
|
Legacy Components
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import SidebarNav from "../../components/sidebarComponent";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { CONSOLE_ERROR_MSG } from "../../constants/alerts_constants";
|
import { CONSOLE_ERROR_MSG } from "../../constants/alerts_constants";
|
||||||
import {
|
import {
|
||||||
|
EXAMPLES_MOCK,
|
||||||
MY_COLLECTION_DESC,
|
MY_COLLECTION_DESC,
|
||||||
USER_PROJECTS_HEADER,
|
USER_PROJECTS_HEADER,
|
||||||
} from "../../constants/constants";
|
} from "../../constants/constants";
|
||||||
|
|
@ -133,7 +134,7 @@ export default function HomePage(): JSX.Element {
|
||||||
</BaseModal.Header>
|
</BaseModal.Header>
|
||||||
<BaseModal.Content>
|
<BaseModal.Content>
|
||||||
<div className="flex h-full w-full flex-wrap gap-3 overflow-auto p-4 custom-scroll">
|
<div className="flex h-full w-full flex-wrap gap-3 overflow-auto p-4 custom-scroll">
|
||||||
{examples.map((example, idx) => {
|
{EXAMPLES_MOCK.map((example, idx) => {
|
||||||
return <UndrawCardComponent key={idx} flow={example} />;
|
return <UndrawCardComponent key={idx} flow={example} />;
|
||||||
})}
|
})}
|
||||||
<NewFlowCardComponent />
|
<NewFlowCardComponent />
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
@apply flex h-full w-[14.5rem] flex-col overflow-hidden border-r scrollbar-hide;
|
@apply flex h-full w-[14.5rem] flex-col overflow-hidden border-r scrollbar-hide;
|
||||||
}
|
}
|
||||||
.side-bar-search-div-placement {
|
.side-bar-search-div-placement {
|
||||||
@apply relative mx-auto flex items-center py-5;
|
@apply relative mx-auto flex items-center py-3;
|
||||||
}
|
}
|
||||||
.side-bar-components-icon {
|
.side-bar-components-icon {
|
||||||
@apply h-6 w-4 text-ring;
|
@apply h-6 w-4 text-ring;
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ export type TemplateVariableType = {
|
||||||
input_types?: Array<string>;
|
input_types?: Array<string>;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
refresh?: boolean;
|
real_time_refresh?: boolean;
|
||||||
|
refresh_button?: boolean;
|
||||||
|
refresh_button_text?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
export type sendAllProps = {
|
export type sendAllProps = {
|
||||||
|
|
|
||||||
84
tests/test_initial_setup.py
Normal file
84
tests/test_initial_setup.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from langflow.graph.graph.base import Graph
|
||||||
|
from langflow.graph.schema import ResultData
|
||||||
|
from langflow.initial_setup.setup import (
|
||||||
|
STARTER_FOLDER_NAME,
|
||||||
|
create_or_update_starter_projects,
|
||||||
|
get_project_data,
|
||||||
|
load_starter_projects,
|
||||||
|
)
|
||||||
|
from langflow.services.database.models.flow.model import Flow
|
||||||
|
from langflow.services.deps import session_scope
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_starter_projects():
|
||||||
|
projects = load_starter_projects()
|
||||||
|
assert isinstance(projects, list)
|
||||||
|
assert all(isinstance(project, dict) for project in projects)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_project_data():
|
||||||
|
projects = load_starter_projects()
|
||||||
|
for project in projects:
|
||||||
|
data = get_project_data(project)
|
||||||
|
assert all(d is not None for d in data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_or_update_starter_projects(client):
|
||||||
|
with session_scope() as session:
|
||||||
|
# Run the function to create or update projects
|
||||||
|
create_or_update_starter_projects()
|
||||||
|
|
||||||
|
# Get the number of projects returned by load_starter_projects
|
||||||
|
num_projects = len(load_starter_projects())
|
||||||
|
|
||||||
|
# Get the number of projects in the database
|
||||||
|
num_db_projects = session.exec(
|
||||||
|
select(func.count(Flow.id)).where(Flow.folder == STARTER_FOLDER_NAME)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
# Check that the number of projects in the database is the same as the number of projects returned by load_starter_projects
|
||||||
|
assert num_db_projects == num_projects
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_starter_project_can_run_successfully(client):
|
||||||
|
with session_scope() as session:
|
||||||
|
# Run the function to create or update projects
|
||||||
|
create_or_update_starter_projects()
|
||||||
|
|
||||||
|
# Get the number of projects returned by load_starter_projects
|
||||||
|
num_projects = len(load_starter_projects())
|
||||||
|
|
||||||
|
# Get the number of projects in the database
|
||||||
|
num_db_projects = session.exec(
|
||||||
|
select(func.count(Flow.id)).where(Flow.folder == STARTER_FOLDER_NAME)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
# Check that the number of projects in the database is the same as the number of projects returned by load_starter_projects
|
||||||
|
assert num_db_projects == num_projects
|
||||||
|
|
||||||
|
# Get all the starter projects
|
||||||
|
projects = session.exec(
|
||||||
|
select(Flow).where(Flow.folder == STARTER_FOLDER_NAME)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
graphs: list[Graph] = [
|
||||||
|
(project.name, Graph.from_payload(project.data, flow_id=project.id))
|
||||||
|
for project in projects
|
||||||
|
]
|
||||||
|
assert len(graphs) == len(projects)
|
||||||
|
for name, graph in graphs:
|
||||||
|
outputs = await graph.run(
|
||||||
|
inputs={},
|
||||||
|
outputs=[],
|
||||||
|
session_id="test",
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
isinstance(output, ResultData) for output in chain.from_iterable(outputs)
|
||||||
|
), f"Project {name} error: {outputs}"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue