Merge branch 'zustand/io/migration' into state_theories
This commit is contained in:
commit
481df3de8f
67 changed files with 798 additions and 599 deletions
|
|
@ -114,7 +114,7 @@ async def build_vertex(
|
||||||
|
|
||||||
vertex = graph.get_vertex(vertex_id)
|
vertex = graph.get_vertex(vertex_id)
|
||||||
try:
|
try:
|
||||||
if not vertex.pinned or not vertex._built:
|
if not vertex.frozen or not vertex._built:
|
||||||
inputs_dict = inputs.model_dump() if inputs else {}
|
inputs_dict = inputs.model_dump() if inputs else {}
|
||||||
await vertex.build(user_id=current_user.id, inputs=inputs_dict)
|
await vertex.build(user_id=current_user.id, inputs=inputs_dict)
|
||||||
|
|
||||||
|
|
@ -233,7 +233,7 @@ async def build_vertex_stream(
|
||||||
)
|
)
|
||||||
yield str(stream_data)
|
yield str(stream_data)
|
||||||
|
|
||||||
elif not vertex.pinned or not vertex._built:
|
elif not vertex.frozen or not vertex._built:
|
||||||
logger.debug(f"Streaming vertex {vertex_id}")
|
logger.debug(f"Streaming vertex {vertex_id}")
|
||||||
stream_data = StreamData(
|
stream_data = StreamData(
|
||||||
event="message",
|
event="message",
|
||||||
|
|
|
||||||
89
src/backend/langflow/base/data/utils.py
Normal file
89
src/backend/langflow/base/data/utils.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
from concurrent import futures
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Text
|
||||||
|
|
||||||
|
from langflow.schema.schema import Record
|
||||||
|
|
||||||
|
|
||||||
|
def is_hidden(path: Path) -> bool:
|
||||||
|
return path.name.startswith(".")
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_file_paths(
|
||||||
|
path: str,
|
||||||
|
types: List[str],
|
||||||
|
load_hidden: bool,
|
||||||
|
recursive: bool,
|
||||||
|
depth: int,
|
||||||
|
) -> List[str]:
|
||||||
|
path_obj = Path(path)
|
||||||
|
if not path_obj.exists() or not path_obj.is_dir():
|
||||||
|
raise ValueError(f"Path {path} must exist and be a directory.")
|
||||||
|
|
||||||
|
def match_types(p: Path) -> bool:
|
||||||
|
return any(p.suffix == f".{t}" for t in types) if types else True
|
||||||
|
|
||||||
|
def is_not_hidden(p: Path) -> bool:
|
||||||
|
return not is_hidden(p) or load_hidden
|
||||||
|
|
||||||
|
def walk_level(directory: Path, max_depth: int):
|
||||||
|
directory = directory.resolve()
|
||||||
|
prefix_length = len(directory.parts)
|
||||||
|
for p in directory.rglob("*" if recursive else "[!.]*"):
|
||||||
|
if len(p.parts) - prefix_length <= max_depth:
|
||||||
|
yield p
|
||||||
|
|
||||||
|
glob = "**/*" if recursive else "*"
|
||||||
|
paths = walk_level(path_obj, depth) if depth else path_obj.glob(glob)
|
||||||
|
file_paths = [
|
||||||
|
Text(p) for p in paths if p.is_file() and match_types(p) and is_not_hidden(p)
|
||||||
|
]
|
||||||
|
|
||||||
|
return file_paths
|
||||||
|
|
||||||
|
|
||||||
|
def parse_file_to_record(file_path: str, silent_errors: bool) -> Optional[Record]:
|
||||||
|
# Use the partition function to load the file
|
||||||
|
from unstructured.partition.auto import partition # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
elements = partition(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
if not silent_errors:
|
||||||
|
raise ValueError(f"Error loading file {file_path}: {e}") from e
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create a Record
|
||||||
|
text = "\n\n".join([Text(el) for el in elements])
|
||||||
|
metadata = elements.metadata if hasattr(elements, "metadata") else {}
|
||||||
|
metadata["file_path"] = file_path
|
||||||
|
record = Record(text=text, data=metadata)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def get_elements(
|
||||||
|
file_paths: List[str],
|
||||||
|
silent_errors: bool,
|
||||||
|
max_concurrency: int,
|
||||||
|
use_multithreading: bool,
|
||||||
|
) -> List[Optional[Record]]:
|
||||||
|
if use_multithreading:
|
||||||
|
records = parallel_load_records(file_paths, silent_errors, max_concurrency)
|
||||||
|
else:
|
||||||
|
records = [
|
||||||
|
parse_file_to_record(file_path, silent_errors) for file_path in file_paths
|
||||||
|
]
|
||||||
|
records = list(filter(None, records))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def parallel_load_records(
|
||||||
|
file_paths: List[str], silent_errors: bool, max_concurrency: int
|
||||||
|
) -> List[Optional[Record]]:
|
||||||
|
with futures.ThreadPoolExecutor(max_workers=max_concurrency) as executor:
|
||||||
|
loaded_files = executor.map(
|
||||||
|
lambda file_path: parse_file_to_record(file_path, silent_errors),
|
||||||
|
file_paths,
|
||||||
|
)
|
||||||
|
# loaded_files is an iterator, so we need to convert it to a list
|
||||||
|
return list(loaded_files)
|
||||||
76
src/backend/langflow/components/data/Directory.py
Normal file
76
src/backend/langflow/components/data/Directory.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from langflow import CustomComponent
|
||||||
|
from langflow.base.data.utils import (
|
||||||
|
parallel_load_records,
|
||||||
|
parse_file_to_record,
|
||||||
|
retrieve_file_paths,
|
||||||
|
)
|
||||||
|
from langflow.schema import Record
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryComponent(CustomComponent):
|
||||||
|
display_name = "Directory"
|
||||||
|
description = "Load files from a directory."
|
||||||
|
|
||||||
|
def build_config(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"path": {"display_name": "Path"},
|
||||||
|
"types": {
|
||||||
|
"display_name": "Types",
|
||||||
|
"info": "File types to load. Leave empty to load all types.",
|
||||||
|
},
|
||||||
|
"depth": {"display_name": "Depth", "info": "Depth to search for files."},
|
||||||
|
"max_concurrency": {"display_name": "Max Concurrency", "advanced": True},
|
||||||
|
"load_hidden": {
|
||||||
|
"display_name": "Load Hidden",
|
||||||
|
"advanced": True,
|
||||||
|
"info": "If true, hidden files will be loaded.",
|
||||||
|
},
|
||||||
|
"recursive": {
|
||||||
|
"display_name": "Recursive",
|
||||||
|
"advanced": True,
|
||||||
|
"info": "If true, the search will be recursive.",
|
||||||
|
},
|
||||||
|
"silent_errors": {
|
||||||
|
"display_name": "Silent Errors",
|
||||||
|
"advanced": True,
|
||||||
|
"info": "If true, errors will not raise an exception.",
|
||||||
|
},
|
||||||
|
"use_multithreading": {
|
||||||
|
"display_name": "Use Multithreading",
|
||||||
|
"advanced": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
types: Optional[List[str]] = None,
|
||||||
|
depth: int = 0,
|
||||||
|
max_concurrency: int = 2,
|
||||||
|
load_hidden: bool = False,
|
||||||
|
recursive: bool = True,
|
||||||
|
silent_errors: bool = False,
|
||||||
|
use_multithreading: bool = True,
|
||||||
|
) -> List[Optional[Record]]:
|
||||||
|
if types is None:
|
||||||
|
types = []
|
||||||
|
resolved_path = self.resolve_path(path)
|
||||||
|
file_paths = retrieve_file_paths(
|
||||||
|
resolved_path, types, load_hidden, recursive, depth
|
||||||
|
)
|
||||||
|
loaded_records = []
|
||||||
|
|
||||||
|
if use_multithreading:
|
||||||
|
loaded_records = parallel_load_records(
|
||||||
|
file_paths, silent_errors, max_concurrency
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
loaded_records = [
|
||||||
|
parse_file_to_record(file_path, silent_errors)
|
||||||
|
for file_path in file_paths
|
||||||
|
]
|
||||||
|
loaded_records = list(filter(None, loaded_records))
|
||||||
|
self.status = loaded_records
|
||||||
|
return loaded_records
|
||||||
28
src/backend/langflow/components/data/File.py
Normal file
28
src/backend/langflow/components/data/File.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from langflow import CustomComponent
|
||||||
|
from langflow.base.data.utils import parse_file_to_record
|
||||||
|
from langflow.schema import Record
|
||||||
|
|
||||||
|
|
||||||
|
class FileComponent(CustomComponent):
|
||||||
|
display_name = "File"
|
||||||
|
description = "Load a file."
|
||||||
|
|
||||||
|
def build_config(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"path": {"display_name": "Path"},
|
||||||
|
"silent_errors": {
|
||||||
|
"display_name": "Silent Errors",
|
||||||
|
"advanced": True,
|
||||||
|
"info": "If true, errors will not raise an exception.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
silent_errors: bool = False,
|
||||||
|
) -> Optional[Record]:
|
||||||
|
resolved_path = self.resolve_path(path)
|
||||||
|
return parse_file_to_record(resolved_path, silent_errors)
|
||||||
26
src/backend/langflow/components/data/URL.py
Normal file
26
src/backend/langflow/components/data/URL.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from langchain_community.document_loaders.url import UnstructuredURLLoader
|
||||||
|
|
||||||
|
from langflow import CustomComponent
|
||||||
|
from langflow.schema import Record
|
||||||
|
|
||||||
|
|
||||||
|
class URLComponent(CustomComponent):
|
||||||
|
display_name = "URL"
|
||||||
|
description = "Load a URL."
|
||||||
|
|
||||||
|
def build_config(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"urls": {"display_name": "URL"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def build(
|
||||||
|
self,
|
||||||
|
urls: list[str],
|
||||||
|
) -> Optional[Record]:
|
||||||
|
|
||||||
|
loader = UnstructuredURLLoader(urls=urls)
|
||||||
|
docs = loader.load()
|
||||||
|
records = self.to_records(docs)
|
||||||
|
return records
|
||||||
|
|
@ -40,7 +40,9 @@ class UrlLoaderComponent(CustomComponent):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"No loader found for: {web_path}") from e
|
raise ValueError(f"No loader found for: {web_path}") from e
|
||||||
docs = loader_instance.load()
|
docs = loader_instance.load()
|
||||||
avg_length = sum(len(doc.page_content) for doc in docs if hasattr(doc, "page_content")) / len(docs)
|
avg_length = sum(
|
||||||
|
len(doc.page_content) for doc in docs if hasattr(doc, "page_content")
|
||||||
|
) / len(docs)
|
||||||
self.status = f"""{len(docs)} documents)
|
self.status = f"""{len(docs)} documents)
|
||||||
\nAvg. Document Length (characters): {int(avg_length)}
|
\nAvg. Document Length (characters): {int(avg_length)}
|
||||||
Documents: {docs[:3]}..."""
|
Documents: {docs[:3]}..."""
|
||||||
0
src/backend/langflow/components/data/__init__.py
Normal file
0
src/backend/langflow/components/data/__init__.py
Normal file
|
|
@ -1,161 +0,0 @@
|
||||||
from concurrent import futures
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Text
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
|
||||||
from langflow.schema import Record
|
|
||||||
|
|
||||||
|
|
||||||
class GatherRecordsComponent(CustomComponent):
|
|
||||||
display_name = "Gather Records"
|
|
||||||
description = "Gather records from a directory."
|
|
||||||
|
|
||||||
def build_config(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"path": {"display_name": "Path"},
|
|
||||||
"types": {
|
|
||||||
"display_name": "Types",
|
|
||||||
"info": "File types to load. Leave empty to load all types.",
|
|
||||||
},
|
|
||||||
"depth": {"display_name": "Depth", "info": "Depth to search for files."},
|
|
||||||
"max_concurrency": {"display_name": "Max Concurrency", "advanced": True},
|
|
||||||
"load_hidden": {
|
|
||||||
"display_name": "Load Hidden",
|
|
||||||
"advanced": True,
|
|
||||||
"info": "If true, hidden files will be loaded.",
|
|
||||||
},
|
|
||||||
"recursive": {
|
|
||||||
"display_name": "Recursive",
|
|
||||||
"advanced": True,
|
|
||||||
"info": "If true, the search will be recursive.",
|
|
||||||
},
|
|
||||||
"silent_errors": {
|
|
||||||
"display_name": "Silent Errors",
|
|
||||||
"advanced": True,
|
|
||||||
"info": "If true, errors will not raise an exception.",
|
|
||||||
},
|
|
||||||
"use_multithreading": {
|
|
||||||
"display_name": "Use Multithreading",
|
|
||||||
"advanced": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def is_hidden(self, path: Path) -> bool:
|
|
||||||
return path.name.startswith(".")
|
|
||||||
|
|
||||||
def retrieve_file_paths(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
types: List[str],
|
|
||||||
load_hidden: bool,
|
|
||||||
recursive: bool,
|
|
||||||
depth: int,
|
|
||||||
) -> List[str]:
|
|
||||||
path_obj = Path(path)
|
|
||||||
if not path_obj.exists() or not path_obj.is_dir():
|
|
||||||
raise ValueError(f"Path {path} must exist and be a directory.")
|
|
||||||
|
|
||||||
def match_types(p: Path) -> bool:
|
|
||||||
return any(p.suffix == f".{t}" for t in types) if types else True
|
|
||||||
|
|
||||||
def is_not_hidden(p: Path) -> bool:
|
|
||||||
return not self.is_hidden(p) or load_hidden
|
|
||||||
|
|
||||||
def walk_level(directory: Path, max_depth: int):
|
|
||||||
directory = directory.resolve()
|
|
||||||
prefix_length = len(directory.parts)
|
|
||||||
for p in directory.rglob("*" if recursive else "[!.]*"):
|
|
||||||
if len(p.parts) - prefix_length <= max_depth:
|
|
||||||
yield p
|
|
||||||
|
|
||||||
glob = "**/*" if recursive else "*"
|
|
||||||
paths = walk_level(path_obj, depth) if depth else path_obj.glob(glob)
|
|
||||||
file_paths = [
|
|
||||||
Text(p)
|
|
||||||
for p in paths
|
|
||||||
if p.is_file() and match_types(p) and is_not_hidden(p)
|
|
||||||
]
|
|
||||||
|
|
||||||
return file_paths
|
|
||||||
|
|
||||||
def parse_file_to_record(
|
|
||||||
self, file_path: str, silent_errors: bool
|
|
||||||
) -> Optional[Record]:
|
|
||||||
# Use the partition function to load the file
|
|
||||||
from unstructured.partition.auto import partition # type: ignore
|
|
||||||
|
|
||||||
try:
|
|
||||||
elements = partition(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
if not silent_errors:
|
|
||||||
raise ValueError(f"Error loading file {file_path}: {e}") from e
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Create a Record
|
|
||||||
text = "\n\n".join([Text(el) for el in elements])
|
|
||||||
metadata = elements.metadata if hasattr(elements, "metadata") else {}
|
|
||||||
metadata["file_path"] = file_path
|
|
||||||
record = Record(text=text, data=metadata)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def get_elements(
|
|
||||||
self,
|
|
||||||
file_paths: List[str],
|
|
||||||
silent_errors: bool,
|
|
||||||
max_concurrency: int,
|
|
||||||
use_multithreading: bool,
|
|
||||||
) -> List[Optional[Record]]:
|
|
||||||
if use_multithreading:
|
|
||||||
records = self.parallel_load_records(
|
|
||||||
file_paths, silent_errors, max_concurrency
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
records = [
|
|
||||||
self.parse_file_to_record(file_path, silent_errors)
|
|
||||||
for file_path in file_paths
|
|
||||||
]
|
|
||||||
records = list(filter(None, records))
|
|
||||||
return records
|
|
||||||
|
|
||||||
def parallel_load_records(
|
|
||||||
self, file_paths: List[str], silent_errors: bool, max_concurrency: int
|
|
||||||
) -> List[Optional[Record]]:
|
|
||||||
with futures.ThreadPoolExecutor(max_workers=max_concurrency) as executor:
|
|
||||||
loaded_files = executor.map(
|
|
||||||
lambda file_path: self.parse_file_to_record(file_path, silent_errors),
|
|
||||||
file_paths,
|
|
||||||
)
|
|
||||||
# loaded_files is an iterator, so we need to convert it to a list
|
|
||||||
return list(loaded_files)
|
|
||||||
|
|
||||||
def build(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
types: Optional[List[str]] = None,
|
|
||||||
depth: int = 0,
|
|
||||||
max_concurrency: int = 2,
|
|
||||||
load_hidden: bool = False,
|
|
||||||
recursive: bool = True,
|
|
||||||
silent_errors: bool = False,
|
|
||||||
use_multithreading: bool = True,
|
|
||||||
) -> List[Optional[Record]]:
|
|
||||||
if types is None:
|
|
||||||
types = []
|
|
||||||
resolved_path = self.resolve_path(path)
|
|
||||||
file_paths = self.retrieve_file_paths(
|
|
||||||
resolved_path, types, load_hidden, recursive, depth
|
|
||||||
)
|
|
||||||
loaded_records = []
|
|
||||||
|
|
||||||
if use_multithreading:
|
|
||||||
loaded_records = self.parallel_load_records(
|
|
||||||
file_paths, silent_errors, max_concurrency
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
loaded_records = [
|
|
||||||
self.parse_file_to_record(file_path, silent_errors)
|
|
||||||
for file_path in file_paths
|
|
||||||
]
|
|
||||||
loaded_records = list(filter(None, loaded_records))
|
|
||||||
self.status = loaded_records
|
|
||||||
return loaded_records
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from langflow.components.io.base.chat import ChatComponent
|
from langflow.base.io.chat import ChatComponent
|
||||||
from langflow.field_typing import Text
|
from langflow.field_typing import Text
|
||||||
from langflow.schema import Record
|
from langflow.schema import Record
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from langflow.components.io.base.text import TextComponent
|
from langflow.base.io.text import TextComponent
|
||||||
from langflow.field_typing import Text
|
from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
0
src/backend/langflow/components/inputs/__init__.py
Normal file
0
src/backend/langflow/components/inputs/__init__.py
Normal file
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from langflow.components.io.base.chat import ChatComponent
|
from langflow.base.io.chat import ChatComponent
|
||||||
from langflow.field_typing import Text
|
from langflow.field_typing import Text
|
||||||
from langflow.schema import Record
|
from langflow.schema import Record
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from langflow.components.io.base.text import TextComponent
|
from langflow.base.io.text import TextComponent
|
||||||
from langflow.field_typing import Text
|
from langflow.field_typing import Text
|
||||||
|
|
||||||
|
|
||||||
0
src/backend/langflow/components/outputs/__init__.py
Normal file
0
src/backend/langflow/components/outputs/__init__.py
Normal file
|
|
@ -1,48 +0,0 @@
|
||||||
# Implement ShouldRunNext component
|
|
||||||
from langchain_core.prompts import PromptTemplate
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
|
||||||
from langflow.field_typing import BaseLanguageModel, Prompt
|
|
||||||
|
|
||||||
|
|
||||||
class ShouldRunNext(CustomComponent):
|
|
||||||
display_name = "Should Run Next"
|
|
||||||
description = "Decides whether to run the next component."
|
|
||||||
|
|
||||||
def build_config(self):
|
|
||||||
return {
|
|
||||||
"prompt": {
|
|
||||||
"display_name": "Prompt",
|
|
||||||
"info": "The prompt to use for the decision. It should generate a boolean response (True or False).",
|
|
||||||
},
|
|
||||||
"llm": {
|
|
||||||
"display_name": "LLM",
|
|
||||||
"info": "The language model to use for the decision.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def build(self, template: Prompt, llm: BaseLanguageModel, **kwargs) -> bool:
|
|
||||||
# This is a simple component that always returns True
|
|
||||||
prompt_template = PromptTemplate.from_template(template)
|
|
||||||
|
|
||||||
attributes_to_check = ["text", "page_content"]
|
|
||||||
for key, value in kwargs.items():
|
|
||||||
for attribute in attributes_to_check:
|
|
||||||
if hasattr(value, attribute):
|
|
||||||
kwargs[key] = getattr(value, attribute)
|
|
||||||
|
|
||||||
chain = prompt_template | llm
|
|
||||||
result = chain.invoke(kwargs)
|
|
||||||
if hasattr(result, "content") and isinstance(result.content, str):
|
|
||||||
result = result.content
|
|
||||||
elif isinstance(result, str):
|
|
||||||
result = result
|
|
||||||
else:
|
|
||||||
result = result.get("response")
|
|
||||||
|
|
||||||
if result.lower() not in ["true", "false"]:
|
|
||||||
raise ValueError("The prompt should generate a boolean response (True or False).")
|
|
||||||
# The string should be the words true or false
|
|
||||||
# if not raise an error
|
|
||||||
bool_result = result.lower() == "true"
|
|
||||||
return bool_result
|
|
||||||
109
src/backend/langflow/components/utilities/APIRequest.py
Normal file
109
src/backend/langflow/components/utilities/APIRequest.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from langflow import CustomComponent
|
||||||
|
from langflow.schema import Record
|
||||||
|
from langflow.services.database.models.base import orjson_dumps
|
||||||
|
|
||||||
|
|
||||||
|
class APIRequest(CustomComponent):
|
||||||
|
display_name: str = "API Request"
|
||||||
|
description: str = "Make an HTTP request to the given URL."
|
||||||
|
output_types: list[str] = ["Record"]
|
||||||
|
documentation: str = "https://docs.langflow.org/components/utilities#api-request"
|
||||||
|
beta: bool = True
|
||||||
|
field_config = {
|
||||||
|
"url": {"display_name": "URL", "info": "The URL to make the request to."},
|
||||||
|
"method": {
|
||||||
|
"display_name": "Method",
|
||||||
|
"info": "The HTTP method to use.",
|
||||||
|
"field_type": "str",
|
||||||
|
"options": ["GET", "POST", "PATCH", "PUT"],
|
||||||
|
"value": "GET",
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"display_name": "Headers",
|
||||||
|
"info": "The headers to send with the request.",
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"display_name": "Record",
|
||||||
|
"info": "The record to send with the request (for POST, PATCH, PUT).",
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"display_name": "Timeout",
|
||||||
|
"field_type": "int",
|
||||||
|
"info": "The timeout to use for the request.",
|
||||||
|
"value": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def make_request(
|
||||||
|
self,
|
||||||
|
session: requests.Session,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
record: Optional[Record] = None,
|
||||||
|
timeout: int = 5,
|
||||||
|
) -> Record:
|
||||||
|
method = method.upper()
|
||||||
|
if method not in ["GET", "POST", "PATCH", "PUT"]:
|
||||||
|
raise ValueError(f"Unsupported method: {method}")
|
||||||
|
|
||||||
|
data = record.text if record else None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.request(
|
||||||
|
method, url, headers=headers, content=data, timeout=timeout
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response_json = response.json()
|
||||||
|
result = orjson_dumps(response_json, indent_2=False)
|
||||||
|
except Exception:
|
||||||
|
result = response.text
|
||||||
|
return Record(
|
||||||
|
text=result,
|
||||||
|
data={
|
||||||
|
"source": url,
|
||||||
|
"headers": headers,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return Record(
|
||||||
|
text="Request Timed Out",
|
||||||
|
data={"source": url, "headers": headers, "status_code": 408},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return Record(
|
||||||
|
text=str(exc),
|
||||||
|
data={"source": url, "headers": headers, "status_code": 500},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def build(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: List[str],
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
record: Optional[Union[Record, List[Record]]] = None,
|
||||||
|
timeout: int = 5,
|
||||||
|
) -> List[Record]:
|
||||||
|
if headers is None:
|
||||||
|
headers = {}
|
||||||
|
urls = url if isinstance(url, list) else [url]
|
||||||
|
records = (
|
||||||
|
record
|
||||||
|
if isinstance(record, list)
|
||||||
|
else [record] if record else [None] * len(urls)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[
|
||||||
|
self.make_request(method, u, headers, doc, timeout)
|
||||||
|
for u, doc in zip(urls, records)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
@ -4,6 +4,7 @@ from langflow.field_typing import Data
|
||||||
|
|
||||||
class Component(CustomComponent):
|
class Component(CustomComponent):
|
||||||
documentation: str = "http://docs.langflow.org/components/custom"
|
documentation: str = "http://docs.langflow.org/components/custom"
|
||||||
|
icon = "custom_components"
|
||||||
|
|
||||||
def build_config(self):
|
def build_config(self):
|
||||||
return {"param": {"display_name": "Parameter"}}
|
return {"param": {"display_name": "Parameter"}}
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
from typing import Optional, Text
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from langchain_core.documents import Document
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
|
||||||
from langflow.services.database.models.base import orjson_dumps
|
|
||||||
|
|
||||||
|
|
||||||
class GetRequest(CustomComponent):
|
|
||||||
display_name: str = "GET Request"
|
|
||||||
description: str = "Make a GET request to the given URL."
|
|
||||||
output_types: list[str] = ["Document"]
|
|
||||||
documentation: str = "https://docs.langflow.org/components/utilities#get-request"
|
|
||||||
beta: bool = True
|
|
||||||
field_config = {
|
|
||||||
"url": {
|
|
||||||
"display_name": "URL",
|
|
||||||
"info": "The URL to make the request to",
|
|
||||||
"is_list": True,
|
|
||||||
},
|
|
||||||
"headers": {
|
|
||||||
"display_name": "Headers",
|
|
||||||
"info": "The headers to send with the request.",
|
|
||||||
},
|
|
||||||
"code": {"show": False},
|
|
||||||
"timeout": {
|
|
||||||
"display_name": "Timeout",
|
|
||||||
"field_type": "int",
|
|
||||||
"info": "The timeout to use for the request.",
|
|
||||||
"value": 5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_document(self, session: requests.Session, url: str, headers: Optional[dict], timeout: int) -> Document:
|
|
||||||
try:
|
|
||||||
response = session.get(url, headers=headers, timeout=int(timeout))
|
|
||||||
try:
|
|
||||||
response_json = response.json()
|
|
||||||
result = orjson_dumps(response_json, indent_2=False)
|
|
||||||
except Exception:
|
|
||||||
result = response.text
|
|
||||||
self.repr_value = result
|
|
||||||
return Document(
|
|
||||||
page_content=result,
|
|
||||||
metadata={
|
|
||||||
"source": url,
|
|
||||||
"headers": headers,
|
|
||||||
"status_code": response.status_code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except requests.Timeout:
|
|
||||||
return Document(
|
|
||||||
page_content="Request Timed Out",
|
|
||||||
metadata={"source": url, "headers": headers, "status_code": 408},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return Document(
|
|
||||||
page_content=Text(exc),
|
|
||||||
metadata={"source": url, "headers": headers, "status_code": 500},
|
|
||||||
)
|
|
||||||
|
|
||||||
def build(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
headers: Optional[dict] = None,
|
|
||||||
timeout: int = 5,
|
|
||||||
) -> list[Document]:
|
|
||||||
if headers is None:
|
|
||||||
headers = {}
|
|
||||||
urls = url if isinstance(url, list) else [url]
|
|
||||||
with requests.Session() as session:
|
|
||||||
documents = [self.get_document(session, u, headers, timeout) for u in urls]
|
|
||||||
self.repr_value = documents
|
|
||||||
return documents
|
|
||||||
19
src/backend/langflow/components/utilities/ListFlows.py
Normal file
19
src/backend/langflow/components/utilities/ListFlows.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from langflow import CustomComponent
|
||||||
|
from langflow.schema import Record
|
||||||
|
|
||||||
|
|
||||||
|
class ListFlowsComponent(CustomComponent):
|
||||||
|
display_name = "List Flows"
|
||||||
|
description = "A component to list all available flows."
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
) -> List[Record]:
|
||||||
|
flows = self.list_flows()
|
||||||
|
self.status = flows
|
||||||
|
return flows
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
from typing import Optional, Text
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from langchain_core.documents import Document
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
|
||||||
from langflow.services.database.models.base import orjson_dumps
|
|
||||||
|
|
||||||
|
|
||||||
class PostRequest(CustomComponent):
|
|
||||||
display_name: str = "POST Request"
|
|
||||||
description: str = "Make a POST request to the given URL."
|
|
||||||
output_types: list[str] = ["Document"]
|
|
||||||
documentation: str = "https://docs.langflow.org/components/utilities#post-request"
|
|
||||||
beta: bool = True
|
|
||||||
field_config = {
|
|
||||||
"url": {"display_name": "URL", "info": "The URL to make the request to."},
|
|
||||||
"headers": {
|
|
||||||
"display_name": "Headers",
|
|
||||||
"info": "The headers to send with the request.",
|
|
||||||
},
|
|
||||||
"code": {"show": False},
|
|
||||||
"document": {"display_name": "Document"},
|
|
||||||
}
|
|
||||||
|
|
||||||
def post_document(
|
|
||||||
self,
|
|
||||||
session: requests.Session,
|
|
||||||
document: Document,
|
|
||||||
url: str,
|
|
||||||
headers: Optional[dict] = None,
|
|
||||||
) -> Document:
|
|
||||||
try:
|
|
||||||
response = session.post(url, headers=headers, data=document.page_content)
|
|
||||||
try:
|
|
||||||
response_json = response.json()
|
|
||||||
result = orjson_dumps(response_json, indent_2=False)
|
|
||||||
except Exception:
|
|
||||||
result = response.text
|
|
||||||
self.repr_value = result
|
|
||||||
return Document(
|
|
||||||
page_content=result,
|
|
||||||
metadata={
|
|
||||||
"source": url,
|
|
||||||
"headers": headers,
|
|
||||||
"status_code": response,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return Document(
|
|
||||||
page_content=Text(exc),
|
|
||||||
metadata={
|
|
||||||
"source": url,
|
|
||||||
"headers": headers,
|
|
||||||
"status_code": 500,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def build(
|
|
||||||
self,
|
|
||||||
document: Document,
|
|
||||||
url: str,
|
|
||||||
headers: Optional[dict] = None,
|
|
||||||
) -> list[Document]:
|
|
||||||
if headers is None:
|
|
||||||
headers = {}
|
|
||||||
|
|
||||||
if not isinstance(document, list) and isinstance(document, Document):
|
|
||||||
documents: list[Document] = [document]
|
|
||||||
elif isinstance(document, list) and all(isinstance(doc, Document) for doc in document):
|
|
||||||
documents = document
|
|
||||||
else:
|
|
||||||
raise ValueError("document must be a Document or a list of Documents")
|
|
||||||
|
|
||||||
with requests.Session() as session:
|
|
||||||
documents = [self.post_document(session, doc, url, headers) for doc in documents]
|
|
||||||
self.repr_value = documents
|
|
||||||
return documents
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
from typing import List, Optional, Text
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from langchain_core.documents import Document
|
|
||||||
|
|
||||||
from langflow import CustomComponent
|
|
||||||
from langflow.services.database.models.base import orjson_dumps
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateRequest(CustomComponent):
|
|
||||||
display_name: str = "Update Request"
|
|
||||||
description: str = "Make a PATCH request to the given URL."
|
|
||||||
output_types: list[str] = ["Document"]
|
|
||||||
documentation: str = "https://docs.langflow.org/components/utilities#update-request"
|
|
||||||
beta: bool = True
|
|
||||||
field_config = {
|
|
||||||
"url": {"display_name": "URL", "info": "The URL to make the request to."},
|
|
||||||
"headers": {
|
|
||||||
"display_name": "Headers",
|
|
||||||
"field_type": "NestedDict",
|
|
||||||
"info": "The headers to send with the request.",
|
|
||||||
},
|
|
||||||
"code": {"show": False},
|
|
||||||
"document": {"display_name": "Document"},
|
|
||||||
"method": {
|
|
||||||
"display_name": "Method",
|
|
||||||
"field_type": "str",
|
|
||||||
"info": "The HTTP method to use.",
|
|
||||||
"options": ["PATCH", "PUT"],
|
|
||||||
"value": "PATCH",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def update_document(
|
|
||||||
self,
|
|
||||||
session: requests.Session,
|
|
||||||
document: Document,
|
|
||||||
url: str,
|
|
||||||
headers: Optional[dict] = None,
|
|
||||||
method: str = "PATCH",
|
|
||||||
) -> Document:
|
|
||||||
try:
|
|
||||||
if method == "PATCH":
|
|
||||||
response = session.patch(url, headers=headers, data=document.page_content)
|
|
||||||
elif method == "PUT":
|
|
||||||
response = session.put(url, headers=headers, data=document.page_content)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported method: {method}")
|
|
||||||
try:
|
|
||||||
response_json = response.json()
|
|
||||||
result = orjson_dumps(response_json, indent_2=False)
|
|
||||||
except Exception:
|
|
||||||
result = response.text
|
|
||||||
self.repr_value = result
|
|
||||||
return Document(
|
|
||||||
page_content=result,
|
|
||||||
metadata={
|
|
||||||
"source": url,
|
|
||||||
"headers": headers,
|
|
||||||
"status_code": response.status_code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return Document(
|
|
||||||
page_content=Text(exc),
|
|
||||||
metadata={"source": url, "headers": headers, "status_code": 500},
|
|
||||||
)
|
|
||||||
|
|
||||||
def build(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
document: Document,
|
|
||||||
url: str,
|
|
||||||
headers: Optional[dict] = None,
|
|
||||||
) -> List[Document]:
|
|
||||||
if headers is None:
|
|
||||||
headers = {}
|
|
||||||
|
|
||||||
if not isinstance(document, list) and isinstance(document, Document):
|
|
||||||
documents: list[Document] = [document]
|
|
||||||
elif isinstance(document, list) and all(isinstance(doc, Document) for doc in document):
|
|
||||||
documents = document
|
|
||||||
else:
|
|
||||||
raise ValueError("document must be a Document or a list of Documents")
|
|
||||||
|
|
||||||
with requests.Session() as session:
|
|
||||||
documents = [self.update_document(session, doc, url, headers, method) for doc in documents]
|
|
||||||
self.repr_value = documents
|
|
||||||
return documents
|
|
||||||
0
src/backend/langflow/components/utilities/__init__.py
Normal file
0
src/backend/langflow/components/utilities/__init__.py
Normal file
|
|
@ -357,6 +357,11 @@ class Graph:
|
||||||
for vertex_id in removed_vertex_ids:
|
for vertex_id in removed_vertex_ids:
|
||||||
self.remove_vertex(vertex_id)
|
self.remove_vertex(vertex_id)
|
||||||
|
|
||||||
|
# The order here matters because adding the vertex is required
|
||||||
|
# if any of them have edges that point to any of the new vertices
|
||||||
|
# By adding them first, them adding the edges we ensure that the
|
||||||
|
# edges have valid vertices to point to
|
||||||
|
|
||||||
# Add new vertices
|
# Add new vertices
|
||||||
for vertex_id in new_vertex_ids:
|
for vertex_id in new_vertex_ids:
|
||||||
new_vertex = other.get_vertex(vertex_id)
|
new_vertex = other.get_vertex(vertex_id)
|
||||||
|
|
@ -366,6 +371,8 @@ class Graph:
|
||||||
for vertex_id in new_vertex_ids:
|
for vertex_id in new_vertex_ids:
|
||||||
new_vertex = other.get_vertex(vertex_id)
|
new_vertex = other.get_vertex(vertex_id)
|
||||||
self._update_edges(new_vertex)
|
self._update_edges(new_vertex)
|
||||||
|
# Graph is set at the end because the edges come from the graph
|
||||||
|
# and the other graph is where the new edges and vertices come from
|
||||||
new_vertex.graph = self
|
new_vertex.graph = self
|
||||||
|
|
||||||
# Update existing vertices that have changed
|
# Update existing vertices that have changed
|
||||||
|
|
@ -395,9 +402,9 @@ class Graph:
|
||||||
vertex.params = {}
|
vertex.params = {}
|
||||||
vertex._build_params()
|
vertex._build_params()
|
||||||
vertex.graph = self
|
vertex.graph = self
|
||||||
# If the vertex is pinned, we don't want
|
# If the vertex is frozen, we don't want
|
||||||
# to reset the results nor the _built attribute
|
# to reset the results nor the _built attribute
|
||||||
if not vertex.pinned:
|
if not vertex.frozen:
|
||||||
vertex._built = False
|
vertex._built = False
|
||||||
vertex.result = None
|
vertex.result = None
|
||||||
vertex.artifacts = {}
|
vertex.artifacts = {}
|
||||||
|
|
@ -410,7 +417,7 @@ class Graph:
|
||||||
for vid in [edge.source_id, edge.target_id]:
|
for vid in [edge.source_id, edge.target_id]:
|
||||||
if vid in self.vertex_map:
|
if vid in self.vertex_map:
|
||||||
_vertex = self.vertex_map[vid]
|
_vertex = self.vertex_map[vid]
|
||||||
if not _vertex.pinned:
|
if not _vertex.frozen:
|
||||||
_vertex._build_params()
|
_vertex._build_params()
|
||||||
|
|
||||||
def _add_vertex(self, vertex: Vertex) -> None:
|
def _add_vertex(self, vertex: Vertex) -> None:
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ class Vertex:
|
||||||
self.base_type = state["base_type"]
|
self.base_type = state["base_type"]
|
||||||
self.is_task = state["is_task"]
|
self.is_task = state["is_task"]
|
||||||
self.id = state["id"]
|
self.id = state["id"]
|
||||||
self.pinned = state.get("pinned", False)
|
self.frozen = state.get("frozen", False)
|
||||||
self._parse_data()
|
self._parse_data()
|
||||||
if "_built_object" in state:
|
if "_built_object" in state:
|
||||||
self._built_object = state["_built_object"]
|
self._built_object = state["_built_object"]
|
||||||
|
|
@ -217,7 +217,7 @@ class Vertex:
|
||||||
self.data = self._data["data"]
|
self.data = self._data["data"]
|
||||||
self.output = self.data["node"]["base_classes"]
|
self.output = self.data["node"]["base_classes"]
|
||||||
self.display_name = self.data["node"].get("display_name", self.id.split("-")[0])
|
self.display_name = self.data["node"].get("display_name", self.id.split("-")[0])
|
||||||
self.pinned = self.data["node"].get("pinned", False)
|
self.frozen = self.data["node"].get("frozen", False)
|
||||||
self.selected_output_type = self.data["node"].get("selected_output_type")
|
self.selected_output_type = self.data["node"].get("selected_output_type")
|
||||||
self.is_input = self.data["node"].get("is_input") or self.is_input
|
self.is_input = self.data["node"].get("is_input") or self.is_input
|
||||||
self.is_output = self.data["node"].get("is_output") or self.is_output
|
self.is_output = self.data["node"].get("is_output") or self.is_output
|
||||||
|
|
@ -676,7 +676,7 @@ class Vertex:
|
||||||
self.build_inactive()
|
self.build_inactive()
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.pinned and self._built:
|
if self.frozen and self._built:
|
||||||
return self.get_requester_result(requester)
|
return self.get_requester_result(requester)
|
||||||
elif self._built and requester is not None:
|
elif self._built and requester is not None:
|
||||||
# This means that the vertex has already been built
|
# This means that the vertex has already been built
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ class ChainVertex(Vertex):
|
||||||
if isinstance(value, PromptVertex):
|
if isinstance(value, PromptVertex):
|
||||||
# Build the PromptVertex, passing the tools if available
|
# Build the PromptVertex, passing the tools if available
|
||||||
tools = kwargs.get("tools", None)
|
tools = kwargs.get("tools", None)
|
||||||
self.params[key] = value.build(tools=tools, pinned=force)
|
self.params[key] = value.build(tools=tools, frozen=force)
|
||||||
|
|
||||||
await self._build(user_id=user_id)
|
await self._build(user_id=user_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ ATTR_FUNC_MAPPING: dict[str, Callable] = {
|
||||||
"beta": getattr_return_bool,
|
"beta": getattr_return_bool,
|
||||||
"documentation": getattr_return_str,
|
"documentation": getattr_return_str,
|
||||||
"icon": validate_icon,
|
"icon": validate_icon,
|
||||||
"pinned": getattr_return_bool,
|
"frozen": getattr_return_bool,
|
||||||
"is_input": getattr_return_bool,
|
"is_input": getattr_return_bool,
|
||||||
"is_output": getattr_return_bool,
|
"is_output": getattr_return_bool,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ class ComponentFunctionEntrypointNameNullError(HTTPException):
|
||||||
|
|
||||||
class Component:
|
class Component:
|
||||||
ERROR_CODE_NULL: ClassVar[str] = "Python code must be provided."
|
ERROR_CODE_NULL: ClassVar[str] = "Python code must be provided."
|
||||||
ERROR_FUNCTION_ENTRYPOINT_NAME_NULL: ClassVar[str] = "The name of the entrypoint function must be provided."
|
ERROR_FUNCTION_ENTRYPOINT_NAME_NULL: ClassVar[str] = (
|
||||||
|
"The name of the entrypoint function must be provided."
|
||||||
|
)
|
||||||
|
|
||||||
code: Optional[str] = None
|
code: Optional[str] = None
|
||||||
_function_entrypoint_name: str = "build"
|
_function_entrypoint_name: str = "build"
|
||||||
|
|
@ -39,6 +41,7 @@ class Component:
|
||||||
def __setattr__(self, key, value):
|
def __setattr__(self, key, value):
|
||||||
if key == "_user_id" and hasattr(self, "_user_id"):
|
if key == "_user_id" and hasattr(self, "_user_id"):
|
||||||
warnings.warn("user_id is immutable and cannot be changed.")
|
warnings.warn("user_id is immutable and cannot be changed.")
|
||||||
|
else:
|
||||||
super().__setattr__(key, value)
|
super().__setattr__(key, value)
|
||||||
|
|
||||||
@cachedmethod(cache=operator.attrgetter("cache"))
|
@cachedmethod(cache=operator.attrgetter("cache"))
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ class CustomComponent(Component):
|
||||||
"""The field configuration of the component. Defaults to an empty dictionary."""
|
"""The field configuration of the component. Defaults to an empty dictionary."""
|
||||||
field_order: Optional[List[str]] = None
|
field_order: Optional[List[str]] = None
|
||||||
"""The field order of the component. Defaults to an empty list."""
|
"""The field order of the component. Defaults to an empty list."""
|
||||||
pinned: Optional[bool] = False
|
frozen: Optional[bool] = False
|
||||||
"""The default pinned state of the component. Defaults to False."""
|
"""The default frozen state of the component. Defaults to False."""
|
||||||
build_parameters: Optional[dict] = None
|
build_parameters: Optional[dict] = None
|
||||||
"""The build parameters of the component. Defaults to None."""
|
"""The build parameters of the component. Defaults to None."""
|
||||||
selected_output_type: Optional[str] = None
|
selected_output_type: Optional[str] = None
|
||||||
|
|
@ -73,6 +73,7 @@ class CustomComponent(Component):
|
||||||
user_id: Optional[Union[UUID, str]] = None
|
user_id: Optional[Union[UUID, str]] = None
|
||||||
status: Optional[Any] = None
|
status: Optional[Any] = None
|
||||||
"""The status of the component. This is displayed on the frontend. Defaults to None."""
|
"""The status of the component. This is displayed on the frontend. Defaults to None."""
|
||||||
|
_flows_records: Optional[List[Record]] = None
|
||||||
|
|
||||||
def update_state(self, name: str, value: Any):
|
def update_state(self, name: str, value: Any):
|
||||||
try:
|
try:
|
||||||
|
|
@ -344,14 +345,34 @@ class CustomComponent(Component):
|
||||||
async def run_flow(
|
async def run_flow(
|
||||||
self,
|
self,
|
||||||
input_value: Union[str, list[str]],
|
input_value: Union[str, list[str]],
|
||||||
flow_id: str,
|
flow_id: Optional[str] = None,
|
||||||
|
flow_name: Optional[str] = None,
|
||||||
tweaks: Optional[dict] = None,
|
tweaks: Optional[dict] = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
if not flow_id and not flow_name:
|
||||||
|
raise ValueError("Flow ID or Flow Name is required")
|
||||||
|
if not self._flows_records:
|
||||||
|
self.list_flows()
|
||||||
|
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
|
||||||
|
]
|
||||||
|
if not flow_ids:
|
||||||
|
raise ValueError(f"Flow {flow_name} not found")
|
||||||
|
elif len(flow_ids) > 1:
|
||||||
|
raise ValueError(f"Multiple flows found with the name {flow_name}")
|
||||||
|
flow_id = flow_ids[0]
|
||||||
|
|
||||||
|
if not flow_id:
|
||||||
|
raise ValueError(f"Flow {flow_name} not found")
|
||||||
|
|
||||||
graph = await self.load_flow(flow_id, tweaks)
|
graph = await self.load_flow(flow_id, tweaks)
|
||||||
input_value_dict = {"input_value": input_value}
|
input_value_dict = {"input_value": input_value}
|
||||||
return await graph.run(input_value_dict, stream=False)
|
return await graph.run(input_value_dict, stream=False)
|
||||||
|
|
||||||
def list_flows(self, *, get_session: Optional[Callable] = None) -> List[Flow]:
|
def list_flows(self, *, get_session: Optional[Callable] = None) -> List[Record]:
|
||||||
if not self._user_id:
|
if not self._user_id:
|
||||||
raise ValueError("Session is invalid")
|
raise ValueError("Session is invalid")
|
||||||
try:
|
try:
|
||||||
|
|
@ -359,11 +380,16 @@ 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)
|
select(Flow)
|
||||||
|
.where(Flow.user_id == self._user_id)
|
||||||
|
.where(Flow.is_component == False)
|
||||||
).all()
|
).all()
|
||||||
return flows
|
|
||||||
|
flows_records = [flow.to_record() for flow in flows]
|
||||||
|
self._flows_records = flows_records
|
||||||
|
return flows_records
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError("Session is invalid") from e
|
raise ValueError(f"Error listing flows: {e}")
|
||||||
|
|
||||||
def build(self, *args: Any, **kwargs: Any) -> Any:
|
def build(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Any, Dict, List, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from langflow.field_typing.range_spec import RangeSpec
|
from langflow.field_typing.range_spec import RangeSpec
|
||||||
from langflow.interface.custom.attributes import ATTR_FUNC_MAPPING
|
from langflow.interface.custom.attributes import ATTR_FUNC_MAPPING
|
||||||
from langflow.interface.custom.code_parser.utils import extract_inner_type
|
from langflow.interface.custom.code_parser.utils import extract_inner_type
|
||||||
|
|
@ -23,7 +25,6 @@ from langflow.template.frontend_node.custom_components import (
|
||||||
)
|
)
|
||||||
from langflow.utils import validate
|
from langflow.utils import validate
|
||||||
from langflow.utils.util import get_base_classes
|
from langflow.utils.util import get_base_classes
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
def add_output_types(
|
def add_output_types(
|
||||||
|
|
@ -249,10 +250,12 @@ 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)
|
||||||
|
# This has to be done to set refresh if options or value are callable
|
||||||
|
update_field_dict(field_dict)
|
||||||
if update_field is not None and field_name != update_field:
|
if update_field is not None and field_name != update_field:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
update_field_dict(field_dict)
|
update_field_dict(field_dict, call=True)
|
||||||
build_config[field_name] = field_dict
|
build_config[field_name] = field_dict
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Error while getting build_config: {str(exc)}")
|
logger.error(f"Error while getting build_config: {str(exc)}")
|
||||||
|
|
@ -399,14 +402,16 @@ def build_custom_components(settings_service):
|
||||||
return custom_components_from_file
|
return custom_components_from_file
|
||||||
|
|
||||||
|
|
||||||
def update_field_dict(field_dict):
|
def update_field_dict(field_dict, call=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 "options" in field_dict and callable(field_dict["options"]):
|
if "options" in field_dict and callable(field_dict["options"]):
|
||||||
|
if call:
|
||||||
field_dict["options"] = field_dict["options"]()
|
field_dict["options"] = field_dict["options"]()
|
||||||
# Also update the "refresh" key
|
# Also update the "refresh" key
|
||||||
field_dict["refresh"] = True
|
field_dict["refresh"] = True
|
||||||
|
|
||||||
if "value" in field_dict and callable(field_dict["value"]):
|
if "value" in field_dict and callable(field_dict["value"]):
|
||||||
|
if call:
|
||||||
field_dict["value"] = field_dict["value"]()
|
field_dict["value"] = field_dict["value"]()
|
||||||
field_dict["refresh"] = True
|
field_dict["refresh"] = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from langflow.interface.retrievers.base import retriever_creator
|
||||||
from langflow.interface.toolkits.base import toolkits_creator
|
from langflow.interface.toolkits.base import toolkits_creator
|
||||||
from langflow.interface.utils import load_file_into_dict
|
from langflow.interface.utils import load_file_into_dict
|
||||||
from langflow.interface.wrappers.base import wrapper_creator
|
from langflow.interface.wrappers.base import wrapper_creator
|
||||||
|
from langflow.schema.schema import Record
|
||||||
from langflow.utils import validate
|
from langflow.utils import validate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -143,9 +144,13 @@ async def instantiate_based_on_type(
|
||||||
return class_object(**params)
|
return class_object(**params)
|
||||||
|
|
||||||
|
|
||||||
async def instantiate_custom_component(node_type, class_object, params, user_id, vertex):
|
async def instantiate_custom_component(
|
||||||
|
node_type, class_object, params, user_id, vertex
|
||||||
|
):
|
||||||
params_copy = params.copy()
|
params_copy = params.copy()
|
||||||
class_object: Type["CustomComponent"] = eval_custom_component_code(params_copy.pop("code"))
|
class_object: Type["CustomComponent"] = eval_custom_component_code(
|
||||||
|
params_copy.pop("code")
|
||||||
|
)
|
||||||
custom_component: "CustomComponent" = class_object(
|
custom_component: "CustomComponent" = class_object(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
parameters=params_copy,
|
parameters=params_copy,
|
||||||
|
|
@ -165,8 +170,10 @@ async def instantiate_custom_component(node_type, class_object, params, user_id,
|
||||||
else:
|
else:
|
||||||
# Call the build method directly if it's sync
|
# Call the build method directly if it's sync
|
||||||
build_result = custom_component.build(**params_copy)
|
build_result = custom_component.build(**params_copy)
|
||||||
|
custom_repr = custom_component.custom_repr()
|
||||||
return custom_component, build_result, {"repr": custom_component.custom_repr()}
|
if not custom_repr and isinstance(build_result, (dict, Record, str)):
|
||||||
|
custom_repr = build_result
|
||||||
|
return custom_component, build_result, {"repr": custom_repr}
|
||||||
|
|
||||||
|
|
||||||
def instantiate_wrapper(node_type, class_object, params):
|
def instantiate_wrapper(node_type, class_object, params):
|
||||||
|
|
@ -219,7 +226,9 @@ def instantiate_memory(node_type, class_object, params):
|
||||||
# I want to catch a specific attribute error that happens
|
# I want to catch a specific attribute error that happens
|
||||||
# when the object does not have a cursor attribute
|
# when the object does not have a cursor attribute
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if "object has no attribute 'cursor'" in str(exc) or 'object has no field "conn"' in str(exc):
|
if "object has no attribute 'cursor'" in str(
|
||||||
|
exc
|
||||||
|
) or 'object has no field "conn"' in str(exc):
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
(
|
(
|
||||||
"Failed to build connection to database."
|
"Failed to build connection to database."
|
||||||
|
|
@ -262,7 +271,9 @@ def instantiate_agent(node_type, class_object: Type[agent_module.Agent], params:
|
||||||
if class_method := getattr(class_object, method, None):
|
if class_method := getattr(class_object, method, None):
|
||||||
agent = class_method(**params)
|
agent = class_method(**params)
|
||||||
tools = params.get("tools", [])
|
tools = params.get("tools", [])
|
||||||
return AgentExecutor.from_agent_and_tools(agent=agent, tools=tools, handle_parsing_errors=True)
|
return AgentExecutor.from_agent_and_tools(
|
||||||
|
agent=agent, tools=tools, handle_parsing_errors=True
|
||||||
|
)
|
||||||
return load_agent_executor(class_object, params)
|
return load_agent_executor(class_object, params)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -318,7 +329,11 @@ def instantiate_embedding(node_type, class_object, params: Dict):
|
||||||
try:
|
try:
|
||||||
return class_object(**params)
|
return class_object(**params)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
params = {key: value for key, value in params.items() if key in class_object.model_fields}
|
params = {
|
||||||
|
key: value
|
||||||
|
for key, value in params.items()
|
||||||
|
if key in class_object.model_fields
|
||||||
|
}
|
||||||
return class_object(**params)
|
return class_object(**params)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -330,7 +345,9 @@ def instantiate_vectorstore(class_object: Type[VectorStore], params: Dict):
|
||||||
if "texts" in params:
|
if "texts" in params:
|
||||||
params["documents"] = params.pop("texts")
|
params["documents"] = params.pop("texts")
|
||||||
if "documents" in params:
|
if "documents" in params:
|
||||||
params["documents"] = [doc for doc in params["documents"] if isinstance(doc, Document)]
|
params["documents"] = [
|
||||||
|
doc for doc in params["documents"] if isinstance(doc, Document)
|
||||||
|
]
|
||||||
if initializer := vecstore_initializer.get(class_object.__name__):
|
if initializer := vecstore_initializer.get(class_object.__name__):
|
||||||
vecstore = initializer(class_object, params)
|
vecstore = initializer(class_object, params)
|
||||||
else:
|
else:
|
||||||
|
|
@ -345,7 +362,9 @@ def instantiate_vectorstore(class_object: Type[VectorStore], params: Dict):
|
||||||
return vecstore
|
return vecstore
|
||||||
|
|
||||||
|
|
||||||
def instantiate_documentloader(node_type: str, class_object: Type[BaseLoader], params: Dict):
|
def instantiate_documentloader(
|
||||||
|
node_type: str, class_object: Type[BaseLoader], params: Dict
|
||||||
|
):
|
||||||
if "file_filter" in params:
|
if "file_filter" in params:
|
||||||
# file_filter will be a string but we need a function
|
# file_filter will be a string but we need a function
|
||||||
# that will be used to filter the files using file_filter
|
# that will be used to filter the files using file_filter
|
||||||
|
|
@ -354,13 +373,17 @@ def instantiate_documentloader(node_type: str, class_object: Type[BaseLoader], p
|
||||||
# in x and if it is, we will return True
|
# in x and if it is, we will return True
|
||||||
file_filter = params.pop("file_filter")
|
file_filter = params.pop("file_filter")
|
||||||
extensions = file_filter.split(",")
|
extensions = file_filter.split(",")
|
||||||
params["file_filter"] = lambda x: any(extension.strip() in x for extension in extensions)
|
params["file_filter"] = lambda x: any(
|
||||||
|
extension.strip() in x for extension in extensions
|
||||||
|
)
|
||||||
metadata = params.pop("metadata", None)
|
metadata = params.pop("metadata", None)
|
||||||
if metadata and isinstance(metadata, str):
|
if metadata and isinstance(metadata, str):
|
||||||
try:
|
try:
|
||||||
metadata = orjson.loads(metadata)
|
metadata = orjson.loads(metadata)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValueError("The metadata you provided is not a valid JSON string.") from exc
|
raise ValueError(
|
||||||
|
"The metadata you provided is not a valid JSON string."
|
||||||
|
) from exc
|
||||||
|
|
||||||
if node_type == "WebBaseLoader":
|
if node_type == "WebBaseLoader":
|
||||||
if web_path := params.pop("web_path", None):
|
if web_path := params.pop("web_path", None):
|
||||||
|
|
@ -393,12 +416,16 @@ def instantiate_textsplitter(
|
||||||
"Try changing the chunk_size of the Text Splitter."
|
"Try changing the chunk_size of the Text Splitter."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
if ("separator_type" in params and params["separator_type"] == "Text") or "separator_type" not in params:
|
if (
|
||||||
|
"separator_type" in params and params["separator_type"] == "Text"
|
||||||
|
) or "separator_type" not in params:
|
||||||
params.pop("separator_type", None)
|
params.pop("separator_type", None)
|
||||||
# separators might come in as an escaped string like \\n
|
# separators might come in as an escaped string like \\n
|
||||||
# so we need to convert it to a string
|
# so we need to convert it to a string
|
||||||
if "separators" in params:
|
if "separators" in params:
|
||||||
params["separators"] = params["separators"].encode().decode("unicode-escape")
|
params["separators"] = (
|
||||||
|
params["separators"].encode().decode("unicode-escape")
|
||||||
|
)
|
||||||
text_splitter = class_object(**params)
|
text_splitter = class_object(**params)
|
||||||
else:
|
else:
|
||||||
from langchain.text_splitter import Language
|
from langchain.text_splitter import Language
|
||||||
|
|
@ -425,7 +452,8 @@ def replace_zero_shot_prompt_with_prompt_template(nodes):
|
||||||
tools = [
|
tools = [
|
||||||
tool
|
tool
|
||||||
for tool in nodes
|
for tool in nodes
|
||||||
if tool["type"] != "chatOutputNode" and "Tool" in tool["data"]["node"]["base_classes"]
|
if tool["type"] != "chatOutputNode"
|
||||||
|
and "Tool" in tool["data"]["node"]["base_classes"]
|
||||||
]
|
]
|
||||||
node["data"] = build_prompt_template(prompt=node["data"], tools=tools)
|
node["data"] = build_prompt_template(prompt=node["data"], tools=tools)
|
||||||
break
|
break
|
||||||
|
|
@ -439,7 +467,9 @@ def load_agent_executor(agent_class: type[agent_module.Agent], params, **kwargs)
|
||||||
# agent has hidden args for memory. might need to be support
|
# agent has hidden args for memory. might need to be support
|
||||||
# memory = params["memory"]
|
# memory = params["memory"]
|
||||||
# if allowed_tools is not a list or set, make it a list
|
# if allowed_tools is not a list or set, make it a list
|
||||||
if not isinstance(allowed_tools, (list, set)) and isinstance(allowed_tools, BaseTool):
|
if not isinstance(allowed_tools, (list, set)) and isinstance(
|
||||||
|
allowed_tools, BaseTool
|
||||||
|
):
|
||||||
allowed_tools = [allowed_tools]
|
allowed_tools = [allowed_tools]
|
||||||
tool_names = [tool.name for tool in allowed_tools]
|
tool_names = [tool.name for tool in allowed_tools]
|
||||||
# Agent class requires an output_parser but Agent classes
|
# Agent class requires an output_parser but Agent classes
|
||||||
|
|
@ -467,7 +497,10 @@ def build_prompt_template(prompt, tools):
|
||||||
format_instructions = prompt["node"]["template"]["format_instructions"]["value"]
|
format_instructions = prompt["node"]["template"]["format_instructions"]["value"]
|
||||||
|
|
||||||
tool_strings = "\n".join(
|
tool_strings = "\n".join(
|
||||||
[f"{tool['data']['node']['name']}: {tool['data']['node']['description']}" for tool in tools]
|
[
|
||||||
|
f"{tool['data']['node']['name']}: {tool['data']['node']['description']}"
|
||||||
|
for tool in tools
|
||||||
|
]
|
||||||
)
|
)
|
||||||
tool_names = ", ".join([tool["data"]["node"]["name"] for tool in tools])
|
tool_names = ", ".join([tool["data"]["node"]["name"] for tool in tools])
|
||||||
format_instructions = format_instructions.format(tool_names=tool_names)
|
format_instructions = format_instructions.format(tool_names=tool_names)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from uuid import UUID, uuid4
|
||||||
from pydantic import field_serializer, field_validator
|
from pydantic import field_serializer, field_validator
|
||||||
from sqlmodel import JSON, Column, Field, Relationship, SQLModel
|
from sqlmodel import JSON, Column, Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
from langflow.schema.schema import Record
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langflow.services.database.models.user import User
|
from langflow.services.database.models.user import User
|
||||||
|
|
||||||
|
|
@ -16,7 +18,9 @@ class FlowBase(SQLModel):
|
||||||
description: Optional[str] = Field(index=True, nullable=True, default=None)
|
description: Optional[str] = Field(index=True, nullable=True, default=None)
|
||||||
data: Optional[Dict] = Field(default=None, nullable=True)
|
data: Optional[Dict] = Field(default=None, nullable=True)
|
||||||
is_component: Optional[bool] = Field(default=False, nullable=True)
|
is_component: Optional[bool] = Field(default=False, nullable=True)
|
||||||
updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow, nullable=True)
|
updated_at: Optional[datetime] = Field(
|
||||||
|
default_factory=datetime.utcnow, nullable=True
|
||||||
|
)
|
||||||
folder: Optional[str] = Field(default=None, nullable=True)
|
folder: Optional[str] = Field(default=None, nullable=True)
|
||||||
|
|
||||||
@field_validator("data")
|
@field_validator("data")
|
||||||
|
|
@ -57,6 +61,18 @@ class Flow(FlowBase, table=True):
|
||||||
user_id: UUID = Field(index=True, foreign_key="user.id", nullable=True)
|
user_id: UUID = Field(index=True, foreign_key="user.id", nullable=True)
|
||||||
user: "User" = Relationship(back_populates="flows")
|
user: "User" = Relationship(back_populates="flows")
|
||||||
|
|
||||||
|
def to_record(self):
|
||||||
|
serialized = self.model_dump()
|
||||||
|
data = {
|
||||||
|
"id": serialized.pop("id"),
|
||||||
|
"data": serialized.pop("data"),
|
||||||
|
"name": serialized.pop("name"),
|
||||||
|
"description": serialized.pop("description"),
|
||||||
|
"updated_at": serialized.pop("updated_at"),
|
||||||
|
}
|
||||||
|
record = Record(text=data.get("name"), data=data)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
class FlowCreate(FlowBase):
|
class FlowCreate(FlowBase):
|
||||||
user_id: Optional[UUID] = None
|
user_id: Optional[UUID] = None
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ class FrontendNode(BaseModel):
|
||||||
"""Full path of the frontend node."""
|
"""Full path of the frontend node."""
|
||||||
field_formatters: FieldFormatters = Field(default_factory=FieldFormatters)
|
field_formatters: FieldFormatters = Field(default_factory=FieldFormatters)
|
||||||
"""Field formatters for the frontend node."""
|
"""Field formatters for the frontend node."""
|
||||||
pinned: bool = False
|
frozen: bool = False
|
||||||
"""Whether the frontend node is pinned."""
|
"""Whether the frontend node is frozen."""
|
||||||
|
|
||||||
beta: bool = False
|
beta: bool = False
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
@ -171,7 +171,9 @@ class FrontendNode(BaseModel):
|
||||||
return _type
|
return _type
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_special_field(field, key: str, _type: str, SPECIAL_FIELD_HANDLERS) -> str:
|
def handle_special_field(
|
||||||
|
field, key: str, _type: str, SPECIAL_FIELD_HANDLERS
|
||||||
|
) -> str:
|
||||||
"""Handles special field by using the respective handler if present."""
|
"""Handles special field by using the respective handler if present."""
|
||||||
handler = SPECIAL_FIELD_HANDLERS.get(key)
|
handler = SPECIAL_FIELD_HANDLERS.get(key)
|
||||||
return handler(field) if handler else _type
|
return handler(field) if handler else _type
|
||||||
|
|
@ -182,7 +184,11 @@ class FrontendNode(BaseModel):
|
||||||
if "dict" in _type.lower() and field.name == "dict_":
|
if "dict" in _type.lower() and field.name == "dict_":
|
||||||
field.field_type = "file"
|
field.field_type = "file"
|
||||||
field.file_types = [".json", ".yaml", ".yml"]
|
field.file_types = [".json", ".yaml", ".yml"]
|
||||||
elif _type.startswith("Dict") or _type.startswith("Mapping") or _type.startswith("dict"):
|
elif (
|
||||||
|
_type.startswith("Dict")
|
||||||
|
or _type.startswith("Mapping")
|
||||||
|
or _type.startswith("dict")
|
||||||
|
):
|
||||||
field.field_type = "dict"
|
field.field_type = "dict"
|
||||||
return _type
|
return _type
|
||||||
|
|
||||||
|
|
@ -193,7 +199,9 @@ class FrontendNode(BaseModel):
|
||||||
field.value = value["default"]
|
field.value = value["default"]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_specific_field_values(field: TemplateField, key: str, name: Optional[str] = None) -> None:
|
def handle_specific_field_values(
|
||||||
|
field: TemplateField, key: str, name: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Handles specific field values for certain fields."""
|
"""Handles specific field values for certain fields."""
|
||||||
if key == "headers":
|
if key == "headers":
|
||||||
field.value = """{"Authorization": "Bearer <token>"}"""
|
field.value = """{"Authorization": "Bearer <token>"}"""
|
||||||
|
|
@ -201,7 +209,9 @@ class FrontendNode(BaseModel):
|
||||||
FrontendNode._handle_api_key_specific_field_values(field, key, name)
|
FrontendNode._handle_api_key_specific_field_values(field, key, name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_model_specific_field_values(field: TemplateField, key: str, name: Optional[str] = None) -> None:
|
def _handle_model_specific_field_values(
|
||||||
|
field: TemplateField, key: str, name: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Handles specific field values related to models."""
|
"""Handles specific field values related to models."""
|
||||||
model_dict = {
|
model_dict = {
|
||||||
"OpenAI": constants.OPENAI_MODELS,
|
"OpenAI": constants.OPENAI_MODELS,
|
||||||
|
|
@ -214,7 +224,9 @@ class FrontendNode(BaseModel):
|
||||||
field.is_list = True
|
field.is_list = True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_api_key_specific_field_values(field: TemplateField, key: str, name: Optional[str] = None) -> None:
|
def _handle_api_key_specific_field_values(
|
||||||
|
field: TemplateField, key: str, name: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Handles specific field values related to API keys."""
|
"""Handles specific field values related to API keys."""
|
||||||
if "api_key" in key and "OpenAI" in str(name):
|
if "api_key" in key and "OpenAI" in str(name):
|
||||||
field.display_name = "OpenAI API Key"
|
field.display_name = "OpenAI API Key"
|
||||||
|
|
@ -254,7 +266,10 @@ class FrontendNode(BaseModel):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_be_password(key: str, show: bool) -> bool:
|
def should_be_password(key: str, show: bool) -> bool:
|
||||||
"""Determines whether the field should be a password field."""
|
"""Determines whether the field should be a password field."""
|
||||||
return any(text in key.lower() for text in {"password", "token", "api", "key"}) and show
|
return (
|
||||||
|
any(text in key.lower() for text in {"password", "token", "api", "key"})
|
||||||
|
and show
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_be_multiline(key: str) -> bool:
|
def should_be_multiline(key: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from langflow.template.template.base import Template
|
||||||
|
|
||||||
|
|
||||||
class MemoryFrontendNode(FrontendNode):
|
class MemoryFrontendNode(FrontendNode):
|
||||||
pinned: bool = True
|
frozen: bool = True
|
||||||
|
|
||||||
def add_extra_fields(self) -> None:
|
def add_extra_fields(self) -> None:
|
||||||
# chat history should have another way to add common field?
|
# chat history should have another way to add common field?
|
||||||
|
|
@ -80,7 +80,9 @@ class MemoryFrontendNode(FrontendNode):
|
||||||
field.show = True
|
field.show = True
|
||||||
field.advanced = False
|
field.advanced = False
|
||||||
field.value = ""
|
field.value = ""
|
||||||
field.info = INPUT_KEY_INFO if field.name == "input_key" else OUTPUT_KEY_INFO
|
field.info = (
|
||||||
|
INPUT_KEY_INFO if field.name == "input_key" else OUTPUT_KEY_INFO
|
||||||
|
)
|
||||||
|
|
||||||
if field.name == "memory_key":
|
if field.name == "memory_key":
|
||||||
field.value = "chat_history"
|
field.value = "chat_history"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import PromptAreaComponent from "../../../../components/promptComponent";
|
||||||
import TextAreaComponent from "../../../../components/textAreaComponent";
|
import TextAreaComponent from "../../../../components/textAreaComponent";
|
||||||
import ToggleShadComponent from "../../../../components/toggleShadComponent";
|
import ToggleShadComponent from "../../../../components/toggleShadComponent";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
|
import { RefreshButton } from "../../../../components/ui/refreshButton";
|
||||||
import {
|
import {
|
||||||
INPUT_HANDLER_HOVER,
|
INPUT_HANDLER_HOVER,
|
||||||
LANGFLOW_SUPPORTED_TYPES,
|
LANGFLOW_SUPPORTED_TYPES,
|
||||||
|
|
@ -68,7 +69,7 @@ export default function ParameterComponent({
|
||||||
const nodes = useFlowStore((state) => state.nodes);
|
const nodes = useFlowStore((state) => state.nodes);
|
||||||
const edges = useFlowStore((state) => state.edges);
|
const edges = useFlowStore((state) => state.edges);
|
||||||
const setNode = useFlowStore((state) => state.setNode);
|
const setNode = useFlowStore((state) => state.setNode);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const flow = currentFlow?.data?.nodes ?? null;
|
const flow = currentFlow?.data?.nodes ?? null;
|
||||||
|
|
||||||
const groupedEdge = useRef(null);
|
const groupedEdge = useRef(null);
|
||||||
|
|
@ -85,7 +86,12 @@ export default function ParameterComponent({
|
||||||
|
|
||||||
const takeSnapshot = useFlowsManagerStore((state) => state.takeSnapshot);
|
const takeSnapshot = useFlowsManagerStore((state) => state.takeSnapshot);
|
||||||
|
|
||||||
const handleUpdateValues = async (name: string, data: NodeDataType) => {
|
const handleUpdateValues = async (
|
||||||
|
name: string,
|
||||||
|
data: NodeDataType,
|
||||||
|
delayAnimation: boolean = true
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
const code = data.node?.template["code"]?.value;
|
const code = data.node?.template["code"]?.value;
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error("Code not found in the template");
|
console.error("Code not found in the template");
|
||||||
|
|
@ -95,13 +101,48 @@ export default function ParameterComponent({
|
||||||
try {
|
try {
|
||||||
const res = await postCustomComponentUpdate(code, name);
|
const res = await postCustomComponentUpdate(code, name);
|
||||||
if (res.status === 200 && data.node?.template) {
|
if (res.status === 200 && data.node?.template) {
|
||||||
data.node!.template[name] = res.data.template[name];
|
setNode(data.id, (oldNode) => {
|
||||||
|
let newNode = cloneDeep(oldNode);
|
||||||
|
|
||||||
|
newNode.data = {
|
||||||
|
...newNode.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
newNode.data.node.template[name] = res.data.template[name];
|
||||||
|
|
||||||
|
return newNode;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrorData(err as { title: string; list?: Array<string> });
|
setErrorData(err as { title: string; list?: Array<string> });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderTooltips();
|
||||||
|
if (delayAnimation) {
|
||||||
|
try {
|
||||||
|
// Wait for at least 500 milliseconds
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
// Continue with the request
|
||||||
|
// If the request takes longer than 500 milliseconds, it will not wait an additional 500 milliseconds
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error occurred while waiting for refresh:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} else setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function fetchData() {
|
||||||
|
if (
|
||||||
|
data.node?.template[name]?.refresh &&
|
||||||
|
Object.keys(data.node?.template[name]?.options ?? {}).length === 0
|
||||||
|
) {
|
||||||
|
handleUpdateValues(name, data, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
const handleOnNewValue = (
|
const handleOnNewValue = (
|
||||||
newValue: string | string[] | boolean | Object[]
|
newValue: string | string[] | boolean | Object[]
|
||||||
): void => {
|
): void => {
|
||||||
|
|
@ -314,16 +355,25 @@ export default function ParameterComponent({
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"w-full truncate text-sm" +
|
"w-full truncate text-sm" +
|
||||||
(left ? "" : " text-end") +
|
(left ? "" : " flex items-center justify-end gap-2") +
|
||||||
(info !== "" ? " flex items-center" : "")
|
(info !== "" ? " flex items-center" : "")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!left && data.node?.frozen && (
|
||||||
|
<div>
|
||||||
|
<IconComponent className="h-5 w-5 text-ice" name={"Snowflake"} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{proxy ? (
|
{proxy ? (
|
||||||
<ShadTooltip content={<span>{proxy.id}</span>}>
|
<ShadTooltip content={<span>{proxy.id}</span>}>
|
||||||
<span>{title}</span>
|
<span className={!left && data.node?.frozen ? " text-ice" : ""}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
</ShadTooltip>
|
</ShadTooltip>
|
||||||
) : (
|
) : (
|
||||||
title
|
<span className={!left && data.node?.frozen ? " text-ice" : ""}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={(info === "" ? "" : "ml-1 ") + " text-status-red"}>
|
<span className={(info === "" ? "" : "ml-1 ") + " text-status-red"}>
|
||||||
{required ? " *" : ""}
|
{required ? " *" : ""}
|
||||||
|
|
@ -390,6 +440,7 @@ export default function ParameterComponent({
|
||||||
!data.node?.template[name].options ? (
|
!data.node?.template[name].options ? (
|
||||||
<div className="mt-2 w-full">
|
<div className="mt-2 w-full">
|
||||||
{data.node?.template[name].list ? (
|
{data.node?.template[name].list ? (
|
||||||
|
<div className="w-5/6 flex-grow">
|
||||||
<InputListComponent
|
<InputListComponent
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={
|
value={
|
||||||
|
|
@ -400,6 +451,20 @@ export default function ParameterComponent({
|
||||||
}
|
}
|
||||||
onChange={handleOnNewValue}
|
onChange={handleOnNewValue}
|
||||||
/>
|
/>
|
||||||
|
{data.node?.template[name].refresh && (
|
||||||
|
<div className="w-1/6">
|
||||||
|
<RefreshButton
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
data={data}
|
||||||
|
className="extra-side-bar-buttons ml-2 mt-1"
|
||||||
|
handleUpdateValues={handleUpdateValues}
|
||||||
|
id={"refresh-button-" + name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : data.node?.template[name].multiline ? (
|
) : data.node?.template[name].multiline ? (
|
||||||
<TextAreaComponent
|
<TextAreaComponent
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
@ -420,14 +485,17 @@ export default function ParameterComponent({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{data.node?.template[name].refresh && (
|
{data.node?.template[name].refresh && (
|
||||||
<button
|
<div className="w-1/6">
|
||||||
className="extra-side-bar-buttons ml-2 mt-1 w-1/6"
|
<RefreshButton
|
||||||
onClick={() => {
|
isLoading={isLoading}
|
||||||
handleUpdateValues(name, data);
|
disabled={disabled}
|
||||||
}}
|
name={name}
|
||||||
>
|
data={data}
|
||||||
<IconComponent name="RefreshCcw" />
|
className="extra-side-bar-buttons ml-2 mt-1"
|
||||||
</button>
|
handleUpdateValues={handleUpdateValues}
|
||||||
|
id={"refresh-button-" + name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -454,11 +522,14 @@ export default function ParameterComponent({
|
||||||
</div>
|
</div>
|
||||||
) : left === true &&
|
) : left === true &&
|
||||||
type === "str" &&
|
type === "str" &&
|
||||||
data.node?.template[name].options ? (
|
(data.node?.template[name].options ||
|
||||||
|
data.node?.template[name]?.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">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
disabled={disabled}
|
||||||
|
isLoading={isLoading}
|
||||||
options={data.node.template[name].options}
|
options={data.node.template[name].options}
|
||||||
onSelect={handleOnNewValue}
|
onSelect={handleOnNewValue}
|
||||||
value={data.node.template[name].value ?? "Choose an option"}
|
value={data.node.template[name].value ?? "Choose an option"}
|
||||||
|
|
@ -466,14 +537,17 @@ export default function ParameterComponent({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{data.node?.template[name].refresh && (
|
{data.node?.template[name].refresh && (
|
||||||
<button
|
<div className="w-1/6">
|
||||||
className="extra-side-bar-buttons ml-2 mt-1 w-1/6"
|
<RefreshButton
|
||||||
onClick={() => {
|
isLoading={isLoading}
|
||||||
handleUpdateValues(name, data);
|
disabled={disabled}
|
||||||
}}
|
name={name}
|
||||||
>
|
data={data}
|
||||||
<IconComponent name="RefreshCcw" />
|
className="extra-side-bar-buttons ml-2 mt-1"
|
||||||
</button>
|
handleUpdateValues={handleUpdateValues}
|
||||||
|
id={"refresh-button-" + name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : left === true && type === "code" ? (
|
) : left === true && type === "code" ? (
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export default function GenericNode({
|
||||||
if (duration === undefined) {
|
if (duration === undefined) {
|
||||||
return "";
|
return "";
|
||||||
} else {
|
} else {
|
||||||
return `Duration: ${duration}`;
|
return `${duration}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const durationString = getDurationString(validationStatus?.data.duration);
|
const durationString = getDurationString(validationStatus?.data.duration);
|
||||||
|
|
@ -758,15 +758,6 @@ export default function GenericNode({
|
||||||
showNode={showNode}
|
showNode={showNode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
|
||||||
{lastRunTime && (
|
|
||||||
<div className="flex justify-center text-muted-foreground">
|
|
||||||
{lastRunTime.split("\n").map((line, index) => (
|
|
||||||
<div key={index}>{line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { classNames } from "../../utils/utils";
|
||||||
import IconComponent from "../genericIconComponent";
|
import IconComponent from "../genericIconComponent";
|
||||||
|
|
||||||
export default function Dropdown({
|
export default function Dropdown({
|
||||||
|
disabled,
|
||||||
|
isLoading,
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
|
@ -27,6 +29,7 @@ export default function Dropdown({
|
||||||
<>
|
<>
|
||||||
<Listbox
|
<Listbox
|
||||||
value={internalValue}
|
value={internalValue}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setInternalValue(value);
|
setInternalValue(value);
|
||||||
onSelect(value);
|
onSelect(value);
|
||||||
|
|
@ -129,11 +132,17 @@ export default function Dropdown({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{(!isLoading && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm italic">
|
<span className="text-sm italic">
|
||||||
No parameters are available for display.
|
No parameters are available for display.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)) || (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm italic">Loading...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { forwardRef } from "react";
|
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
||||||
|
import { Suspense, forwardRef, lazy } from "react";
|
||||||
import { IconComponentProps } from "../../types/components";
|
import { IconComponentProps } from "../../types/components";
|
||||||
import { nodeIconsLucide } from "../../utils/styleUtils";
|
import { nodeIconsLucide } from "../../utils/styleUtils";
|
||||||
|
|
||||||
|
|
@ -14,7 +15,13 @@ const ForwardedIconComponent = forwardRef(
|
||||||
}: IconComponentProps,
|
}: IconComponentProps,
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const TargetIcon = nodeIconsLucide[name] ?? nodeIconsLucide["unknown"];
|
let TargetIcon = nodeIconsLucide[name];
|
||||||
|
if (!TargetIcon) {
|
||||||
|
// check if name exists in dynamicIconImports
|
||||||
|
if (!dynamicIconImports[name]) {
|
||||||
|
TargetIcon = nodeIconsLucide["unknown"];
|
||||||
|
} else TargetIcon = lazy(dynamicIconImports[name]);
|
||||||
|
}
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
strokeWidth: strokeWidth ?? 1.5,
|
strokeWidth: strokeWidth ?? 1.5,
|
||||||
|
|
@ -22,13 +29,21 @@ const ForwardedIconComponent = forwardRef(
|
||||||
...(iconColor && { color: iconColor, stroke: stroke }),
|
...(iconColor && { color: iconColor, stroke: stroke }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!TargetIcon) {
|
||||||
|
return null; // Render nothing until the icon is loaded
|
||||||
|
}
|
||||||
|
const fallback = (
|
||||||
|
<div style={{ background: "#ddd", width: 24, height: 24 }} />
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={fallback}>
|
||||||
<TargetIcon
|
<TargetIcon
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-testid={id ? `${id}-${name}` : `icon-${name}`}
|
data-testid={id ? `${id}-${name}` : `icon-${name}`}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
55
src/frontend/src/components/ui/refreshButton.tsx
Normal file
55
src/frontend/src/components/ui/refreshButton.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import IconComponent from "../../components/genericIconComponent";
|
||||||
|
import { NodeDataType } from "../../types/flow";
|
||||||
|
import { cn } from "../../utils/utils";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
function RefreshButton({
|
||||||
|
isLoading,
|
||||||
|
disabled,
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
handleUpdateValues,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
name: string;
|
||||||
|
data: NodeDataType;
|
||||||
|
className?: string;
|
||||||
|
handleUpdateValues: (name: string, data: NodeDataType) => void;
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
handleUpdateValues(name, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const classNames = cn(className, disabled ? "cursor-not-allowed" : "");
|
||||||
|
|
||||||
|
// icon class name should take into account the disabled state and the loading state
|
||||||
|
const disabledIconTextClass = disabled ? "text-muted-foreground" : "";
|
||||||
|
const iconClassName = cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isLoading ? "animate-spin" : "animate-wiggle",
|
||||||
|
disabledIconTextClass
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={disabled}
|
||||||
|
className={classNames}
|
||||||
|
onClick={handleClick}
|
||||||
|
id={id}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
name={isLoading ? "Loader2" : "RefreshCcw"}
|
||||||
|
className={iconClassName}
|
||||||
|
id={id + "-icon"}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RefreshButton };
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
getNodeId,
|
getNodeId,
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
reconnectEdges,
|
reconnectEdges,
|
||||||
|
scapeJSONParse,
|
||||||
validateSelection,
|
validateSelection,
|
||||||
} from "../../../../utils/reactflowUtils";
|
} from "../../../../utils/reactflowUtils";
|
||||||
import { getRandomName, isWrappedWithClass } from "../../../../utils/utils";
|
import { getRandomName, isWrappedWithClass } from "../../../../utils/utils";
|
||||||
|
|
@ -108,7 +109,7 @@ export default function Page({
|
||||||
...old.data,
|
...old.data,
|
||||||
node: {
|
node: {
|
||||||
...old.data.node,
|
...old.data.node,
|
||||||
pinned: old.data?.node?.pinned ? false : true,
|
frozen: old.data?.node?.frozen ? false : true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -320,6 +321,8 @@ export default function Page({
|
||||||
(oldEdge: Edge, newConnection: Connection) => {
|
(oldEdge: Edge, newConnection: Connection) => {
|
||||||
if (isValidConnection(newConnection, nodes, edges)) {
|
if (isValidConnection(newConnection, nodes, edges)) {
|
||||||
edgeUpdateSuccessful.current = true;
|
edgeUpdateSuccessful.current = true;
|
||||||
|
oldEdge.data.targetHandle = scapeJSONParse(newConnection.targetHandle!);
|
||||||
|
oldEdge.data.sourceHandle = scapeJSONParse(newConnection.sourceHandle!);
|
||||||
setEdges((els) => updateEdge(oldEdge, newConnection, els));
|
setEdges((els) => updateEdge(oldEdge, newConnection, els));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
} from "../../../../utils/utils";
|
} from "../../../../utils/utils";
|
||||||
import DisclosureComponent from "../DisclosureComponent";
|
import DisclosureComponent from "../DisclosureComponent";
|
||||||
import SidebarDraggableComponent from "./sideBarDraggableComponent";
|
import SidebarDraggableComponent from "./sideBarDraggableComponent";
|
||||||
|
import { sortKeys } from "./utils";
|
||||||
|
|
||||||
export default function ExtraSidebar(): JSX.Element {
|
export default function ExtraSidebar(): JSX.Element {
|
||||||
const data = useTypesStore((state) => state.data);
|
const data = useTypesStore((state) => state.data);
|
||||||
|
|
@ -320,19 +321,7 @@ export default function ExtraSidebar(): JSX.Element {
|
||||||
|
|
||||||
<div className="side-bar-components-div-arrangement">
|
<div className="side-bar-components-div-arrangement">
|
||||||
{Object.keys(dataFilter)
|
{Object.keys(dataFilter)
|
||||||
.sort((a, b) => {
|
.sort(sortKeys)
|
||||||
if (a.toLowerCase() === "saved_components") {
|
|
||||||
return -1;
|
|
||||||
} else if (b.toLowerCase() === "saved_components") {
|
|
||||||
return 1;
|
|
||||||
} else if (a.toLowerCase() === "custom_components") {
|
|
||||||
return -2;
|
|
||||||
} else if (b.toLowerCase() === "custom_components") {
|
|
||||||
return 2;
|
|
||||||
} else {
|
|
||||||
return a.localeCompare(b);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map((SBSectionName: keyof APIObjectType, index) =>
|
.map((SBSectionName: keyof APIObjectType, index) =>
|
||||||
Object.keys(dataFilter[SBSectionName]).length > 0 ? (
|
Object.keys(dataFilter[SBSectionName]).length > 0 ? (
|
||||||
<DisclosureComponent
|
<DisclosureComponent
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
export function sortKeys(a: string, b: string) {
|
||||||
|
// Define the order of specific keys
|
||||||
|
const order = [
|
||||||
|
"saved_components",
|
||||||
|
"inputs",
|
||||||
|
"outputs",
|
||||||
|
"data",
|
||||||
|
"utilities",
|
||||||
|
"models",
|
||||||
|
];
|
||||||
|
const indexA = order.indexOf(a.toLowerCase());
|
||||||
|
const indexB = order.indexOf(b.toLowerCase());
|
||||||
|
|
||||||
|
// Check if both keys are in the predefined order
|
||||||
|
if (indexA !== -1 && indexB !== -1) {
|
||||||
|
return indexA - indexB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only 'a' is in the predefined order, it should come first
|
||||||
|
if (indexA !== -1) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only 'b' is in the predefined order, it should come first
|
||||||
|
if (indexB !== -1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither 'a' nor 'b' are in the predefined order, sort them alphabetically
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export default function NodeToolbarComponent({
|
||||||
const isMinimal = numberOfHandles <= 1;
|
const isMinimal = numberOfHandles <= 1;
|
||||||
const isGroup = data.node?.flow ? true : false;
|
const isGroup = data.node?.flow ? true : false;
|
||||||
|
|
||||||
const pinned = data.node?.pinned ?? false;
|
const frozen = data.node?.frozen ?? false;
|
||||||
const paste = useFlowStore((state) => state.paste);
|
const paste = useFlowStore((state) => state.paste);
|
||||||
const nodes = useFlowStore((state) => state.nodes);
|
const nodes = useFlowStore((state) => state.nodes);
|
||||||
const edges = useFlowStore((state) => state.edges);
|
const edges = useFlowStore((state) => state.edges);
|
||||||
|
|
@ -267,7 +267,7 @@ export default function NodeToolbarComponent({
|
||||||
</button>
|
</button>
|
||||||
</ShadTooltip>
|
</ShadTooltip>
|
||||||
|
|
||||||
<ShadTooltip content="Pin" side="top">
|
<ShadTooltip content="Freeze" side="top">
|
||||||
<button
|
<button
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"relative -ml-px inline-flex items-center bg-background px-2 py-2 text-foreground shadow-md ring-1 ring-inset ring-ring transition-all duration-500 ease-in-out hover:bg-muted focus:z-10"
|
"relative -ml-px inline-flex items-center bg-background px-2 py-2 text-foreground shadow-md ring-1 ring-inset ring-ring transition-all duration-500 ease-in-out hover:bg-muted focus:z-10"
|
||||||
|
|
@ -280,17 +280,18 @@ export default function NodeToolbarComponent({
|
||||||
...old.data,
|
...old.data,
|
||||||
node: {
|
node: {
|
||||||
...old.data.node,
|
...old.data.node,
|
||||||
pinned: old.data?.node?.pinned ? false : true,
|
frozen: old.data?.node?.frozen ? false : true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconComponent
|
<IconComponent
|
||||||
name="Pin"
|
name="Snowflake"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-all",
|
"h-4 w-4 transition-all",
|
||||||
pinned ? "animate-wiggle fill-current" : ""
|
// TODO UPDATE THIS COLOR TO BE A VARIABLE
|
||||||
|
frozen ? "animate-wiggle text-ice" : ""
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
FLOW_BUILD_SUCCESS_ALERT,
|
FLOW_BUILD_SUCCESS_ALERT,
|
||||||
MISSED_ERROR_ALERT,
|
MISSED_ERROR_ALERT,
|
||||||
} from "../constants/alerts_constants";
|
} from "../constants/alerts_constants";
|
||||||
import { RUN_TIMESTAMP_PREFIX } from "../constants/constants";
|
|
||||||
import { BuildStatus } from "../constants/enums";
|
import { BuildStatus } from "../constants/enums";
|
||||||
import { getFlowPool } from "../controllers/API";
|
import { getFlowPool } from "../controllers/API";
|
||||||
import { VertexBuildTypeAPI } from "../types/api";
|
import { VertexBuildTypeAPI } from "../types/api";
|
||||||
|
|
@ -188,10 +187,12 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
|
||||||
typeof change === "function"
|
typeof change === "function"
|
||||||
? change(get().nodes.find((node) => node.id === id)!)
|
? change(get().nodes.find((node) => node.id === id)!)
|
||||||
: change;
|
: change;
|
||||||
|
|
||||||
get().setNodes((oldNodes) =>
|
get().setNodes((oldNodes) =>
|
||||||
oldNodes.map((node) => {
|
oldNodes.map((node) => {
|
||||||
if (node.id === id) {
|
if (node.id === id) {
|
||||||
|
if ((node.data as NodeDataType).node?.frozen) {
|
||||||
|
(newChange.data as NodeDataType).node!.frozen = false;
|
||||||
|
}
|
||||||
return newChange;
|
return newChange;
|
||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
|
|
@ -572,9 +573,7 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
|
||||||
};
|
};
|
||||||
if (status == BuildStatus.BUILT) {
|
if (status == BuildStatus.BUILT) {
|
||||||
const timestamp_string = new Date(Date.now()).toLocaleString();
|
const timestamp_string = new Date(Date.now()).toLocaleString();
|
||||||
newFlowBuildStatus[
|
newFlowBuildStatus[id].timestamp = timestamp_string;
|
||||||
id
|
|
||||||
].timestamp = `${RUN_TIMESTAMP_PREFIX} ${timestamp_string}`;
|
|
||||||
}
|
}
|
||||||
console.log("updateBuildStatus", newFlowBuildStatus);
|
console.log("updateBuildStatus", newFlowBuildStatus);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,7 @@
|
||||||
@apply border-none ring ring-[#FF9090];
|
@apply border-none ring ring-[#FF9090];
|
||||||
}
|
}
|
||||||
.built-invalid-status-dark {
|
.built-invalid-status-dark {
|
||||||
@apply border-none ring ring-[#751C1C]
|
@apply border-none ring ring-[#751C1C];
|
||||||
}
|
}
|
||||||
.building-status {
|
.building-status {
|
||||||
@apply border-none ring;
|
@apply border-none ring;
|
||||||
|
|
@ -431,7 +431,9 @@
|
||||||
.code-area-external-link:hover {
|
.code-area-external-link:hover {
|
||||||
@apply hover:text-accent-foreground;
|
@apply hover:text-accent-foreground;
|
||||||
}
|
}
|
||||||
|
.dropdown-component-disabled {
|
||||||
|
@apply pointer-events-none cursor-not-allowed;
|
||||||
|
}
|
||||||
.dropdown-component-outline {
|
.dropdown-component-outline {
|
||||||
@apply input-edit-node relative pr-8;
|
@apply input-edit-node relative pr-8;
|
||||||
}
|
}
|
||||||
|
|
@ -441,11 +443,17 @@
|
||||||
.dropdown-component-display {
|
.dropdown-component-display {
|
||||||
@apply block w-full truncate bg-background;
|
@apply block w-full truncate bg-background;
|
||||||
}
|
}
|
||||||
|
.dropdown-component-display-disabled {
|
||||||
|
@apply text-muted-foreground;
|
||||||
|
}
|
||||||
.dropdown-component-arrow {
|
.dropdown-component-arrow {
|
||||||
@apply pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2;
|
@apply pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2;
|
||||||
}
|
}
|
||||||
.dropdown-component-arrow-color {
|
.dropdown-component-arrow-color {
|
||||||
@apply extra-side-bar-save-disable h-5 w-5;
|
@apply h-5 w-5 text-accent-foreground;
|
||||||
|
}
|
||||||
|
.dropdown-component-arrow-color-disable {
|
||||||
|
@apply h-5 w-5 text-muted-foreground;
|
||||||
}
|
}
|
||||||
.dropdown-component-options {
|
.dropdown-component-options {
|
||||||
@apply z-10 mt-1 max-h-60 overflow-auto rounded-md bg-background py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm;
|
@apply z-10 mt-1 max-h-60 overflow-auto rounded-md bg-background py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm;
|
||||||
|
|
@ -902,7 +910,7 @@
|
||||||
@apply flex-max-width px-2 py-6 pl-4 pr-9;
|
@apply flex-max-width px-2 py-6 pl-4 pr-9;
|
||||||
}
|
}
|
||||||
.form-modal-chatbot-icon {
|
.form-modal-chatbot-icon {
|
||||||
@apply flex flex-col mb-3 ml-3 mr-6 mt-1;
|
@apply mb-3 ml-3 mr-6 mt-1 flex flex-col;
|
||||||
}
|
}
|
||||||
.form-modal-chat-image {
|
.form-modal-chat-image {
|
||||||
@apply flex flex-col items-center gap-1;
|
@apply flex flex-col items-center gap-1;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--ring: 215 20.2% 65.1%; /* hsl(215 20% 65%) */
|
--ring: 215 20.2% 65.1%; /* hsl(215 20% 65%) */
|
||||||
--round-btn-shadow: #00000063;
|
--round-btn-shadow: #00000063;
|
||||||
|
--ice: #31a3cc;
|
||||||
|
|
||||||
--error-background: #fef2f2;
|
--error-background: #fef2f2;
|
||||||
--error-foreground: #991b1b;
|
--error-foreground: #991b1b;
|
||||||
|
|
@ -67,6 +68,7 @@
|
||||||
.dark {
|
.dark {
|
||||||
--background: 224 35% 7.5%; /* hsl(224 40% 10%) */
|
--background: 224 35% 7.5%; /* hsl(224 40% 10%) */
|
||||||
--foreground: 213 31% 80%; /* hsl(213 31% 91%) */
|
--foreground: 213 31% 80%; /* hsl(213 31% 91%) */
|
||||||
|
--ice: #60A5FA;
|
||||||
|
|
||||||
--muted: 223 27% 11%; /* hsl(223 27% 11%) */
|
--muted: 223 27% 11%; /* hsl(223 27% 11%) */
|
||||||
--muted-foreground: 215.4 16.3% 56.9%; /* hsl(215 16% 56%) */
|
--muted-foreground: 215.4 16.3% 56.9%; /* hsl(215 16% 56%) */
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export type APIClassType = {
|
||||||
documentation: string;
|
documentation: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
official?: boolean;
|
official?: boolean;
|
||||||
pinned?: boolean;
|
frozen?: boolean;
|
||||||
flow?: FlowType;
|
flow?: FlowType;
|
||||||
[key: string]:
|
[key: string]:
|
||||||
| Array<string>
|
| Array<string>
|
||||||
|
|
@ -54,6 +54,7 @@ export type TemplateVariableType = {
|
||||||
input_types?: Array<string>;
|
input_types?: Array<string>;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
refresh?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
export type sendAllProps = {
|
export type sendAllProps = {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export type ToggleComponentType = {
|
||||||
editNode?: boolean;
|
editNode?: boolean;
|
||||||
};
|
};
|
||||||
export type DropDownComponentType = {
|
export type DropDownComponentType = {
|
||||||
|
disabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,8 @@ export async function buildVertices({
|
||||||
|
|
||||||
if (validateNodes) {
|
if (validateNodes) {
|
||||||
try {
|
try {
|
||||||
validateNodes(verticesIds);
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
validateNodes(nodes.map((node) => node.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Braces,
|
Braces,
|
||||||
Cable,
|
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -46,6 +45,7 @@ import {
|
||||||
FileUp,
|
FileUp,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FormInput,
|
FormInput,
|
||||||
Forward,
|
Forward,
|
||||||
|
|
@ -99,6 +99,7 @@ import {
|
||||||
Share2,
|
Share2,
|
||||||
Shield,
|
Shield,
|
||||||
Sliders,
|
Sliders,
|
||||||
|
Snowflake,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
Store,
|
Store,
|
||||||
|
|
@ -204,6 +205,9 @@ export const gradients = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const nodeColors: { [char: string]: string } = {
|
export const nodeColors: { [char: string]: string } = {
|
||||||
|
inputs: "#9AAE42",
|
||||||
|
outputs: "#AA2411",
|
||||||
|
data: "#6344BE",
|
||||||
prompts: "#4367BF",
|
prompts: "#4367BF",
|
||||||
models: "#AA2411",
|
models: "#AA2411",
|
||||||
model_specs: "#6344BE",
|
model_specs: "#6344BE",
|
||||||
|
|
@ -224,16 +228,19 @@ export const nodeColors: { [char: string]: string } = {
|
||||||
toolkits: "#DB2C2C",
|
toolkits: "#DB2C2C",
|
||||||
wrappers: "#E6277A",
|
wrappers: "#E6277A",
|
||||||
utilities: "#31A3CC",
|
utilities: "#31A3CC",
|
||||||
|
langchain_utilities: "#31A3CC",
|
||||||
output_parsers: "#E6A627",
|
output_parsers: "#E6A627",
|
||||||
str: "#31a3cc",
|
str: "#31a3cc",
|
||||||
Text: "#31a3cc",
|
Text: "#31a3cc",
|
||||||
retrievers: "#e6b25a",
|
retrievers: "#e6b25a",
|
||||||
unknown: "#9CA3AF",
|
unknown: "#9CA3AF",
|
||||||
custom_components: "#ab11ab",
|
custom_components: "#ab11ab",
|
||||||
io: "#e6b25a",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const nodeNames: { [char: string]: string } = {
|
export const nodeNames: { [char: string]: string } = {
|
||||||
|
inputs: "Inputs",
|
||||||
|
outputs: "Outputs",
|
||||||
|
data: "Data",
|
||||||
prompts: "Prompts",
|
prompts: "Prompts",
|
||||||
models: "Language Models",
|
models: "Language Models",
|
||||||
model_specs: "Model Specs",
|
model_specs: "Model Specs",
|
||||||
|
|
@ -252,13 +259,16 @@ export const nodeNames: { [char: string]: string } = {
|
||||||
textsplitters: "Text Splitters",
|
textsplitters: "Text Splitters",
|
||||||
retrievers: "Retrievers",
|
retrievers: "Retrievers",
|
||||||
utilities: "Utilities",
|
utilities: "Utilities",
|
||||||
|
langchain_utilities: "Langchain Utilities",
|
||||||
output_parsers: "Output Parsers",
|
output_parsers: "Output Parsers",
|
||||||
custom_components: "Custom",
|
custom_components: "Custom",
|
||||||
io: "I/O",
|
|
||||||
unknown: "Other",
|
unknown: "Other",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const nodeIconsLucide: iconsType = {
|
export const nodeIconsLucide: iconsType = {
|
||||||
|
inputs: Download,
|
||||||
|
outputs: Upload,
|
||||||
|
data: FolderOpen,
|
||||||
AzureChatOpenAi: AzureIcon,
|
AzureChatOpenAi: AzureIcon,
|
||||||
Ollama: OllamaIcon,
|
Ollama: OllamaIcon,
|
||||||
ChatOllama: OllamaIcon,
|
ChatOllama: OllamaIcon,
|
||||||
|
|
@ -341,6 +351,7 @@ export const nodeIconsLucide: iconsType = {
|
||||||
textsplitters: Scissors,
|
textsplitters: Scissors,
|
||||||
wrappers: Gift,
|
wrappers: Gift,
|
||||||
utilities: Wand2,
|
utilities: Wand2,
|
||||||
|
langchain_utilities: Wand2,
|
||||||
WolframAlphaAPIWrapper: SvgWolfram,
|
WolframAlphaAPIWrapper: SvgWolfram,
|
||||||
output_parsers: Compass,
|
output_parsers: Compass,
|
||||||
retrievers: FileSearch,
|
retrievers: FileSearch,
|
||||||
|
|
@ -383,6 +394,7 @@ export const nodeIconsLucide: iconsType = {
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Code2,
|
Code2,
|
||||||
Variable,
|
Variable,
|
||||||
|
Snowflake,
|
||||||
Store,
|
Store,
|
||||||
Download,
|
Download,
|
||||||
Eraser,
|
Eraser,
|
||||||
|
|
@ -446,7 +458,6 @@ export const nodeIconsLucide: iconsType = {
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
TextCursorInput,
|
TextCursorInput,
|
||||||
Repeat,
|
Repeat,
|
||||||
io: Cable,
|
|
||||||
Sliders,
|
Sliders,
|
||||||
ScreenShare,
|
ScreenShare,
|
||||||
Code,
|
Code,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ module.exports = {
|
||||||
"beta-foreground": "var(--beta-foreground)",
|
"beta-foreground": "var(--beta-foreground)",
|
||||||
"chat-bot-icon": "var(--chat-bot-icon)",
|
"chat-bot-icon": "var(--chat-bot-icon)",
|
||||||
"chat-user-icon": "var(--chat-user-icon)",
|
"chat-user-icon": "var(--chat-user-icon)",
|
||||||
|
"ice": "var(--ice)",
|
||||||
|
|
||||||
white: "var(--white)",
|
white: "var(--white)",
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue