Merge branch 'two_edges' into two_edges_dev

This commit is contained in:
italojohnny 2024-06-14 09:29:50 -03:00
commit 902e616286
22 changed files with 235 additions and 186 deletions

View file

@ -5,6 +5,8 @@ from langflow.inputs import StrInput
from langflow.schema import Data from langflow.schema import Data
from langflow.template import Output from langflow.template import Output
import re
class URLComponent(Component): class URLComponent(Component):
display_name = "URL" display_name = "URL"
@ -25,8 +27,39 @@ class URLComponent(Component):
Output(display_name="Data", name="data", method="fetch_content"), Output(display_name="Data", name="data", method="fetch_content"),
] ]
def ensure_url(self, string: str) -> str:
"""
Ensures the given string is a URL by adding 'http://' if it doesn't start with 'http://' or 'https://'.
Raises an error if the string is not a valid URL.
Parameters:
string (str): The string to be checked and possibly modified.
Returns:
str: The modified string that is ensured to be a URL.
Raises:
ValueError: If the string is not a valid URL.
"""
if not string.startswith(("http://", "https://")):
string = "http://" + string
# Basic URL validation regex
url_regex = re.compile(
r"^(http://|https://)?" # http:// or https://
r"(([a-zA-Z0-9\.-]+)" # domain
r"(\.[a-zA-Z]{2,}))" # top-level domain
r"(:[0-9]{1,5})?" # optional port
r"(\/.*)?$" # optional path
)
if not re.match(url_regex, string):
raise ValueError(f"Invalid URL: {string}")
return string
def fetch_content(self) -> Data: def fetch_content(self) -> Data:
urls = [url.strip() for url in self.urls if url.strip()] urls = [self.ensure_url(url.strip()) for url in self.urls if url.strip()]
loader = WebBaseLoader(web_paths=urls) loader = WebBaseLoader(web_paths=urls)
docs = loader.load() docs = loader.load()
data = [Data(content=doc.page_content, **doc.metadata) for doc in docs] data = [Data(content=doc.page_content, **doc.metadata) for doc in docs]

View file

@ -1,90 +1,92 @@
from typing import Union
from langflow.custom import Component from langflow.custom import Component
from langflow.field_typing import Text from langflow.field_typing import Text
from langflow.schema import Data from langflow.inputs import BoolInput, DropdownInput, StrInput
from langflow.template import Input, Output from langflow.template import Output
class TextOperatorComponent(Component): class TextOperatorComponent(Component):
display_name = "Text Operator" display_name = "Text Operator"
description = "Compares two text inputs based on a specified condition such as equality or inequality, with optional case sensitivity." description = "Compares two text inputs based on a specified condition such as equality or inequality, with optional case sensitivity."
icon = "equal"
inputs = [ inputs = [
Input(name="input_text", type=str, display_name="Input Text", info="The primary text input for the operation."), StrInput(
Input(name="match_text", type=str, display_name="Match Text", info="The text input to compare against."), name="input_text",
Input( display_name="Input Text",
name="operator", info="The primary text input for the operation.",
type=str,
display_name="Operator",
info="The operator to apply for comparing the texts.",
options=["equals", "not equals", "contains", "starts with", "ends with", "exists"],
), ),
Input( StrInput(
name="match_text",
display_name="Match Text",
info="The text input to compare against.",
),
DropdownInput(
name="operator",
display_name="Operator",
options=["equals", "not equals", "contains", "starts with", "ends with"],
info="The operator to apply for comparing the texts.",
),
BoolInput(
name="case_sensitive", name="case_sensitive",
type=bool,
display_name="Case Sensitive", display_name="Case Sensitive",
info="If true, the comparison will be case sensitive.", info="If true, the comparison will be case sensitive.",
default=False, value=False,
advanced=True,
), ),
Input( StrInput(
name="true_output", name="true_output",
type=Union[str, Data],
display_name="True Output", display_name="True Output",
info="The output to return or display when the comparison is true.", info="The output to return or display when the comparison is true.",
input_types=["Text", "Data"], advanced=True,
), ),
Input( StrInput(
name="false_output", name="false_output",
type=Union[str, Data],
display_name="False Output", display_name="False Output",
info="The output to return or display when the comparison is false.", info="The output to return or display when the comparison is false.",
input_types=["Text", "Data"], advanced=True,
), ),
] ]
outputs = [ outputs = [
Output(display_name="True Result", name="true_result", method="result_response"), Output(display_name="True Result", name="true_result", method="true_response"),
Output(display_name="False Result", name="false_result", method="result_response"), Output(display_name="False Result", name="false_result", method="false_response"),
] ]
def true_response(self) -> Union[Text, Data]: def evaluate_condition(self, input_text: str, match_text: str, operator: str, case_sensitive: bool) -> bool:
self.stop("False Result")
return self.true_output if self.true_output else self.input_text
def false_response(self) -> Union[Text, Data]:
self.stop("True Result")
return self.false_output if self.false_output else self.input_text
def result_response(self) -> Union[Text, Data]:
input_text = self.input_text
match_text = self.match_text
operator = self.operator
case_sensitive = self.case_sensitive
if not input_text or not match_text:
raise ValueError("Both 'input_text' and 'match_text' must be provided and non-empty.")
if not case_sensitive: if not case_sensitive:
input_text = input_text.lower() input_text = input_text.lower()
match_text = match_text.lower() match_text = match_text.lower()
result = False
if operator == "equals": if operator == "equals":
result = input_text == match_text return input_text == match_text
elif operator == "not equals": elif operator == "not equals":
result = input_text != match_text return input_text != match_text
elif operator == "contains": elif operator == "contains":
result = match_text in input_text return match_text in input_text
elif operator == "starts with": elif operator == "starts with":
result = input_text.startswith(match_text) return input_text.startswith(match_text)
elif operator == "ends with": elif operator == "ends with":
result = input_text.endswith(match_text) return input_text.endswith(match_text)
return False
def true_response(self) -> Text:
result = self.evaluate_condition(self.input_text, self.match_text, self.operator, self.case_sensitive)
if result: if result:
response = self.true_response() self.stop("false_result")
response = self.true_output if self.true_output else self.input_text
self.status = response self.status = response
return response return response
else: else:
response = self.false_response() self.stop("true_result")
return ""
def false_response(self) -> Text:
result = self.evaluate_condition(self.input_text, self.match_text, self.operator, self.case_sensitive)
if not result:
self.stop("true_result")
response = self.false_output if self.false_output else self.input_text
self.status = response self.status = response
return response return response
else:
self.stop("false_result")
return ""

View file

@ -1,29 +1,38 @@
from langflow.custom import CustomComponent from langflow.custom import Component
from langflow.field_typing import Text from langflow.field_typing import Text
from langflow.inputs import StrInput
from langflow.template import Output
class CombineTextComponent(CustomComponent): class CombineTextComponent(Component):
display_name = "Combine Text" display_name = "Combine Text"
description = "Concatenate two text sources into a single text chunk using a specified delimiter." description = "Concatenate two text sources into a single text chunk using a specified delimiter."
icon = "merge" icon = "merge"
def build_config(self): inputs = [
return { StrInput(
"text1": { name="text1",
"display_name": "First Text", display_name="First Text",
"info": "The first text input to concatenate.", info="The first text input to concatenate.",
}, ),
"text2": { StrInput(
"display_name": "Second Text", name="text2",
"info": "The second text input to concatenate.", display_name="Second Text",
}, info="The second text input to concatenate.",
"delimiter": { ),
"display_name": "Delimiter", StrInput(
"info": "A string used to separate the two text inputs. Defaults to a whitespace.", name="delimiter",
}, display_name="Delimiter",
} info="A string used to separate the two text inputs. Defaults to a whitespace.",
default=" ",
),
]
def build(self, text1: str, text2: str, delimiter: str = " ") -> Text: outputs = [
combined = delimiter.join([text1, text2]) Output(display_name="Combined Text", name="combined_text", method="combine_texts"),
]
def combine_texts(self) -> Text:
combined = self.delimiter.join([self.text1, self.text2])
self.status = combined self.status = combined
return combined return combined

View file

@ -1,25 +0,0 @@
from langflow.custom import CustomComponent
from langflow.field_typing import Text
class CombineTextsUnsortedComponent(CustomComponent):
display_name = "Combine Texts (Unsorted)"
description = "Concatenate text sources into a single text chunk using a specified delimiter."
icon = "merge"
def build_config(self):
return {
"texts": {
"display_name": "Texts",
"info": "The first text input to concatenate.",
},
"delimiter": {
"display_name": "Delimiter",
"info": "A string used to separate the two text inputs. Defaults to a whitespace.",
},
}
def build(self, texts: list[str], delimiter: str = " ") -> Text:
combined = delimiter.join(texts)
self.status = combined
return combined

View file

@ -1,36 +0,0 @@
from langflow.custom import CustomComponent
from langflow.field_typing import Text
from langflow.helpers.data import data_to_text
from langflow.schema import Data
class DataToTextComponent(CustomComponent):
display_name = "Data To Text"
description = "Convert Data into plain text following a specified template."
def build_config(self):
return {
"data": {
"display_name": "Data",
"info": "The data to convert to text.",
},
"template": {
"display_name": "Template",
"info": "The template to use for formatting the data. It can contain the keys {text}, {data} or any other key in the Data.",
"multiline": True,
},
}
def build(
self,
data: list[Data],
template: str = "Text: {text}\nData: {data}",
) -> Text:
if not data:
return ""
if isinstance(data, Data):
data = [data]
result_string = data_to_text(template, data)
self.status = result_string
return result_string

View file

@ -6,9 +6,10 @@ from langflow.custom import CustomComponent
from langflow.schema import Data from langflow.schema import Data
class DocumentToDataComponent(CustomComponent): class DocumentsToDataComponent(CustomComponent):
display_name = "Documents To Data" display_name = "Documents ⇢ Data"
description = "Convert LangChain Documents into Data." description = "Convert LangChain Documents into Data."
icon = "LangChain"
field_config = { field_config = {
"documents": {"display_name": "Documents"}, "documents": {"display_name": "Documents"},

View file

@ -1,20 +1,28 @@
from typing import List from typing import List
from langflow.custom import Component from langflow.custom import Component
from langflow.inputs import StrInput from langflow.inputs import StrInput, HandleInput
from langflow.schema import Data from langflow.schema import Data
from langflow.template import Input, Output from langflow.template import Output
class FilterDataComponent(Component): class FilterDataComponent(Component):
display_name = "Filter Message" display_name = "Filter Data"
description = "Filters a Message object based on a list of strings." description = "Filters a Data object based on a list of keys."
icon = "filter" icon = "filter"
inputs = [ inputs = [
Input(name="message", display_name="Message", info="Message object to filter.", input_types=["Message"]), HandleInput(
name="data",
display_name="Data",
info="Data object to filter.",
input_types=["Message", "Data"],
),
StrInput( StrInput(
name="filter_criteria", display_name="Filter Criteria", info="List of strings to filter by.", is_list=True name="filter_criteria",
display_name="Filter Criteria",
info="List of keys to filter by.",
is_list=True,
), ),
] ]
@ -24,10 +32,12 @@ class FilterDataComponent(Component):
def filter_data(self) -> Data: def filter_data(self) -> Data:
filter_criteria: List[str] = self.filter_criteria filter_criteria: List[str] = self.filter_criteria
data = self.data.data if isinstance(self.data, Data) else {}
# Filter the data # Filter the data
filtered = {key: value for key, value in self.message.data.items() if key == filter_criteria} filtered = {key: value for key, value in data.items() if key in filter_criteria}
# Create a new Data object with the filtered data # Create a new Data object with the filtered data
self.status = filtered filtered_data = Data(data=filtered)
return filtered self.status = filtered_data
return filtered_data

View file

@ -0,0 +1,34 @@
from langflow.custom import Component
from langflow.helpers.data import data_to_text
from langflow.field_typing import Text
from langflow.inputs import MultilineInput, HandleInput
from langflow.template import Output
class ParseDataComponent(Component):
display_name = "Parse Data"
description = "Convert Data into plain text following a specified template."
icon = "braces"
inputs = [
HandleInput(
name="data", display_name="Data", info="The data to convert to text.", input_types=["Message", "Data"]
),
MultilineInput(
name="template",
display_name="Template",
info="The template to use for formatting the data. It can contain the keys {text}, {data} or any other key in the Data.",
),
]
outputs = [
Output(display_name="Text", name="text", method="parse_data_to_text"),
]
def parse_data_to_text(self) -> Text:
data = self.data if isinstance(self.data, list) else [self.data]
template = self.template or "Text: {text}"
result_string = data_to_text(template, data)
self.status = result_string
return result_string

View file

@ -1,15 +1,15 @@
from .CreateData import CreateDataComponent from .CreateData import CreateDataComponent
from .CustomComponent import Component from .CustomComponent import Component
from .DataToText import DataToTextComponent from .ParseData import ParseDataComponent
from .DocumentToData import DocumentToDataComponent from .DocumentToData import DocumentsToDataComponent
from .IDGenerator import UUIDGeneratorComponent from .IDGenerator import UUIDGeneratorComponent
from .UpdateData import UpdateDataComponent from .UpdateData import UpdateDataComponent
__all__ = [ __all__ = [
"Component", "Component",
"UpdateDataComponent", "UpdateDataComponent",
"DocumentToDataComponent", "DocumentsToDataComponent",
"UUIDGeneratorComponent", "UUIDGeneratorComponent",
"DataToTextComponent", "ParseDataComponent",
"CreateDataComponent", "CreateDataComponent",
] ]

View file

@ -54,7 +54,7 @@ class ChatAntropicSpecsComponent(CustomComponent):
self, self,
model: str, model: str,
anthropic_api_key: Optional[str] = None, anthropic_api_key: Optional[str] = None,
max_tokens: Optional[int] = None, max_tokens: Optional[int] = 1000,
temperature: Optional[float] = None, temperature: Optional[float] = None,
api_endpoint: Optional[str] = None, api_endpoint: Optional[str] = None,
) -> BaseLanguageModel: ) -> BaseLanguageModel:

View file

@ -66,7 +66,7 @@ class AnthropicLLM(CustomComponent):
self, self,
model: str, model: str,
anthropic_api_key: Optional[str] = None, anthropic_api_key: Optional[str] = None,
max_tokens: Optional[int] = None, max_tokens: Optional[int] = 1000,
temperature: Optional[float] = None, temperature: Optional[float] = None,
anthropic_api_url: Optional[str] = None, anthropic_api_url: Optional[str] = None,
) -> BaseLanguageModel: ) -> BaseLanguageModel:

View file

@ -82,7 +82,7 @@ class AnthropicLLM(LCModelComponent):
input_value: Text, input_value: Text,
system_message: Optional[str] = None, system_message: Optional[str] = None,
anthropic_api_key: Optional[str] = None, anthropic_api_key: Optional[str] = None,
max_tokens: Optional[int] = None, max_tokens: Optional[int] = 1000,
temperature: Optional[float] = None, temperature: Optional[float] = None,
anthropic_api_url: Optional[str] = None, anthropic_api_url: Optional[str] = None,
stream: bool = False, stream: bool = False,

View file

@ -98,10 +98,15 @@ class Component(CustomComponent):
raw = self.status raw = self.status
if hasattr(raw, "data") and raw is not None: if hasattr(raw, "data") and raw is not None:
raw = raw.data raw = raw.data
if raw is None:
raw = custom_repr
elif hasattr(raw, "model_dump") and raw is not None: elif hasattr(raw, "model_dump") and raw is not None:
raw = raw.model_dump() raw = raw.model_dump()
artifact_type = get_artifact_type(self.status, result) if raw is None and isinstance(result, (dict, Data, str)):
raw = result.data if isinstance(result, Data) else result
artifact_type = get_artifact_type(self.repr_value or raw, result)
raw = post_process_raw(raw, artifact_type) raw = post_process_raw(raw, artifact_type)
artifact = {"repr": custom_repr, "raw": raw, "type": artifact_type} artifact = {"repr": custom_repr, "raw": raw, "type": artifact_type}
_artifacts[output.name] = artifact _artifacts[output.name] = artifact
@ -110,21 +115,15 @@ class Component(CustomComponent):
return _results, _artifacts return _results, _artifacts
def custom_repr(self): def custom_repr(self):
# ! Temporary REPR if self.repr_value == "":
# Since all are dict, yaml.dump them self.repr_value = self.status
if isinstance(self._results, dict): if isinstance(self.repr_value, dict):
_build_results = recursive_serialize_or_str(self._results) return yaml.dump(self.repr_value)
try: if isinstance(self.repr_value, str):
custom_repr = yaml.dump(_build_results) return self.repr_value
except Exception as e: if isinstance(self.repr_value, BaseModel) and not isinstance(self.repr_value, Data):
logger.error(f"Error while dumping build_result: {e}") return str(self.repr_value)
custom_repr = str(self._results) return self.repr_value
if custom_repr is None and isinstance(self._results, (dict, Data, str)):
custom_repr = self._results
if not isinstance(custom_repr, str):
custom_repr = str(custom_repr)
return custom_repr
def build_inputs(self, user_id: Optional[Union[str, UUID]] = None): def build_inputs(self, user_id: Optional[Union[str, UUID]] = None):
""" """

View file

@ -35,8 +35,8 @@ class ResultData(BaseModel):
message = values["artifacts"][key] message = values["artifacts"][key]
# ! Temporary fix # ! Temporary fix
if not isinstance(message, dict): if message is None:
message = {"message": message} continue
if "stream_url" in message and "type" in message: if "stream_url" in message and "type" in message:
stream_url = StreamURL(location=message["stream_url"]) stream_url = StreamURL(location=message["stream_url"])

View file

@ -234,16 +234,15 @@ class Vertex:
self.has_session_id = "session_id" in template_dicts self.has_session_id = "session_id" in template_dicts
self.required_inputs = [ self.required_inputs = []
template_dicts[key]["type"] for key, value in template_dicts.items() if value["required"] self.optional_inputs = []
] for value_dict in template_dicts.values():
self.optional_inputs = [ list_to_append = self.required_inputs if value_dict.get("required") else self.optional_inputs
template_dicts[key]["type"] for key, value in template_dicts.items() if not value["required"]
] if "type" in value_dict:
# Add the template_dicts[key]["input_types"] to the optional_inputs list_to_append.append(value_dict["type"])
self.optional_inputs.extend( if "input_types" in value_dict:
[input_type for value in template_dicts.values() for input_type in value.get("input_types", [])] list_to_append.extend(value_dict["input_types"])
)
template_dict = self.data["node"]["template"] template_dict = self.data["node"]["template"]
self.vertex_type = ( self.vertex_type = (

View file

@ -5,11 +5,12 @@ from .inputs import (
FileInput, FileInput,
FloatInput, FloatInput,
IntInput, IntInput,
MultilineInput,
NestedDictInput, NestedDictInput,
PromptInput, PromptInput,
SecretStrInput, SecretStrInput,
StrInput, StrInput,
MultilineInput, HandleInput,
) )
__all__ = [ __all__ = [
@ -24,4 +25,5 @@ __all__ = [
"FileInput", "FileInput",
"PromptInput", "PromptInput",
"MultilineInput", "MultilineInput",
"HandleInput",
] ]

View file

@ -25,7 +25,7 @@ SerializableFieldTypes = Annotated[FieldTypes, PlainSerializer(lambda v: v.value
class BaseInputMixin(BaseModel): class BaseInputMixin(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
field_type: Optional[SerializableFieldTypes] = Field(default=FieldTypes.TEXT) field_type: Optional[SerializableFieldTypes | str] = Field(default=FieldTypes.TEXT)
required: bool = False required: bool = False
"""Specifies if the field is required. Defaults to False.""" """Specifies if the field is required. Defaults to False."""

View file

@ -1,6 +1,6 @@
from typing import Callable, Optional, Union from typing import Callable, Optional, Union
from pydantic import Field from pydantic import Field, model_validator
from langflow.inputs.validators import StrictBoolean from langflow.inputs.validators import StrictBoolean
@ -16,6 +16,17 @@ from .input_mixin import (
) )
class HandleInput(BaseInputMixin):
input_types: list[str] = Field(default_factory=list)
field_type: Optional[str] = ""
@model_validator(mode="after")
def validate_model_type(self):
# FieldType should be a string
self.field_type = " | ".join(self.input_types)
return self
class PromptInput(BaseInputMixin, ListableInputMixin): class PromptInput(BaseInputMixin, ListableInputMixin):
field_type: Optional[SerializableFieldTypes] = FieldTypes.PROMPT field_type: Optional[SerializableFieldTypes] = FieldTypes.PROMPT
@ -24,6 +35,7 @@ class PromptInput(BaseInputMixin, ListableInputMixin):
class StrInput(BaseInputMixin, ListableInputMixin, DatabaseLoadMixin): # noqa: F821 class StrInput(BaseInputMixin, ListableInputMixin, DatabaseLoadMixin): # noqa: F821
field_type: Optional[SerializableFieldTypes] = FieldTypes.TEXT field_type: Optional[SerializableFieldTypes] = FieldTypes.TEXT
load_from_db: StrictBoolean = False load_from_db: StrictBoolean = False
input_types: list[str] = ["Text"]
"""Defines if the field will allow the user to open a text editor. Default is False.""" """Defines if the field will allow the user to open a text editor. Default is False."""
@ -83,4 +95,5 @@ InputTypes = Union[
FileInput, FileInput,
PromptInput, PromptInput,
MultilineInput, MultilineInput,
HandleInput,
] ]

View file

@ -23,7 +23,9 @@ class Template(BaseModel):
# first sort alphabetically # first sort alphabetically
# then sort fields so that fields that have .field_type in DIRECT_TYPES are first # then sort fields so that fields that have .field_type in DIRECT_TYPES are first
self.fields.sort(key=lambda x: x.name) self.fields.sort(key=lambda x: x.name)
self.fields.sort(key=lambda x: x.field_type in DIRECT_TYPES, reverse=False) self.fields.sort(
key=lambda x: x.field_type in DIRECT_TYPES if hasattr(x, "field_type") else False, reverse=False
)
@model_serializer(mode="wrap") @model_serializer(mode="wrap")
def serialize_model(self, handler): def serialize_model(self, handler):

View file

@ -302,6 +302,12 @@ export default function GenericNode({
); );
}; };
useEffect(() => {
if (hiddenOutputs && hiddenOutputs.length == 0) {
setShowHiddenOutputs(false);
}
}, [hiddenOutputs]);
const memoizedNodeToolbarComponent = useMemo(() => { const memoizedNodeToolbarComponent = useMemo(() => {
return ( return (
<NodeToolbar> <NodeToolbar>

View file

@ -123,7 +123,7 @@ export const MenuBar = ({}: {}): JSX.Element => {
title: UPLOAD_ERROR_ALERT, title: UPLOAD_ERROR_ALERT,
list: [error], list: [error],
}); });
} },
); );
}} }}
> >
@ -193,7 +193,7 @@ export const MenuBar = ({}: {}): JSX.Element => {
name="RefreshCcw" name="RefreshCcw"
className="header-menu-options" className="header-menu-options"
/> />
Reload Components Refresh All
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -221,7 +221,7 @@ export const MenuBar = ({}: {}): JSX.Element => {
name={isBuilding || saveLoading ? "Loader2" : "CheckCircle2"} name={isBuilding || saveLoading ? "Loader2" : "CheckCircle2"}
className={cn( className={cn(
"h-4 w-4", "h-4 w-4",
isBuilding || saveLoading ? "animate-spin" : "animate-wiggle" isBuilding || saveLoading ? "animate-spin" : "animate-wiggle",
)} )}
/> />
{printByBuildStatus()} {printByBuildStatus()}

View file

@ -244,7 +244,7 @@ export const nodeColors: { [char: string]: string } = {
outputs: "#AA2411", outputs: "#AA2411",
data: "#198BF6", data: "#198BF6",
prompts: "#4367BF", prompts: "#4367BF",
models: "#6344BE", models: "#ab11ab",
model_specs: "#6344BE", model_specs: "#6344BE",
chains: "#FE7500", chains: "#FE7500",
Document: "#7AAE42", Document: "#7AAE42",
@ -271,10 +271,10 @@ export const nodeColors: { [char: string]: string } = {
Text: "#4367BF", Text: "#4367BF",
retrievers: "#e6b25a", retrievers: "#e6b25a",
unknown: "#9CA3AF", unknown: "#9CA3AF",
custom_components: "#ab11ab", // custom_components: "#ab11ab",
Data: "#31a3cc", Data: "#9CA3AF",
Data: "#31a3cc",
Message: "#4367BF", Message: "#4367BF",
BaseLanguageModel: "#ab11ab",
}; };
export const nodeNames: { [char: string]: string } = { export const nodeNames: { [char: string]: string } = {