mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-26 17:51:54 +01:00
Slack bot management dashboard (#483)
This commit is contained in:
parent
0c58c8d6cb
commit
d41d844116
@ -0,0 +1,38 @@
|
||||
"""Add SlackBotConfig table
|
||||
|
||||
Revision ID: 7da543f5672f
|
||||
Revises: febe9eaa0644
|
||||
Create Date: 2023-09-24 16:34:17.526128
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7da543f5672f"
|
||||
down_revision = "febe9eaa0644"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"slack_bot_config",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("persona_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"channel_config",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["persona_id"],
|
||||
["persona.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("slack_bot_config")
|
@ -0,0 +1,37 @@
|
||||
"""Add document_set / persona relationship table
|
||||
|
||||
Revision ID: febe9eaa0644
|
||||
Revises: 57b53544726e
|
||||
Create Date: 2023-09-24 13:06:24.018610
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "febe9eaa0644"
|
||||
down_revision = "57b53544726e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"persona__document_set",
|
||||
sa.Column("persona_id", sa.Integer(), nullable=False),
|
||||
sa.Column("document_set_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["document_set_id"],
|
||||
["document_set.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["persona_id"],
|
||||
["persona.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("persona_id", "document_set_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("persona__document_set")
|
40
backend/danswer/bots/slack/config.py
Normal file
40
backend/danswer/bots/slack/config.py
Normal file
@ -0,0 +1,40 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.db.models import SlackBotConfig
|
||||
from danswer.db.slack_bot_config import fetch_slack_bot_configs
|
||||
|
||||
|
||||
def get_slack_bot_config_for_channel(
|
||||
channel_name: str, db_session: Session
|
||||
) -> SlackBotConfig | None:
|
||||
slack_bot_configs = fetch_slack_bot_configs(db_session=db_session)
|
||||
for config in slack_bot_configs:
|
||||
if channel_name in config.channel_config["channel_names"]:
|
||||
return config
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_channel_names(
|
||||
channel_names: list[str],
|
||||
current_slack_bot_config_id: int | None,
|
||||
db_session: Session,
|
||||
) -> list[str]:
|
||||
"""Make sure that these channel_names don't exist in other slack bot configs.
|
||||
Returns a list of cleaned up channel names (e.g. '#' removed if present)"""
|
||||
slack_bot_configs = fetch_slack_bot_configs(db_session=db_session)
|
||||
cleaned_channel_names = [
|
||||
channel_name.lstrip("#").lower() for channel_name in channel_names
|
||||
]
|
||||
for slack_bot_config in slack_bot_configs:
|
||||
if slack_bot_config.id == current_slack_bot_config_id:
|
||||
continue
|
||||
|
||||
for channel_name in cleaned_channel_names:
|
||||
if channel_name in slack_bot_config.channel_config["channel_names"]:
|
||||
raise ValueError(
|
||||
f"Channel name '{channel_name}' already exists in "
|
||||
"another slack bot config"
|
||||
)
|
||||
|
||||
return cleaned_channel_names
|
@ -6,12 +6,16 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.bots.slack.blocks import build_documents_blocks
|
||||
from danswer.bots.slack.blocks import build_qa_response_blocks
|
||||
from danswer.bots.slack.config import get_slack_bot_config_for_channel
|
||||
from danswer.bots.slack.utils import get_channel_name_from_id
|
||||
from danswer.bots.slack.utils import respond_in_thread
|
||||
from danswer.configs.app_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
|
||||
from danswer.configs.app_configs import DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER
|
||||
from danswer.configs.app_configs import DANSWER_BOT_DISPLAY_ERROR_MSGS
|
||||
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
|
||||
from danswer.configs.app_configs import DOCUMENT_INDEX_NAME
|
||||
from danswer.configs.app_configs import ENABLE_DANSWERBOT_REFLEXION
|
||||
from danswer.configs.constants import DOCUMENT_SETS
|
||||
from danswer.db.engine import get_sqlalchemy_engine
|
||||
from danswer.direct_qa.answer_question import answer_qa_query
|
||||
from danswer.server.models import QAResponse
|
||||
@ -29,6 +33,27 @@ def handle_message(
|
||||
should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS,
|
||||
disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER,
|
||||
) -> None:
|
||||
engine = get_sqlalchemy_engine()
|
||||
with Session(engine) as db_session:
|
||||
channel_name = get_channel_name_from_id(client=client, channel_id=channel)
|
||||
slack_bot_config = get_slack_bot_config_for_channel(
|
||||
channel_name=channel_name, db_session=db_session
|
||||
)
|
||||
document_set_names: list[str] | None = None
|
||||
validity_check_enabled = ENABLE_DANSWERBOT_REFLEXION
|
||||
if slack_bot_config and slack_bot_config.persona:
|
||||
document_set_names = [
|
||||
document_set.name
|
||||
for document_set in slack_bot_config.persona.document_sets
|
||||
]
|
||||
validity_check_enabled = slack_bot_config.channel_config.get(
|
||||
"answer_validity_check_enabled", validity_check_enabled
|
||||
)
|
||||
logger.info(
|
||||
"Found slack bot config for channel. Restricting bot to use document "
|
||||
f"sets: {document_set_names}, validity check enabled: {validity_check_enabled}"
|
||||
)
|
||||
|
||||
@retry(
|
||||
tries=num_retries,
|
||||
delay=0.25,
|
||||
@ -45,6 +70,7 @@ def handle_message(
|
||||
db_session=db_session,
|
||||
answer_generation_timeout=answer_generation_timeout,
|
||||
real_time_flow=False,
|
||||
enable_reflexion=validity_check_enabled,
|
||||
)
|
||||
if not answer.error_msg:
|
||||
return answer
|
||||
@ -57,7 +83,9 @@ def handle_message(
|
||||
query=msg,
|
||||
collection=DOCUMENT_INDEX_NAME,
|
||||
use_keyword=False, # always use semantic search when handling Slack messages
|
||||
filters=None,
|
||||
filters=[{DOCUMENT_SETS: document_set_names}]
|
||||
if document_set_names
|
||||
else None,
|
||||
offset=None,
|
||||
)
|
||||
)
|
||||
|
@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import MutableMapping
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
@ -12,6 +11,8 @@ from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
from danswer.bots.slack.handlers.handle_feedback import handle_slack_feedback
|
||||
from danswer.bots.slack.handlers.handle_message import handle_message
|
||||
from danswer.bots.slack.utils import decompose_block_id
|
||||
from danswer.dynamic_configs.interface import ConfigNotFoundError
|
||||
from danswer.server.slack_bot_management import get_tokens
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@ -37,16 +38,14 @@ class _ChannelIdAdapter(logging.LoggerAdapter):
|
||||
def _get_socket_client() -> SocketModeClient:
|
||||
# For more info on how to set this up, checkout the docs:
|
||||
# https://docs.danswer.dev/slack_bot_setup
|
||||
app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN")
|
||||
if not app_token:
|
||||
raise RuntimeError("DANSWER_BOT_SLACK_APP_TOKEN is not set")
|
||||
bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN")
|
||||
if not bot_token:
|
||||
raise RuntimeError("DANSWER_BOT_SLACK_BOT_TOKEN is not set")
|
||||
try:
|
||||
slack_bot_tokens = get_tokens()
|
||||
except ConfigNotFoundError:
|
||||
raise RuntimeError("Slack tokens not found")
|
||||
return SocketModeClient(
|
||||
# This app-level token will be used only for establishing a connection
|
||||
app_token=app_token,
|
||||
web_client=WebClient(token=bot_token),
|
||||
app_token=slack_bot_tokens.app_token,
|
||||
web_client=WebClient(token=slack_bot_tokens.bot_token),
|
||||
)
|
||||
|
||||
|
||||
|
35
backend/danswer/bots/slack/tokens.py
Normal file
35
backend/danswer/bots/slack/tokens.py
Normal file
@ -0,0 +1,35 @@
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
from danswer.dynamic_configs import get_dynamic_config_store
|
||||
from danswer.dynamic_configs.interface import ConfigNotFoundError
|
||||
from danswer.server.models import SlackBotTokens
|
||||
|
||||
|
||||
_SLACK_BOT_TOKENS_CONFIG_KEY = "slack_bot_tokens_config_key"
|
||||
|
||||
|
||||
def fetch_tokens() -> SlackBotTokens:
|
||||
# first check env variables
|
||||
app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN")
|
||||
bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN")
|
||||
if app_token and bot_token:
|
||||
return SlackBotTokens(app_token=app_token, bot_token=bot_token)
|
||||
|
||||
dynamic_config_store = get_dynamic_config_store()
|
||||
try:
|
||||
return SlackBotTokens(
|
||||
**cast(dict, dynamic_config_store.load(key=_SLACK_BOT_TOKENS_CONFIG_KEY))
|
||||
)
|
||||
except ConfigNotFoundError as e:
|
||||
raise ValueError from e
|
||||
|
||||
|
||||
def save_tokens(
|
||||
tokens: SlackBotTokens,
|
||||
) -> None:
|
||||
dynamic_config_store = get_dynamic_config_store()
|
||||
dynamic_config_store.store(
|
||||
key=_SLACK_BOT_TOKENS_CONFIG_KEY,
|
||||
val=dict(tokens),
|
||||
)
|
@ -2,6 +2,7 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
|
||||
from retry import retry
|
||||
@ -9,6 +10,7 @@ from slack_sdk import WebClient
|
||||
from slack_sdk.models.blocks import Block
|
||||
from slack_sdk.models.metadata import Metadata
|
||||
|
||||
from danswer.bots.slack.tokens import fetch_tokens
|
||||
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
|
||||
from danswer.configs.constants import ID_SEPARATOR
|
||||
from danswer.connectors.slack.utils import make_slack_api_rate_limited
|
||||
@ -19,6 +21,11 @@ from danswer.utils.text_processing import replace_whitespaces_w_space
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def get_web_client() -> WebClient:
|
||||
slack_tokens = fetch_tokens()
|
||||
return WebClient(token=slack_tokens.bot_token)
|
||||
|
||||
|
||||
@retry(
|
||||
tries=DANSWER_BOT_NUM_RETRIES,
|
||||
delay=0.25,
|
||||
@ -119,3 +126,13 @@ def remove_slack_text_interactions(slack_str: str) -> str:
|
||||
slack_str = UserIdReplacer.replace_links(slack_str)
|
||||
slack_str = UserIdReplacer.add_zero_width_whitespace_after_tag(slack_str)
|
||||
return slack_str
|
||||
|
||||
|
||||
def get_channel_from_id(client: WebClient, channel_id: str) -> dict[str, Any]:
|
||||
response = client.conversations_info(channel=channel_id)
|
||||
response.validate()
|
||||
return response["channel"]
|
||||
|
||||
|
||||
def get_channel_name_from_id(client: WebClient, channel_id: str) -> str:
|
||||
return get_channel_from_id(client, channel_id)["name"]
|
||||
|
@ -270,6 +270,7 @@ def create_persona(
|
||||
hint_text: str | None,
|
||||
default_persona: bool,
|
||||
db_session: Session,
|
||||
commit: bool = True,
|
||||
) -> Persona:
|
||||
persona = db_session.query(Persona).filter_by(id=persona_id).first()
|
||||
|
||||
@ -292,6 +293,10 @@ def create_persona(
|
||||
)
|
||||
db_session.add(persona)
|
||||
|
||||
db_session.commit()
|
||||
if commit:
|
||||
db_session.commit()
|
||||
else:
|
||||
# flush the session so that the persona has an ID
|
||||
db_session.flush()
|
||||
|
||||
return persona
|
||||
|
@ -2,6 +2,8 @@ import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import NotRequired
|
||||
from typing import TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_users.db import SQLAlchemyBaseOAuthAccountTableUUID
|
||||
@ -79,6 +81,43 @@ class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
|
||||
pass
|
||||
|
||||
|
||||
"""
|
||||
Association tables
|
||||
NOTE: must be at the top since they are referenced by other tables
|
||||
"""
|
||||
|
||||
|
||||
class Persona__DocumentSet(Base):
|
||||
__tablename__ = "persona__document_set"
|
||||
|
||||
persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True)
|
||||
document_set_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("document_set.id"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class DocumentSet__ConnectorCredentialPair(Base):
|
||||
__tablename__ = "document_set__connector_credential_pair"
|
||||
|
||||
document_set_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("document_set.id"), primary_key=True
|
||||
)
|
||||
connector_credential_pair_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("connector_credential_pair.id"), primary_key=True
|
||||
)
|
||||
# if `True`, then is part of the current state of the document set
|
||||
# if `False`, then is a part of the prior state of the document set
|
||||
# rows with `is_current=False` should be deleted when the document
|
||||
# set is updated and should not exist for a given document set if
|
||||
# `DocumentSet.is_up_to_date == True`
|
||||
is_current: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
|
||||
class ConnectorCredentialPair(Base):
|
||||
"""Connectors and Credentials can have a many-to-many relationship
|
||||
I.e. A Confluence Connector may have multiple admin users who can run it with their own credentials
|
||||
@ -118,6 +157,11 @@ class ConnectorCredentialPair(Base):
|
||||
credential: Mapped["Credential"] = relationship(
|
||||
"Credential", back_populates="connectors"
|
||||
)
|
||||
document_sets: Mapped[List["DocumentSet"]] = relationship(
|
||||
"DocumentSet",
|
||||
secondary=DocumentSet__ConnectorCredentialPair.__table__,
|
||||
back_populates="connector_credential_pairs",
|
||||
)
|
||||
|
||||
|
||||
class Connector(Base):
|
||||
@ -349,36 +393,15 @@ class DocumentSet(Base):
|
||||
# whether or not changes to the document set have been propogated
|
||||
is_up_to_date: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
connector_credential_pair_relationships: Mapped[
|
||||
list["DocumentSet__ConnectorCredentialPair"]
|
||||
] = relationship(
|
||||
"DocumentSet__ConnectorCredentialPair", back_populates="document_set"
|
||||
connector_credential_pairs: Mapped[list[ConnectorCredentialPair]] = relationship(
|
||||
"ConnectorCredentialPair",
|
||||
secondary=DocumentSet__ConnectorCredentialPair.__table__,
|
||||
back_populates="document_sets",
|
||||
)
|
||||
|
||||
|
||||
class DocumentSet__ConnectorCredentialPair(Base):
|
||||
__tablename__ = "document_set__connector_credential_pair"
|
||||
|
||||
document_set_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("document_set.id"), primary_key=True
|
||||
)
|
||||
connector_credential_pair_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("connector_credential_pair.id"), primary_key=True
|
||||
)
|
||||
# if `True`, then is part of the current state of the document set
|
||||
# if `False`, then is a part of the prior state of the document set
|
||||
# rows with `is_current=False` should be deleted when the document
|
||||
# set is updated and should not exist for a given document set if
|
||||
# `DocumentSet.is_up_to_date == True`
|
||||
is_current: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
document_set: Mapped[DocumentSet] = relationship(
|
||||
"DocumentSet", back_populates="connector_credential_pair_relationships"
|
||||
personas: Mapped[list["Persona"]] = relationship(
|
||||
"Persona",
|
||||
secondary=Persona__DocumentSet.__table__,
|
||||
back_populates="document_sets",
|
||||
)
|
||||
|
||||
|
||||
@ -421,6 +444,12 @@ class Persona(Base):
|
||||
# If it's updated and no longer latest (should no longer be shown), it is also considered deleted
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
document_sets: Mapped[list[DocumentSet]] = relationship(
|
||||
"DocumentSet",
|
||||
secondary=Persona__DocumentSet.__table__,
|
||||
back_populates="personas",
|
||||
)
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
__tablename__ = "chat_message"
|
||||
@ -446,3 +475,27 @@ class ChatMessage(Base):
|
||||
|
||||
chat_session: Mapped[ChatSession] = relationship("ChatSession")
|
||||
persona: Mapped[Persona | None] = relationship("Persona")
|
||||
|
||||
|
||||
class ChannelConfig(TypedDict):
|
||||
"""NOTE: is a `TypedDict` so it can be used a type hint for a JSONB column
|
||||
in Postgres"""
|
||||
|
||||
channel_names: list[str]
|
||||
answer_validity_check_enabled: NotRequired[bool] # not specified => False
|
||||
team_members: NotRequired[list[str]]
|
||||
|
||||
|
||||
class SlackBotConfig(Base):
|
||||
__tablename__ = "slack_bot_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
persona_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("persona.id"), nullable=True
|
||||
)
|
||||
# JSON for flexibility. Contains things like: channel name, team members, etc.
|
||||
channel_config: Mapped[ChannelConfig] = mapped_column(
|
||||
postgresql.JSONB(), nullable=False
|
||||
)
|
||||
|
||||
persona: Mapped[Persona | None] = relationship("Persona")
|
||||
|
149
backend/danswer/db/slack_bot_config.py
Normal file
149
backend/danswer/db/slack_bot_config.py
Normal file
@ -0,0 +1,149 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.db.chat import create_persona
|
||||
from danswer.db.models import ChannelConfig
|
||||
from danswer.db.models import Persona
|
||||
from danswer.db.models import Persona__DocumentSet
|
||||
from danswer.db.models import SlackBotConfig
|
||||
|
||||
|
||||
def _build_persona_name(channel_names: list[str]) -> str:
|
||||
return f"__slack_bot_persona__{'-'.join(channel_names)}"
|
||||
|
||||
|
||||
def _cleanup_relationships(db_session: Session, persona_id: int) -> None:
|
||||
"""NOTE: does not commit changes"""
|
||||
# delete existing persona-document_set relationships
|
||||
existing_relationships = db_session.scalars(
|
||||
select(Persona__DocumentSet).where(
|
||||
Persona__DocumentSet.persona_id == persona_id
|
||||
)
|
||||
)
|
||||
for rel in existing_relationships:
|
||||
db_session.delete(rel)
|
||||
|
||||
|
||||
def _create_slack_bot_persona(
|
||||
db_session: Session,
|
||||
channel_names: list[str],
|
||||
document_sets: list[int],
|
||||
existing_persona_id: int | None = None,
|
||||
) -> Persona:
|
||||
"""NOTE: does not commit changes"""
|
||||
# create/update persona associated with the slack bot
|
||||
persona_name = _build_persona_name(channel_names)
|
||||
persona = create_persona(
|
||||
persona_id=existing_persona_id,
|
||||
name=persona_name,
|
||||
retrieval_enabled=True,
|
||||
system_text=None,
|
||||
tools_text=None,
|
||||
hint_text=None,
|
||||
default_persona=False,
|
||||
db_session=db_session,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
if existing_persona_id:
|
||||
_cleanup_relationships(db_session=db_session, persona_id=existing_persona_id)
|
||||
|
||||
# create relationship between the new persona and the desired document_sets
|
||||
for document_set_id in document_sets:
|
||||
db_session.add(
|
||||
Persona__DocumentSet(persona_id=persona.id, document_set_id=document_set_id)
|
||||
)
|
||||
|
||||
return persona
|
||||
|
||||
|
||||
def insert_slack_bot_config(
|
||||
document_sets: list[int],
|
||||
channel_config: ChannelConfig,
|
||||
db_session: Session,
|
||||
) -> SlackBotConfig:
|
||||
persona = None
|
||||
if document_sets:
|
||||
persona = _create_slack_bot_persona(
|
||||
db_session=db_session,
|
||||
channel_names=channel_config["channel_names"],
|
||||
document_sets=document_sets,
|
||||
)
|
||||
|
||||
slack_bot_config = SlackBotConfig(
|
||||
persona_id=persona.id if persona else None,
|
||||
channel_config=channel_config,
|
||||
)
|
||||
db_session.add(slack_bot_config)
|
||||
db_session.commit()
|
||||
|
||||
return slack_bot_config
|
||||
|
||||
|
||||
def update_slack_bot_config(
|
||||
slack_bot_config_id: int,
|
||||
document_sets: list[int],
|
||||
channel_config: ChannelConfig,
|
||||
db_session: Session,
|
||||
) -> SlackBotConfig:
|
||||
slack_bot_config = db_session.scalar(
|
||||
select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id)
|
||||
)
|
||||
if slack_bot_config is None:
|
||||
raise ValueError(
|
||||
f"Unable to find slack bot config with ID {slack_bot_config_id}"
|
||||
)
|
||||
|
||||
existing_persona_id = slack_bot_config.persona_id
|
||||
|
||||
persona = None
|
||||
if document_sets:
|
||||
persona = _create_slack_bot_persona(
|
||||
db_session=db_session,
|
||||
channel_names=channel_config["channel_names"],
|
||||
document_sets=document_sets,
|
||||
existing_persona_id=slack_bot_config.persona_id,
|
||||
)
|
||||
else:
|
||||
# if no document sets and an existing persona exists, then
|
||||
# remove persona + persona -> document set relationships
|
||||
if existing_persona_id:
|
||||
_cleanup_relationships(
|
||||
db_session=db_session, persona_id=existing_persona_id
|
||||
)
|
||||
existing_persona = db_session.scalar(
|
||||
select(Persona).where(Persona.id == existing_persona_id)
|
||||
)
|
||||
db_session.delete(existing_persona)
|
||||
|
||||
slack_bot_config.persona_id = persona.id if persona else None
|
||||
slack_bot_config.channel_config = channel_config
|
||||
db_session.commit()
|
||||
|
||||
return slack_bot_config
|
||||
|
||||
|
||||
def remove_slack_bot_config(
|
||||
slack_bot_config_id: int,
|
||||
db_session: Session,
|
||||
) -> None:
|
||||
slack_bot_config = db_session.scalar(
|
||||
select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id)
|
||||
)
|
||||
if slack_bot_config is None:
|
||||
raise ValueError(
|
||||
f"Unable to find slack bot config with ID {slack_bot_config_id}"
|
||||
)
|
||||
|
||||
existing_persona_id = slack_bot_config.persona_id
|
||||
if existing_persona_id:
|
||||
_cleanup_relationships(db_session=db_session, persona_id=existing_persona_id)
|
||||
|
||||
db_session.delete(slack_bot_config)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def fetch_slack_bot_configs(db_session: Session) -> Sequence[SlackBotConfig]:
|
||||
return db_session.scalars(select(SlackBotConfig)).all()
|
@ -42,6 +42,7 @@ from danswer.server.event_loading import router as event_processing_router
|
||||
from danswer.server.health import router as health_router
|
||||
from danswer.server.manage import router as admin_router
|
||||
from danswer.server.search_backend import router as backend_router
|
||||
from danswer.server.slack_bot_management import router as slack_bot_management_router
|
||||
from danswer.server.users import router as user_router
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
@ -79,6 +80,7 @@ def get_application() -> FastAPI:
|
||||
application.include_router(user_router)
|
||||
application.include_router(credential_router)
|
||||
application.include_router(document_set_router)
|
||||
application.include_router(slack_bot_management_router)
|
||||
application.include_router(health_router)
|
||||
|
||||
application.include_router(
|
||||
|
@ -16,9 +16,11 @@ from danswer.configs.constants import QAFeedbackType
|
||||
from danswer.configs.constants import SearchFeedbackType
|
||||
from danswer.connectors.models import InputType
|
||||
from danswer.datastores.interfaces import IndexFilter
|
||||
from danswer.db.models import ChannelConfig
|
||||
from danswer.db.models import Connector
|
||||
from danswer.db.models import Credential
|
||||
from danswer.db.models import DeletionStatus
|
||||
from danswer.db.models import DocumentSet as DocumentSetDBModel
|
||||
from danswer.db.models import IndexAttempt
|
||||
from danswer.db.models import IndexingStatus
|
||||
from danswer.direct_qa.interfaces import DanswerQuote
|
||||
@ -386,3 +388,50 @@ class DocumentSet(BaseModel):
|
||||
description: str
|
||||
cc_pair_descriptors: list[ConnectorCredentialPairDescriptor]
|
||||
is_up_to_date: bool
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, document_set_model: DocumentSetDBModel) -> "DocumentSet":
|
||||
return cls(
|
||||
id=document_set_model.id,
|
||||
name=document_set_model.name,
|
||||
description=document_set_model.description,
|
||||
cc_pair_descriptors=[
|
||||
ConnectorCredentialPairDescriptor(
|
||||
id=cc_pair.id,
|
||||
name=cc_pair.name,
|
||||
connector=ConnectorSnapshot.from_connector_db_model(
|
||||
cc_pair.connector
|
||||
),
|
||||
credential=CredentialSnapshot.from_credential_db_model(
|
||||
cc_pair.credential
|
||||
),
|
||||
)
|
||||
for cc_pair in document_set_model.connector_credential_pairs
|
||||
],
|
||||
is_up_to_date=document_set_model.is_up_to_date,
|
||||
)
|
||||
|
||||
|
||||
class SlackBotTokens(BaseModel):
|
||||
bot_token: str
|
||||
app_token: str
|
||||
|
||||
|
||||
class SlackBotConfigCreationRequest(BaseModel):
|
||||
# currently, a persona is created for each slack bot config
|
||||
# in the future, `document_sets` will probably be replaced
|
||||
# by an optional `PersonaSnapshot` object. Keeping it like this
|
||||
# for now for simplicity / speed of development
|
||||
document_sets: list[int]
|
||||
channel_names: list[str]
|
||||
answer_validity_check_enabled: bool
|
||||
|
||||
|
||||
class SlackBotConfig(BaseModel):
|
||||
id: int
|
||||
# currently, a persona is created for each slack bot config
|
||||
# in the future, `document_sets` will probably be replaced
|
||||
# by an optional `PersonaSnapshot` object. Keeping it like this
|
||||
# for now for simplicity / speed of development
|
||||
document_sets: list[DocumentSet]
|
||||
channel_config: ChannelConfig
|
||||
|
159
backend/danswer/server/slack_bot_management.py
Normal file
159
backend/danswer/server/slack_bot_management.py
Normal file
@ -0,0 +1,159 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.bots.slack.config import validate_channel_names
|
||||
from danswer.bots.slack.tokens import fetch_tokens
|
||||
from danswer.bots.slack.tokens import save_tokens
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import ChannelConfig
|
||||
from danswer.db.models import User
|
||||
from danswer.db.slack_bot_config import fetch_slack_bot_configs
|
||||
from danswer.db.slack_bot_config import insert_slack_bot_config
|
||||
from danswer.db.slack_bot_config import remove_slack_bot_config
|
||||
from danswer.db.slack_bot_config import update_slack_bot_config
|
||||
from danswer.server.models import DocumentSet
|
||||
from danswer.server.models import SlackBotConfig
|
||||
from danswer.server.models import SlackBotConfigCreationRequest
|
||||
from danswer.server.models import SlackBotTokens
|
||||
|
||||
|
||||
router = APIRouter(prefix="/manage")
|
||||
|
||||
|
||||
@router.post("/admin/slack-bot/config")
|
||||
def create_slack_bot_config(
|
||||
slack_bot_config_creation_request: SlackBotConfigCreationRequest,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> SlackBotConfig:
|
||||
if not slack_bot_config_creation_request.channel_names:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Must provide at least one channel name",
|
||||
)
|
||||
|
||||
try:
|
||||
cleaned_channel_names = validate_channel_names(
|
||||
channel_names=slack_bot_config_creation_request.channel_names,
|
||||
current_slack_bot_config_id=None,
|
||||
db_session=db_session,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
channel_config: ChannelConfig = {
|
||||
"channel_names": cleaned_channel_names,
|
||||
"answer_validity_check_enabled": slack_bot_config_creation_request.answer_validity_check_enabled,
|
||||
}
|
||||
slack_bot_config_model = insert_slack_bot_config(
|
||||
document_sets=slack_bot_config_creation_request.document_sets,
|
||||
channel_config=channel_config,
|
||||
db_session=db_session,
|
||||
)
|
||||
return SlackBotConfig(
|
||||
id=slack_bot_config_model.id,
|
||||
document_sets=[
|
||||
DocumentSet.from_model(document_set)
|
||||
for document_set in slack_bot_config_model.persona.document_sets
|
||||
]
|
||||
if slack_bot_config_model.persona
|
||||
else [],
|
||||
channel_config=slack_bot_config_model.channel_config,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/admin/slack-bot/config/{slack_bot_config_id}")
|
||||
def patch_slack_bot_config(
|
||||
slack_bot_config_id: int,
|
||||
slack_bot_config_creation_request: SlackBotConfigCreationRequest,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> SlackBotConfig:
|
||||
if not slack_bot_config_creation_request.channel_names:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Must provide at least one channel name",
|
||||
)
|
||||
|
||||
try:
|
||||
cleaned_channel_names = validate_channel_names(
|
||||
channel_names=slack_bot_config_creation_request.channel_names,
|
||||
current_slack_bot_config_id=slack_bot_config_id,
|
||||
db_session=db_session,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
slack_bot_config_model = update_slack_bot_config(
|
||||
slack_bot_config_id=slack_bot_config_id,
|
||||
document_sets=slack_bot_config_creation_request.document_sets,
|
||||
channel_config={
|
||||
"channel_names": cleaned_channel_names,
|
||||
"answer_validity_check_enabled": slack_bot_config_creation_request.answer_validity_check_enabled,
|
||||
},
|
||||
db_session=db_session,
|
||||
)
|
||||
return SlackBotConfig(
|
||||
id=slack_bot_config_model.id,
|
||||
document_sets=[
|
||||
DocumentSet.from_model(document_set)
|
||||
for document_set in slack_bot_config_model.persona.document_sets
|
||||
]
|
||||
if slack_bot_config_model.persona
|
||||
else [],
|
||||
channel_config=slack_bot_config_model.channel_config,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/admin/slack-bot/config/{slack_bot_config_id}")
|
||||
def delete_slack_bot_config(
|
||||
slack_bot_config_id: int,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> None:
|
||||
remove_slack_bot_config(
|
||||
slack_bot_config_id=slack_bot_config_id, db_session=db_session
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/slack-bot/config")
|
||||
def list_slack_bot_configs(
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> list[SlackBotConfig]:
|
||||
slack_bot_config_models = fetch_slack_bot_configs(db_session=db_session)
|
||||
return [
|
||||
SlackBotConfig(
|
||||
id=slack_bot_config_model.id,
|
||||
document_sets=[
|
||||
DocumentSet.from_model(document_set)
|
||||
for document_set in slack_bot_config_model.persona.document_sets
|
||||
]
|
||||
if slack_bot_config_model.persona
|
||||
else [],
|
||||
channel_config=slack_bot_config_model.channel_config,
|
||||
)
|
||||
for slack_bot_config_model in slack_bot_config_models
|
||||
]
|
||||
|
||||
|
||||
@router.put("/admin/slack-bot/tokens")
|
||||
def put_tokens(tokens: SlackBotTokens) -> None:
|
||||
save_tokens(tokens=tokens)
|
||||
|
||||
|
||||
@router.get("/admin/slack-bot/tokens")
|
||||
def get_tokens() -> SlackBotTokens:
|
||||
try:
|
||||
return fetch_tokens()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="No tokens found")
|
188
web/src/app/admin/bot/SlackBotConfigCreationForm.tsx
Normal file
188
web/src/app/admin/bot/SlackBotConfigCreationForm.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { DocumentSet, SlackBotConfig } from "@/lib/types";
|
||||
import {
|
||||
BooleanFormField,
|
||||
TextArrayField,
|
||||
} from "@/components/admin/connectors/Field";
|
||||
import { createSlackBotConfig, updateSlackBotConfig } from "./lib";
|
||||
import { channel } from "diagnostics_channel";
|
||||
|
||||
interface SetCreationPopupProps {
|
||||
onClose: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
documentSets: DocumentSet<any, any>[];
|
||||
existingSlackBotConfig?: SlackBotConfig;
|
||||
}
|
||||
|
||||
export const SlackBotCreationForm = ({
|
||||
onClose,
|
||||
setPopup,
|
||||
documentSets,
|
||||
existingSlackBotConfig,
|
||||
}: SetCreationPopupProps) => {
|
||||
const isUpdate = existingSlackBotConfig !== undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
channel_names: existingSlackBotConfig
|
||||
? existingSlackBotConfig.channel_config.channel_names
|
||||
: ([] as string[]),
|
||||
answer_validity_check_enabled:
|
||||
existingSlackBotConfig?.channel_config
|
||||
?.answer_validity_check_enabled || false,
|
||||
document_sets: existingSlackBotConfig
|
||||
? existingSlackBotConfig.document_sets.map(
|
||||
(documentSet) => documentSet.id
|
||||
)
|
||||
: ([] as number[]),
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
channel_names: Yup.array().of(Yup.string()),
|
||||
answer_validity_check_enabled: Yup.boolean().required(),
|
||||
document_sets: Yup.array().of(Yup.number()),
|
||||
})}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
|
||||
// remove empty channel names
|
||||
const cleanedValues = {
|
||||
...values,
|
||||
channel_names: values.channel_names.filter(
|
||||
(channelName) => channelName !== ""
|
||||
),
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isUpdate) {
|
||||
response = await updateSlackBotConfig(
|
||||
existingSlackBotConfig.id,
|
||||
cleanedValues
|
||||
);
|
||||
} else {
|
||||
response = await createSlackBotConfig(cleanedValues);
|
||||
}
|
||||
formikHelpers.setSubmitting(false);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? "Successfully updated DanswerBot config!"
|
||||
: "Successfully created DanswerBot config!",
|
||||
type: "success",
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? `Error updating DanswerBot config - ${errorMsg}`
|
||||
: `Error creating DanswerBot config - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values }) => (
|
||||
<Form>
|
||||
<h2 className="text-lg font-bold mb-3">
|
||||
{isUpdate
|
||||
? "Update a DanswerBot Config"
|
||||
: "Create a new DanswerBot Config"}
|
||||
</h2>
|
||||
<TextArrayField
|
||||
name="channel_names"
|
||||
label="Channel Names:"
|
||||
values={values}
|
||||
subtext="The names of the Slack channels you want DanswerBot to assist in. For example, '#ask-danswer'."
|
||||
/>
|
||||
<div className="border-t border-gray-700 py-2" />
|
||||
<BooleanFormField
|
||||
name="answer_validity_check_enabled"
|
||||
label="Hide Non-Answers"
|
||||
subtext="If set, will only answer questions that the model determines it can answer"
|
||||
/>
|
||||
<div className="border-t border-gray-700 py-2" />
|
||||
<FieldArray
|
||||
name="document_sets"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div>
|
||||
<div>
|
||||
Document Sets:
|
||||
<br />
|
||||
<div className="text-xs">
|
||||
The document sets that DanswerBot should search
|
||||
through. If left blank, DanswerBot will search through
|
||||
all documents.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap">
|
||||
{documentSets.map((documentSet) => {
|
||||
const ind = values.document_sets.indexOf(
|
||||
documentSet.id
|
||||
);
|
||||
let isSelected = ind !== -1;
|
||||
return (
|
||||
<div
|
||||
key={documentSet.id}
|
||||
className={
|
||||
`
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
border
|
||||
border-gray-700
|
||||
w-fit
|
||||
flex
|
||||
cursor-pointer ` +
|
||||
(isSelected
|
||||
? " bg-gray-600"
|
||||
: " bg-gray-900 hover:bg-gray-700")
|
||||
}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
arrayHelpers.remove(ind);
|
||||
} else {
|
||||
arrayHelpers.push(documentSet.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="my-auto">{documentSet.name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="flex">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={
|
||||
"bg-slate-500 hover:bg-slate-700 text-white " +
|
||||
"font-bold py-2 px-4 rounded focus:outline-none " +
|
||||
"focus:shadow-outline w-full max-w-sm mx-auto"
|
||||
}
|
||||
>
|
||||
{isUpdate ? "Update!" : "Create!"}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
94
web/src/app/admin/bot/SlackBotTokensForm.tsx
Normal file
94
web/src/app/admin/bot/SlackBotTokensForm.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { Form, Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { SlackBotTokens } from "@/lib/types";
|
||||
import {
|
||||
TextArrayField,
|
||||
TextFormField,
|
||||
} from "@/components/admin/connectors/Field";
|
||||
import {
|
||||
createSlackBotConfig,
|
||||
setSlackBotTokens,
|
||||
updateSlackBotConfig,
|
||||
} from "./lib";
|
||||
|
||||
interface SlackBotTokensFormProps {
|
||||
onClose: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
existingTokens?: SlackBotTokens;
|
||||
}
|
||||
|
||||
export const SlackBotTokensForm = ({
|
||||
onClose,
|
||||
setPopup,
|
||||
existingTokens,
|
||||
}: SlackBotTokensFormProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-800 p-6 rounded border border-gray-700 shadow-lg relative w-1/2 text-sm"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Formik
|
||||
initialValues={existingTokens || { app_token: "", bot_token: "" }}
|
||||
validationSchema={Yup.object().shape({
|
||||
channel_names: Yup.array().of(Yup.string().required()),
|
||||
document_sets: Yup.array().of(Yup.number()),
|
||||
})}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
const response = await setSlackBotTokens(values);
|
||||
formikHelpers.setSubmitting(false);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: "Successfully set Slack tokens!",
|
||||
type: "success",
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
const errorMsg = await response.text();
|
||||
setPopup({
|
||||
message: `Error setting Slack tokens - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<h2 className="text-lg font-bold mb-3">Set Slack Bot Tokens</h2>
|
||||
<TextFormField
|
||||
name="bot_token"
|
||||
label="Slack Bot Token"
|
||||
type="password"
|
||||
/>
|
||||
<TextFormField
|
||||
name="app_token"
|
||||
label="Slack App Token"
|
||||
type="password"
|
||||
/>
|
||||
<div className="flex">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={
|
||||
"bg-slate-500 hover:bg-slate-700 text-white " +
|
||||
"font-bold py-2 px-4 rounded focus:outline-none " +
|
||||
"focus:shadow-outline w-full max-w-sm mx-auto"
|
||||
}
|
||||
>
|
||||
Set Tokens
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
23
web/src/app/admin/bot/hooks.ts
Normal file
23
web/src/app/admin/bot/hooks.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SlackBotConfig, SlackBotTokens } from "@/lib/types";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
export const useSlackBotConfigs = () => {
|
||||
const url = "/api/manage/admin/slack-bot/config";
|
||||
const swrResponse = useSWR<SlackBotConfig[]>(url, errorHandlingFetcher);
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshSlackBotConfigs: () => mutate(url),
|
||||
};
|
||||
};
|
||||
|
||||
export const useSlackBotTokens = () => {
|
||||
const url = "/api/manage/admin/slack-bot/tokens";
|
||||
const swrResponse = useSWR<SlackBotTokens>(url, errorHandlingFetcher);
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshSlackBotTokens: () => mutate(url),
|
||||
};
|
||||
};
|
51
web/src/app/admin/bot/lib.ts
Normal file
51
web/src/app/admin/bot/lib.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { ChannelConfig, SlackBotTokens } from "@/lib/types";
|
||||
|
||||
interface SlackBotConfigCreationRequest {
|
||||
document_sets: number[];
|
||||
channel_names: string[];
|
||||
answer_validity_check_enabled: boolean;
|
||||
}
|
||||
|
||||
export const createSlackBotConfig = async (
|
||||
creationRequest: SlackBotConfigCreationRequest
|
||||
) => {
|
||||
return fetch("/api/manage/admin/slack-bot/config", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(creationRequest),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSlackBotConfig = async (
|
||||
id: number,
|
||||
creationRequest: SlackBotConfigCreationRequest
|
||||
) => {
|
||||
return fetch(`/api/manage/admin/slack-bot/config/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(creationRequest),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteSlackBotConfig = async (id: number) => {
|
||||
return fetch(`/api/manage/admin/slack-bot/config/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const setSlackBotTokens = async (slackBotTokens: SlackBotTokens) => {
|
||||
return fetch(`/api/manage/admin/slack-bot/tokens`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(slackBotTokens),
|
||||
});
|
||||
};
|
300
web/src/app/admin/bot/page.tsx
Normal file
300
web/src/app/admin/bot/page.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { PageSelector } from "@/components/PageSelector";
|
||||
import { BasicTable } from "@/components/admin/connectors/BasicTable";
|
||||
import {
|
||||
BookmarkIcon,
|
||||
CPUIcon,
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { DocumentSet, SlackBotConfig } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import { useSlackBotConfigs, useSlackBotTokens } from "./hooks";
|
||||
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SlackBotCreationForm } from "./SlackBotConfigCreationForm";
|
||||
import { deleteSlackBotConfig } from "./lib";
|
||||
import { SlackBotTokensForm } from "./SlackBotTokensForm";
|
||||
import { useDocumentSets } from "../documents/sets/hooks";
|
||||
|
||||
const numToDisplay = 50;
|
||||
|
||||
const EditRow = ({
|
||||
existingSlackBotConfig,
|
||||
setPopup,
|
||||
documentSets,
|
||||
refreshSlackBotConfigs,
|
||||
}: {
|
||||
existingSlackBotConfig: SlackBotConfig;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
documentSets: DocumentSet<any, any>[];
|
||||
refreshSlackBotConfigs: () => void;
|
||||
}) => {
|
||||
const [isEditPopupOpen, setEditPopupOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{isEditPopupOpen && (
|
||||
<SlackBotCreationForm
|
||||
onClose={() => {
|
||||
setEditPopupOpen(false);
|
||||
refreshSlackBotConfigs();
|
||||
}}
|
||||
setPopup={setPopup}
|
||||
documentSets={documentSets}
|
||||
existingSlackBotConfig={existingSlackBotConfig}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="cursor-pointer my-auto"
|
||||
onClick={() => setEditPopupOpen(true)}
|
||||
>
|
||||
<EditIcon />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DocumentFeedbackTableProps {
|
||||
slackBotConfigs: SlackBotConfig[];
|
||||
documentSets: DocumentSet<any, any>[];
|
||||
refresh: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
}
|
||||
|
||||
const SlackBotConfigsTable = ({
|
||||
slackBotConfigs,
|
||||
documentSets,
|
||||
refresh,
|
||||
setPopup,
|
||||
}: DocumentFeedbackTableProps) => {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// sort by name for consistent ordering
|
||||
slackBotConfigs.sort((a, b) => {
|
||||
if (a.id < b.id) {
|
||||
return -1;
|
||||
} else if (a.id > b.id) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasicTable
|
||||
columns={[
|
||||
{
|
||||
header: "Channels",
|
||||
key: "channels",
|
||||
},
|
||||
{
|
||||
header: "Document Sets",
|
||||
key: "document_sets",
|
||||
},
|
||||
{
|
||||
header: "Hide Non-Answers",
|
||||
key: "answer_validity_check_enabled",
|
||||
},
|
||||
{
|
||||
header: "Delete",
|
||||
key: "delete",
|
||||
width: "50px",
|
||||
},
|
||||
]}
|
||||
data={slackBotConfigs
|
||||
.slice((page - 1) * numToDisplay, page * numToDisplay)
|
||||
.map((slackBotConfig) => {
|
||||
return {
|
||||
channels: (
|
||||
<div className="flex gap-x-2">
|
||||
<EditRow
|
||||
existingSlackBotConfig={slackBotConfig}
|
||||
setPopup={setPopup}
|
||||
refreshSlackBotConfigs={refresh}
|
||||
documentSets={documentSets}
|
||||
/>
|
||||
<div className="my-auto">
|
||||
{slackBotConfig.channel_config.channel_names
|
||||
.map((channel_name) => `#${channel_name}`)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
document_sets: (
|
||||
<div>
|
||||
{slackBotConfig.document_sets
|
||||
.map((documentSet) => documentSet.name)
|
||||
.join(", ")}
|
||||
</div>
|
||||
),
|
||||
answer_validity_check_enabled: slackBotConfig.channel_config
|
||||
.answer_validity_check_enabled ? (
|
||||
<div className="text-gray-300">Yes</div>
|
||||
) : (
|
||||
<div className="text-gray-300">No</div>
|
||||
),
|
||||
delete: (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
const response = await deleteSlackBotConfig(
|
||||
slackBotConfig.id
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `Slack bot config "${slackBotConfig.id}" deleted`,
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = await response.text();
|
||||
setPopup({
|
||||
message: `Failed to delete Slack bot config - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<div className="mt-3 flex">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
totalPages={Math.ceil(slackBotConfigs.length / numToDisplay)}
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Main = () => {
|
||||
const [slackBotConfigModalIsOpen, setSlackBotConfigModalIsOpen] =
|
||||
useState(false);
|
||||
const [slackBotTokensModalIsOpen, setSlackBotTokensModalIsOpen] =
|
||||
useState(false);
|
||||
const { popup, setPopup } = usePopup();
|
||||
const {
|
||||
data: slackBotConfigs,
|
||||
isLoading: isSlackBotConfigsLoading,
|
||||
error: slackBotConfigsError,
|
||||
refreshSlackBotConfigs,
|
||||
} = useSlackBotConfigs();
|
||||
const {
|
||||
data: documentSets,
|
||||
isLoading: isDocumentSetsLoading,
|
||||
error: documentSetsError,
|
||||
} = useDocumentSets();
|
||||
|
||||
const { data: slackBotTokens, refreshSlackBotTokens } = useSlackBotTokens();
|
||||
|
||||
if (isSlackBotConfigsLoading || isDocumentSetsLoading) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
if (slackBotConfigsError || !slackBotConfigs) {
|
||||
return <div>Error: {slackBotConfigsError}</div>;
|
||||
}
|
||||
|
||||
if (documentSetsError || !documentSets) {
|
||||
return <div>Error: {documentSetsError}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{popup}
|
||||
|
||||
<h2 className="text-lg font-bold mb-2">Step 1: Configure Slack Tokens</h2>
|
||||
{!slackBotTokens ? (
|
||||
<SlackBotTokensForm
|
||||
onClose={() => refreshSlackBotTokens()}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm italic">Tokens saved!</div>
|
||||
<Button
|
||||
onClick={() => setSlackBotTokensModalIsOpen(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Edit Tokens
|
||||
</Button>
|
||||
{slackBotTokensModalIsOpen && (
|
||||
<SlackBotTokensForm
|
||||
onClose={() => {
|
||||
refreshSlackBotTokens();
|
||||
setSlackBotTokensModalIsOpen(false);
|
||||
}}
|
||||
setPopup={setPopup}
|
||||
existingTokens={slackBotTokens}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{slackBotTokens && (
|
||||
<>
|
||||
<h2 className="text-lg font-bold mb-2 mt-4">
|
||||
Step 2: Setup DanswerBot
|
||||
</h2>
|
||||
<div className="text-sm mb-3">
|
||||
Configure Danswer to automatically answer questions in Slack
|
||||
channels.
|
||||
</div>
|
||||
|
||||
<div className="mb-2"></div>
|
||||
|
||||
<div className="flex mb-3">
|
||||
<Button
|
||||
className="ml-2 my-auto"
|
||||
onClick={() => setSlackBotConfigModalIsOpen(true)}
|
||||
>
|
||||
New Slack Bot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SlackBotConfigsTable
|
||||
slackBotConfigs={slackBotConfigs}
|
||||
documentSets={documentSets}
|
||||
refresh={refreshSlackBotConfigs}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
|
||||
{slackBotConfigModalIsOpen && (
|
||||
<SlackBotCreationForm
|
||||
documentSets={documentSets}
|
||||
onClose={() => {
|
||||
refreshSlackBotConfigs();
|
||||
setSlackBotConfigModalIsOpen(false);
|
||||
}}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
|
||||
<CPUIcon size={32} />
|
||||
<h1 className="text-3xl font-bold pl-2">Slack Bot Configuration</h1>
|
||||
</div>
|
||||
|
||||
<Main />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@ -1,10 +1,13 @@
|
||||
import { fetcher } from "@/lib/fetcher";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { DocumentSet } from "@/lib/types";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
export const useDocumentSets = () => {
|
||||
const url = "/api/manage/document-set";
|
||||
const swrResponse = useSWR<DocumentSet<any, any>[]>(url, fetcher);
|
||||
const swrResponse = useSWR<DocumentSet<any, any>[]>(
|
||||
url,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
UsersIcon,
|
||||
ThumbsUpIcon,
|
||||
BookmarkIcon,
|
||||
CPUIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { DISABLE_AUTH } from "@/lib/constants";
|
||||
import { getCurrentUserSS } from "@/lib/userSS";
|
||||
@ -44,7 +45,7 @@ export default async function AdminLayout({
|
||||
return (
|
||||
<div>
|
||||
<Header user={user} />
|
||||
<div className="bg-gray-900 pt-8 flex">
|
||||
<div className="bg-gray-900 pt-8 pb-8 flex">
|
||||
<Sidebar
|
||||
title="Connector"
|
||||
collections={[
|
||||
@ -244,6 +245,20 @@ export default async function AdminLayout({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Bots",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<CPUIcon size={18} />
|
||||
<div className="ml-1">Slack Bot</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/bot",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">
|
||||
|
@ -87,20 +87,22 @@ export const BooleanFormField = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface TextArrayFieldProps {
|
||||
interface TextArrayFieldProps<T extends Yup.AnyObject> {
|
||||
name: string;
|
||||
label: string;
|
||||
values: T;
|
||||
subtext?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function TextArrayFieldBuilder<T extends Yup.AnyObject>({
|
||||
export function TextArrayField<T extends Yup.AnyObject>({
|
||||
name,
|
||||
label,
|
||||
values,
|
||||
subtext,
|
||||
type = "text",
|
||||
}: TextArrayFieldProps): FormBodyBuilder<T> {
|
||||
const TextArrayField: FormBodyBuilder<T> = (values) => (
|
||||
type,
|
||||
}: TextArrayFieldProps<T>) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label htmlFor={name} className="block">
|
||||
{label}
|
||||
@ -153,5 +155,20 @@ export function TextArrayFieldBuilder<T extends Yup.AnyObject>({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return TextArrayField;
|
||||
}
|
||||
|
||||
interface TextArrayFieldBuilderProps<T extends Yup.AnyObject> {
|
||||
name: string;
|
||||
label: string;
|
||||
subtext?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function TextArrayFieldBuilder<T extends Yup.AnyObject>(
|
||||
props: TextArrayFieldBuilderProps<T>
|
||||
): FormBodyBuilder<T> {
|
||||
const _TextArrayField: FormBodyBuilder<T> = (values) => (
|
||||
<TextArrayField {...props} values={values} />
|
||||
);
|
||||
return _TextArrayField;
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ import {
|
||||
FiZoomIn,
|
||||
FiCopy,
|
||||
FiBookmark,
|
||||
FiCpu,
|
||||
} from "react-icons/fi";
|
||||
import { SiBookstack } from "react-icons/si";
|
||||
import Image from "next/image";
|
||||
@ -251,6 +252,13 @@ export const BookmarkIcon = ({
|
||||
return <FiBookmark size={size} className={className} />;
|
||||
};
|
||||
|
||||
export const CPUIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return <FiCpu size={size} className={className} />;
|
||||
};
|
||||
|
||||
//
|
||||
// COMPANY LOGOS
|
||||
//
|
||||
|
@ -1 +1,25 @@
|
||||
export const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
class FetchError extends Error {
|
||||
status: number;
|
||||
info: any;
|
||||
|
||||
constructor(message: string, status: number, info: any) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.info = info;
|
||||
}
|
||||
}
|
||||
|
||||
export const errorHandlingFetcher = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const error = new FetchError(
|
||||
"An error occurred while fetching the data.",
|
||||
res.status,
|
||||
await res.json()
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
@ -228,3 +228,21 @@ export interface DocumentSet<ConnectorType, CredentialType> {
|
||||
cc_pair_descriptors: CCPairDescriptor<ConnectorType, CredentialType>[];
|
||||
is_up_to_date: boolean;
|
||||
}
|
||||
|
||||
// SLACK BOT CONFIGS
|
||||
export interface ChannelConfig {
|
||||
channel_names: string[];
|
||||
answer_validity_check_enabled?: boolean;
|
||||
team_members?: string[];
|
||||
}
|
||||
|
||||
export interface SlackBotConfig {
|
||||
id: number;
|
||||
document_sets: DocumentSet<any, any>[];
|
||||
channel_config: ChannelConfig;
|
||||
}
|
||||
|
||||
export interface SlackBotTokens {
|
||||
bot_token: string;
|
||||
app_token: string;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user