Astra Assistants Support (#2041)
* ruff * revert config.yaml * add build_config * ruff * add build_config
This commit is contained in:
parent
912683009f
commit
0d5228d681
14 changed files with 274 additions and 15 deletions
22
poetry.lock
generated
22
poetry.lock
generated
|
|
@ -282,6 +282,25 @@ websockets = ">=11.0"
|
||||||
[package.extras]
|
[package.extras]
|
||||||
extras = ["pyaudio (>=0.2.13)"]
|
extras = ["pyaudio (>=0.2.13)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "astra-assistants"
|
||||||
|
version = "2.0.15"
|
||||||
|
description = "Astra Assistants API - drop in replacement for OpenAI Assistants, powered by AstraDB"
|
||||||
|
optional = false
|
||||||
|
python-versions = "<4.0,>=3.10"
|
||||||
|
files = [
|
||||||
|
{file = "astra_assistants-2.0.15-py3-none-any.whl", hash = "sha256:aa7d9479cf1a501f6edfc666a6d64028866ca8111d2936c4ae5b0649958252cb"},
|
||||||
|
{file = "astra_assistants-2.0.15.tar.gz", hash = "sha256:6daa30f5db3f8460bba8272b3c7bb096c25310cf9f866c8b7b90505e9037138e"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
aiohttp = ">=3.9.4,<4.0.0"
|
||||||
|
boto3 = ">=1.34.31,<2.0.0"
|
||||||
|
httpx = ">=0.27.0,<0.28.0"
|
||||||
|
litellm = ">=1.36.0,<2.0.0"
|
||||||
|
openai = ">=1.20.0,<2.0.0"
|
||||||
|
python-dotenv = ">=1.0.1,<2.0.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "astrapy"
|
name = "astrapy"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
|
|
@ -6336,6 +6355,7 @@ description = "Nvidia JIT LTO Library"
|
||||||
optional = true
|
optional = true
|
||||||
python-versions = ">=3"
|
python-versions = ">=3"
|
||||||
files = [
|
files = [
|
||||||
|
{file = "nvidia_nvjitlink_cu12-12.5.82-py3-none-manylinux2014_aarch64.whl", hash = "sha256:98103729cc5226e13ca319a10bbf9433bbbd44ef64fe72f45f067cacc14b8d27"},
|
||||||
{file = "nvidia_nvjitlink_cu12-12.5.82-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f9b37bc5c8cf7509665cb6ada5aaa0ce65618f2332b7d3e78e9790511f111212"},
|
{file = "nvidia_nvjitlink_cu12-12.5.82-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f9b37bc5c8cf7509665cb6ada5aaa0ce65618f2332b7d3e78e9790511f111212"},
|
||||||
{file = "nvidia_nvjitlink_cu12-12.5.82-py3-none-win_amd64.whl", hash = "sha256:e782564d705ff0bf61ac3e1bf730166da66dd2fe9012f111ede5fc49b64ae697"},
|
{file = "nvidia_nvjitlink_cu12-12.5.82-py3-none-win_amd64.whl", hash = "sha256:e782564d705ff0bf61ac3e1bf730166da66dd2fe9012f111ede5fc49b64ae697"},
|
||||||
]
|
]
|
||||||
|
|
@ -11816,4 +11836,4 @@ local = ["ctransformers", "llama-cpp-python", "sentence-transformers"]
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = ">=3.10,<3.13"
|
python-versions = ">=3.10,<3.13"
|
||||||
content-hash = "1c676440f3d6e74df8460f61ce3106255cc6b5158fcf798de368e4e7d623f19a"
|
content-hash = "dc3c3bf982b64e4f8cf62249471bec4a90e76bde0bd010b4e2e6f6ca0d6a551b"
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ langsmith = "^0.1.86"
|
||||||
yfinance = "^0.2.40"
|
yfinance = "^0.2.40"
|
||||||
langchain-google-community = "^1.0.6"
|
langchain-google-community = "^1.0.6"
|
||||||
wolframalpha = "^5.1.3"
|
wolframalpha = "^5.1.3"
|
||||||
|
astra-assistants = "^2.0.15"
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
from .create_assistant import AssistantsCreateAssistant
|
||||||
|
from .get_assistant import AssistantsGetAssistantName
|
||||||
|
from .list_assistants import AssistantsListAssistants
|
||||||
|
from .run import AssistantsRun
|
||||||
|
from .getenvvar import GetEnvVar
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AssistantsCreateAssistant",
|
||||||
|
"AssistantsGetAssistantName",
|
||||||
|
"AssistantsListAssistants",
|
||||||
|
"AssistantsRun",
|
||||||
|
"GetEnvVar",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
from openai import OpenAI
|
||||||
|
from astra_assistants import patch
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantsCreateAssistant(CustomComponent):
|
||||||
|
display_name = "Create Assistant"
|
||||||
|
description = "Creates an Assistant and returns it's id"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {
|
||||||
|
"name": {
|
||||||
|
"display_name": "Assistant Name",
|
||||||
|
"advanced": False,
|
||||||
|
"info": "Name for the assistant being created",
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"display_name": "Instructions",
|
||||||
|
"info": "Instructions for the assistant, think of these as the system prompt.",
|
||||||
|
"advanced": False,
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"display_name": "Model name",
|
||||||
|
"advanced": False,
|
||||||
|
"info": (
|
||||||
|
"Model for the assistant.\n\n"
|
||||||
|
"Environment variables for provider credentials can be set with the Dotenv Component.\n\n"
|
||||||
|
"Models are supported via LiteLLM, see (https://docs.litellm.ai/docs/providers) for supported model names and env vars."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"env_set": {
|
||||||
|
"display_name": "Environment Set",
|
||||||
|
"advanced": False,
|
||||||
|
"info": "Dummy input to allow chaining with Dotenv Component.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(self, name: str, instructions: str, model: str, env_set: str = None) -> str:
|
||||||
|
print(f"env_set is {env_set}")
|
||||||
|
if env_set is None:
|
||||||
|
raise Exception("Environment variables not set")
|
||||||
|
client = patch(OpenAI())
|
||||||
|
assistant = client.beta.assistants.create(
|
||||||
|
name=name,
|
||||||
|
instructions=instructions,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
return assistant.id
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
from openai import OpenAI
|
||||||
|
from astra_assistants import patch
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantsCreateThread(CustomComponent):
|
||||||
|
display_name = "Create Assistant Thread"
|
||||||
|
description = "Creates a thread and returns the thread id"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {
|
||||||
|
"env_set": {
|
||||||
|
"display_name": "Environment Set",
|
||||||
|
"advanced": False,
|
||||||
|
"info": "Dummy input to allow chaining with Dotenv Component.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(self, env_set: str = None) -> str:
|
||||||
|
client = patch(OpenAI())
|
||||||
|
|
||||||
|
thread = client.beta.threads.create()
|
||||||
|
thread_id = thread.id
|
||||||
|
|
||||||
|
return thread_id
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import io
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
|
||||||
|
|
||||||
|
class Dotenv(CustomComponent):
|
||||||
|
display_name = "Dotenv"
|
||||||
|
description = "Load .env file into env vars"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {
|
||||||
|
"dotenv_file_content": {
|
||||||
|
"display_name": "Dotenv file content",
|
||||||
|
"advanced": False,
|
||||||
|
"info": (
|
||||||
|
"Paste the content of your .env file directly\n\n"
|
||||||
|
"Since contents are sensitive, using a Global variable set as 'password' is recommended"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(self, dotenv_file_content: str) -> str:
|
||||||
|
try:
|
||||||
|
fake_file = io.StringIO(dotenv_file_content)
|
||||||
|
result = load_dotenv(stream=fake_file, override=True)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
from openai import OpenAI
|
||||||
|
from astra_assistants import patch
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantsGetAssistantName(CustomComponent):
|
||||||
|
display_name = "Get Assistant name"
|
||||||
|
description = "Assistant by id"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {
|
||||||
|
"assistant_id": {
|
||||||
|
"display_name": "Assistant ID",
|
||||||
|
"advanced": False,
|
||||||
|
},
|
||||||
|
"env_set": {
|
||||||
|
"display_name": "Environment Set",
|
||||||
|
"advanced": False,
|
||||||
|
"info": "Dummy input to allow chaining with Dotenv Component.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(self, assistant_id: str, env_set: str = None) -> str:
|
||||||
|
client = patch(OpenAI())
|
||||||
|
assistant = client.beta.assistants.retrieve(
|
||||||
|
assistant_id=assistant_id,
|
||||||
|
)
|
||||||
|
return assistant.name
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import os
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
|
||||||
|
|
||||||
|
class GetEnvVar(CustomComponent):
|
||||||
|
display_name = "Get env var"
|
||||||
|
description = "Get env var"
|
||||||
|
icon = "custom_components"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {"env_var_name": {"display_name": "Env var name"}}
|
||||||
|
|
||||||
|
def build(self, env_var_name: str) -> str:
|
||||||
|
return os.environ[env_var_name]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
from typing import List
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
from openai import OpenAI
|
||||||
|
from astra_assistants import patch
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantsListAssistants(CustomComponent):
|
||||||
|
display_name = "List Assistants"
|
||||||
|
description = "Returns a list of assistant id's"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def build(self) -> List[str]:
|
||||||
|
client = patch(OpenAI())
|
||||||
|
assistants = client.beta.assistants.list()
|
||||||
|
id_list = [assistant.id for assistant in assistants]
|
||||||
|
return id_list
|
||||||
63
src/backend/base/langflow/components/astra_assistants/run.py
Normal file
63
src/backend/base/langflow/components/astra_assistants/run.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from langflow.custom import CustomComponent
|
||||||
|
from openai import OpenAI
|
||||||
|
from openai.lib.streaming import AssistantEventHandler
|
||||||
|
from astra_assistants import patch
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantsRun(CustomComponent):
|
||||||
|
display_name = "Run Assistant"
|
||||||
|
description = "Executes an Assistant Run against a thread"
|
||||||
|
|
||||||
|
def build_config(self):
|
||||||
|
return {
|
||||||
|
"assistant_id": {
|
||||||
|
"display_name": "Assistant ID",
|
||||||
|
"advanced": False,
|
||||||
|
"info": (
|
||||||
|
"The ID of the assistant to run. \n\n"
|
||||||
|
"Can be retrieved using the List Assistants component or created with the Create Assistant component."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"user_message": {
|
||||||
|
"display_name": "User Message",
|
||||||
|
"info": "User message to pass to the run.",
|
||||||
|
"advanced": False,
|
||||||
|
},
|
||||||
|
"thread_id": {
|
||||||
|
"display_name": "Thread ID",
|
||||||
|
"advanced": False,
|
||||||
|
"info": "Thread ID to use with the run. If not provided, a new thread will be created.",
|
||||||
|
},
|
||||||
|
"env_set": {
|
||||||
|
"display_name": "Environment Set",
|
||||||
|
"advanced": False,
|
||||||
|
"info": "Dummy input to allow chaining with Dotenv Component.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def build(self, assistant_id: str, user_message: str, thread_id: str = None, env_set: str = None) -> str:
|
||||||
|
text = ""
|
||||||
|
client = patch(OpenAI())
|
||||||
|
|
||||||
|
if thread_id is None:
|
||||||
|
thread = client.beta.threads.create()
|
||||||
|
thread_id = thread.id
|
||||||
|
|
||||||
|
# add the user message
|
||||||
|
client.beta.threads.messages.create(thread_id=thread_id, role="user", content=user_message)
|
||||||
|
|
||||||
|
class EventHandler(AssistantEventHandler):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
event_handler = EventHandler()
|
||||||
|
with client.beta.threads.runs.create_and_stream(
|
||||||
|
thread_id=thread_id,
|
||||||
|
assistant_id=assistant_id,
|
||||||
|
event_handler=event_handler,
|
||||||
|
) as stream:
|
||||||
|
# return stream.text_deltas
|
||||||
|
for part in stream.text_deltas:
|
||||||
|
text += part
|
||||||
|
print(part)
|
||||||
|
return text
|
||||||
|
|
@ -1619,4 +1619,4 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
src/frontend/package-lock.json
generated
1
src/frontend/package-lock.json
generated
|
|
@ -789,6 +789,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
|
"extraneous": true,
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
@apply w-full truncate pr-1 text-xs text-foreground;
|
@apply w-full truncate pr-1 text-xs text-foreground;
|
||||||
}
|
}
|
||||||
.side-bar-components-div-form {
|
.side-bar-components-div-form {
|
||||||
@apply flex w-full items-center justify-between rounded-md rounded-l-none border border-l-0 border-dashed border-ring bg-white px-3 py-1 text-sm;
|
@apply flex w-full items-center justify-between rounded-md rounded-l-none border border-l-0 border-dashed border-ring bg-white px-3 py-1 text-sm;
|
||||||
}
|
}
|
||||||
.side-bar-components-border {
|
.side-bar-components-border {
|
||||||
@apply cursor-grab rounded-l-md border-l-8;
|
@apply cursor-grab rounded-l-md border-l-8;
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
/* Frozen state border */
|
/* Frozen state border */
|
||||||
.border-ring-frozen {
|
.border-ring-frozen {
|
||||||
position: relative;
|
position: relative;
|
||||||
@apply rounded-md border shadow-frozen-ring;
|
@apply rounded-md border shadow-frozen-ring;
|
||||||
}
|
}
|
||||||
.border-ring-frozen::before {
|
.border-ring-frozen::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|
@ -440,10 +440,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.unused-side-bar-aside {
|
.unused-side-bar-aside {
|
||||||
@apply flex flex-shrink-0 flex-col overflow-hidden border-r transition-all duration-500;
|
@apply flex flex-shrink-0 flex-col overflow-hidden border-r transition-all duration-500;
|
||||||
}
|
}
|
||||||
.unused-side-bar-arrangement {
|
.unused-side-bar-arrangement {
|
||||||
@apply flex h-full w-52 flex-col items-start overflow-y-auto border bg-background scrollbar-hide;
|
@apply flex h-full w-52 flex-col items-start overflow-y-auto border bg-background scrollbar-hide;
|
||||||
}
|
}
|
||||||
.unused-side-bar-division {
|
.unused-side-bar-division {
|
||||||
@apply flex-max-width flex-grow flex-col;
|
@apply flex-max-width flex-grow flex-col;
|
||||||
|
|
@ -593,13 +593,13 @@
|
||||||
@apply inline-flex h-9 items-center justify-center rounded-md border border-input px-3 pr-0 shadow-sm;
|
@apply inline-flex h-9 items-center justify-center rounded-md border border-input px-3 pr-0 shadow-sm;
|
||||||
}
|
}
|
||||||
.header-waitlist-link-box {
|
.header-waitlist-link-box {
|
||||||
@apply inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-input px-2 text-sm font-medium text-muted-foreground shadow-sm ring-offset-background disabled:pointer-events-none disabled:opacity-50;
|
@apply inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-input px-2 text-sm font-medium text-muted-foreground shadow-sm ring-offset-background disabled:pointer-events-none disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.header-waitlist-link-box:hover {
|
.header-waitlist-link-box:hover {
|
||||||
@apply hover:bg-accent hover:text-accent-foreground;
|
@apply hover:bg-accent hover:text-accent-foreground;
|
||||||
}
|
}
|
||||||
.header-github-link {
|
.header-github-link {
|
||||||
@apply header-github-link-box text-sm font-medium text-muted-foreground ring-offset-background disabled:pointer-events-none disabled:opacity-50;
|
@apply header-github-link-box text-sm font-medium text-muted-foreground ring-offset-background disabled:pointer-events-none disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.header-github-link:focus-visible {
|
.header-github-link:focus-visible {
|
||||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||||
|
|
@ -647,7 +647,7 @@
|
||||||
@apply focus:outline-none focus:ring-1 focus:ring-primary focus:ring-offset-1;
|
@apply focus:outline-none focus:ring-1 focus:ring-primary focus:ring-offset-1;
|
||||||
}
|
}
|
||||||
.toggle-component-span {
|
.toggle-component-span {
|
||||||
@apply pointer-events-none relative h-5 w-5 transform rounded-full shadow ring-0 transition duration-200 ease-in-out;
|
@apply pointer-events-none relative h-5 w-5 transform rounded-full shadow ring-0 transition duration-200 ease-in-out;
|
||||||
}
|
}
|
||||||
.toggle-component-second-span {
|
.toggle-component-second-span {
|
||||||
@apply absolute inset-0 flex h-full w-full items-center justify-center transition-opacity;
|
@apply absolute inset-0 flex h-full w-full items-center justify-center transition-opacity;
|
||||||
|
|
@ -658,7 +658,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-modal-txtarea {
|
.chat-input-modal-txtarea {
|
||||||
@apply form-input block w-full rounded-md border-ring pr-10 custom-scroll sm:text-sm;
|
@apply form-input block w-full rounded-md border-ring pr-10 custom-scroll sm:text-sm;
|
||||||
}
|
}
|
||||||
.chat-input-modal-div {
|
.chat-input-modal-div {
|
||||||
@apply absolute bottom-0.5 right-3;
|
@apply absolute bottom-0.5 right-3;
|
||||||
|
|
@ -693,7 +693,7 @@
|
||||||
@apply flex-max-width items-center text-start;
|
@apply flex-max-width items-center text-start;
|
||||||
}
|
}
|
||||||
.chat-message-modal-text {
|
.chat-message-modal-text {
|
||||||
@apply relative w-full text-start text-sm font-normal text-muted-foreground;
|
@apply relative w-full text-start text-sm font-normal text-muted-foreground;
|
||||||
}
|
}
|
||||||
.chat-message-modal-icon-div {
|
.chat-message-modal-icon-div {
|
||||||
@apply absolute -left-2 -top-1 cursor-pointer;
|
@apply absolute -left-2 -top-1 cursor-pointer;
|
||||||
|
|
@ -1128,7 +1128,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-invalid {
|
.input-invalid {
|
||||||
@apply border-destructive focus:border-destructive focus:ring-destructive;
|
@apply border-destructive focus:border-destructive focus:ring-destructive;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-container {
|
.fade-container {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
|
||||||
import tailwindcssForms from "@tailwindcss/forms";
|
import tailwindcssForms from "@tailwindcss/forms";
|
||||||
import tailwindcssAnimate from "tailwindcss-animate";
|
|
||||||
import tailwindcssTypography from "@tailwindcss/typography";
|
import tailwindcssTypography from "@tailwindcss/typography";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
import tailwindcssDottedBackground from "tailwindcss-dotted-background";
|
import tailwindcssDottedBackground from "tailwindcss-dotted-background";
|
||||||
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
import plugin from "tailwindcss/plugin";
|
import plugin from "tailwindcss/plugin";
|
||||||
|
|
||||||
|
|
@ -254,4 +254,4 @@ const config = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue