add default slack channel config

This commit is contained in:
pablodanswer 2025-02-05 14:26:26 -08:00
parent 78153e5012
commit 7153cb09f1
24 changed files with 658 additions and 349 deletions

View File

@ -0,0 +1,76 @@
"""add default slack channel config
Revision ID: eaa3b5593925
Revises: 98a5008d8711
Create Date: 2025-02-03 18:07:56.552526
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "eaa3b5593925"
down_revision = "98a5008d8711"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add is_default column
op.add_column(
"slack_channel_config",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index(
"ix_slack_channel_config_slack_bot_id_default",
"slack_channel_config",
["slack_bot_id", "is_default"],
unique=True,
postgresql_where=sa.text("is_default IS TRUE"),
)
# Create default channel configs for existing slack bots without one
conn = op.get_bind()
slack_bots = conn.execute(sa.text("SELECT id FROM slack_bot")).fetchall()
for slack_bot in slack_bots:
slack_bot_id = slack_bot[0]
existing_default = conn.execute(
sa.text(
"SELECT id FROM slack_channel_config WHERE slack_bot_id = :bot_id AND is_default = TRUE"
),
{"bot_id": slack_bot_id},
).fetchone()
if not existing_default:
conn.execute(
sa.text(
"""
INSERT INTO slack_channel_config (
slack_bot_id, persona_id, channel_config, enable_auto_filters, is_default
) VALUES (
:bot_id, NULL,
'{"channel_name": null, "respond_member_group_list": [], "answer_filters": [], "follow_up_tags": []}',
FALSE, TRUE
)
"""
),
{"bot_id": slack_bot_id},
)
def downgrade() -> None:
# Delete default slack channel configs
conn = op.get_bind()
conn.execute(sa.text("DELETE FROM slack_channel_config WHERE is_default = TRUE"))
# Remove index
op.drop_index(
"ix_slack_channel_config_slack_bot_id_default",
table_name="slack_channel_config",
)
# Remove is_default column
op.drop_column("slack_channel_config", "is_default")

View File

@ -80,7 +80,7 @@ def oneoff_standard_answers(
def _handle_standard_answers( def _handle_standard_answers(
message_info: SlackMessageInfo, message_info: SlackMessageInfo,
receiver_ids: list[str] | None, receiver_ids: list[str] | None,
slack_channel_config: SlackChannelConfig | None, slack_channel_config: SlackChannelConfig,
prompt: Prompt | None, prompt: Prompt | None,
logger: OnyxLoggingAdapter, logger: OnyxLoggingAdapter,
client: WebClient, client: WebClient,
@ -94,13 +94,10 @@ def _handle_standard_answers(
Returns True if standard answers are found to match the user's message and therefore, Returns True if standard answers are found to match the user's message and therefore,
we still need to respond to the users. we still need to respond to the users.
""" """
# if no channel config, then no standard answers are configured
if not slack_channel_config:
return False
slack_thread_id = message_info.thread_to_respond slack_thread_id = message_info.thread_to_respond
configured_standard_answer_categories = ( configured_standard_answer_categories = (
slack_channel_config.standard_answer_categories if slack_channel_config else [] slack_channel_config.standard_answer_categories
) )
configured_standard_answers = set( configured_standard_answers = set(
[ [

View File

@ -1 +1,2 @@
SLACK_BOT_PERSONA_PREFIX = "__slack_bot_persona__" SLACK_BOT_PERSONA_PREFIX = "__slack_bot_persona__"
DEFAULT_PERSONA_SLACK_CHANNEL_NAME = "DEFAULT_SLACK_CHANNEL"

View File

@ -1716,7 +1716,7 @@ class ChannelConfig(TypedDict):
"""NOTE: is a `TypedDict` so it can be used as a type hint for a JSONB column """NOTE: is a `TypedDict` so it can be used as a type hint for a JSONB column
in Postgres""" in Postgres"""
channel_name: str channel_name: str | None # None for default channel config
respond_tag_only: NotRequired[bool] # defaults to False respond_tag_only: NotRequired[bool] # defaults to False
respond_to_bots: NotRequired[bool] # defaults to False respond_to_bots: NotRequired[bool] # defaults to False
respond_member_group_list: NotRequired[list[str]] respond_member_group_list: NotRequired[list[str]]
@ -1737,7 +1737,6 @@ class SlackChannelConfig(Base):
persona_id: Mapped[int | None] = mapped_column( persona_id: Mapped[int | None] = mapped_column(
ForeignKey("persona.id"), nullable=True ForeignKey("persona.id"), nullable=True
) )
# JSON for flexibility. Contains things like: channel name, team members, etc.
channel_config: Mapped[ChannelConfig] = mapped_column( channel_config: Mapped[ChannelConfig] = mapped_column(
postgresql.JSONB(), nullable=False postgresql.JSONB(), nullable=False
) )
@ -1746,6 +1745,8 @@ class SlackChannelConfig(Base):
Boolean, nullable=False, default=False Boolean, nullable=False, default=False
) )
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
persona: Mapped[Persona | None] = relationship("Persona") persona: Mapped[Persona | None] = relationship("Persona")
slack_bot: Mapped["SlackBot"] = relationship( slack_bot: Mapped["SlackBot"] = relationship(
"SlackBot", "SlackBot",
@ -1757,6 +1758,21 @@ class SlackChannelConfig(Base):
back_populates="slack_channel_configs", back_populates="slack_channel_configs",
) )
__table_args__ = (
UniqueConstraint(
"slack_bot_id",
"is_default",
name="uq_slack_channel_config_slack_bot_id_default",
),
Index(
"ix_slack_channel_config_slack_bot_id_default",
"slack_bot_id",
"is_default",
unique=True,
postgresql_where=(is_default is True), # type: ignore
),
)
class SlackBot(Base): class SlackBot(Base):
__tablename__ = "slack_bot" __tablename__ = "slack_bot"

View File

@ -74,3 +74,15 @@ def remove_slack_bot(
def fetch_slack_bots(db_session: Session) -> Sequence[SlackBot]: def fetch_slack_bots(db_session: Session) -> Sequence[SlackBot]:
return db_session.scalars(select(SlackBot)).all() return db_session.scalars(select(SlackBot)).all()
def fetch_slack_bot_tokens(
db_session: Session, slack_bot_id: int
) -> dict[str, str] | None:
slack_bot = db_session.scalar(select(SlackBot).where(SlackBot.id == slack_bot_id))
if not slack_bot:
return None
return {
"app_token": slack_bot.app_token,
"bot_token": slack_bot.bot_token,
}

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from onyx.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT from onyx.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT
from onyx.context.search.enums import RecencyBiasSetting from onyx.context.search.enums import RecencyBiasSetting
from onyx.db.constants import DEFAULT_PERSONA_SLACK_CHANNEL_NAME
from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX
from onyx.db.models import ChannelConfig from onyx.db.models import ChannelConfig
from onyx.db.models import Persona from onyx.db.models import Persona
@ -22,8 +23,8 @@ from onyx.utils.variable_functionality import (
) )
def _build_persona_name(channel_name: str) -> str: def _build_persona_name(channel_name: str | None) -> str:
return f"{SLACK_BOT_PERSONA_PREFIX}{channel_name}" return f"{SLACK_BOT_PERSONA_PREFIX}{channel_name if channel_name else DEFAULT_PERSONA_SLACK_CHANNEL_NAME}"
def _cleanup_relationships(db_session: Session, persona_id: int) -> None: def _cleanup_relationships(db_session: Session, persona_id: int) -> None:
@ -40,7 +41,7 @@ def _cleanup_relationships(db_session: Session, persona_id: int) -> None:
def create_slack_channel_persona( def create_slack_channel_persona(
db_session: Session, db_session: Session,
channel_name: str, channel_name: str | None,
document_set_ids: list[int], document_set_ids: list[int],
existing_persona_id: int | None = None, existing_persona_id: int | None = None,
num_chunks: float = MAX_CHUNKS_FED_TO_CHAT, num_chunks: float = MAX_CHUNKS_FED_TO_CHAT,
@ -90,6 +91,7 @@ def insert_slack_channel_config(
channel_config: ChannelConfig, channel_config: ChannelConfig,
standard_answer_category_ids: list[int], standard_answer_category_ids: list[int],
enable_auto_filters: bool, enable_auto_filters: bool,
is_default: bool = False,
) -> SlackChannelConfig: ) -> SlackChannelConfig:
versioned_fetch_standard_answer_categories_by_ids = ( versioned_fetch_standard_answer_categories_by_ids = (
fetch_versioned_implementation_with_fallback( fetch_versioned_implementation_with_fallback(
@ -115,12 +117,26 @@ def insert_slack_channel_config(
f"Some or all categories with ids {standard_answer_category_ids} do not exist" f"Some or all categories with ids {standard_answer_category_ids} do not exist"
) )
if is_default:
existing_default = db_session.scalar(
select(SlackChannelConfig).where(
SlackChannelConfig.slack_bot_id == slack_bot_id,
SlackChannelConfig.is_default is True, # type: ignore
)
)
if existing_default:
raise ValueError("A default config already exists for this Slack bot.")
else:
if "channel_name" not in channel_config:
raise ValueError("Channel name is required for non-default configs.")
slack_channel_config = SlackChannelConfig( slack_channel_config = SlackChannelConfig(
slack_bot_id=slack_bot_id, slack_bot_id=slack_bot_id,
persona_id=persona_id, persona_id=persona_id,
channel_config=channel_config, channel_config=channel_config,
standard_answer_categories=existing_standard_answer_categories, standard_answer_categories=existing_standard_answer_categories,
enable_auto_filters=enable_auto_filters, enable_auto_filters=enable_auto_filters,
is_default=is_default,
) )
db_session.add(slack_channel_config) db_session.add(slack_channel_config)
db_session.commit() db_session.commit()
@ -164,12 +180,7 @@ def update_slack_channel_config(
f"Some or all categories with ids {standard_answer_category_ids} do not exist" f"Some or all categories with ids {standard_answer_category_ids} do not exist"
) )
# get the existing persona id before updating the object
existing_persona_id = slack_channel_config.persona_id
# update the config # update the config
# NOTE: need to do this before cleaning up the old persona or else we
# will encounter `violates foreign key constraint` errors
slack_channel_config.persona_id = persona_id slack_channel_config.persona_id = persona_id
slack_channel_config.channel_config = channel_config slack_channel_config.channel_config = channel_config
slack_channel_config.standard_answer_categories = list( slack_channel_config.standard_answer_categories = list(
@ -177,20 +188,6 @@ def update_slack_channel_config(
) )
slack_channel_config.enable_auto_filters = enable_auto_filters slack_channel_config.enable_auto_filters = enable_auto_filters
# if the persona has changed, then clean up the old persona
if persona_id != existing_persona_id and existing_persona_id:
existing_persona = db_session.scalar(
select(Persona).where(Persona.id == existing_persona_id)
)
# if the existing persona was one created just for use with this Slack channel,
# then clean it up
if existing_persona and existing_persona.name.startswith(
SLACK_BOT_PERSONA_PREFIX
):
_cleanup_relationships(
db_session=db_session, persona_id=existing_persona_id
)
db_session.commit() db_session.commit()
return slack_channel_config return slack_channel_config
@ -253,3 +250,32 @@ def fetch_slack_channel_config(
SlackChannelConfig.id == slack_channel_config_id SlackChannelConfig.id == slack_channel_config_id
) )
) )
def fetch_slack_channel_config_for_channel_or_default(
db_session: Session, slack_bot_id: int, channel_name: str | None
) -> SlackChannelConfig | None:
# attempt to find channel-specific config first
if channel_name:
sc_config = db_session.scalar(
select(SlackChannelConfig).where(
SlackChannelConfig.slack_bot_id == slack_bot_id,
SlackChannelConfig.channel_config["channel_name"].astext
== channel_name,
)
)
else:
sc_config = None
if sc_config:
return sc_config
# if none found, see if there is a default
default_sc = db_session.scalar(
select(SlackChannelConfig).where(
SlackChannelConfig.slack_bot_id == slack_bot_id,
SlackChannelConfig.is_default == True, # noqa: E712
)
)
return default_sc

View File

@ -3,9 +3,11 @@ import os
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from onyx.db.models import SlackChannelConfig from onyx.db.models import SlackChannelConfig
from onyx.db.slack_channel_config import (
fetch_slack_channel_config_for_channel_or_default,
)
from onyx.db.slack_channel_config import fetch_slack_channel_configs from onyx.db.slack_channel_config import fetch_slack_channel_configs
VALID_SLACK_FILTERS = [ VALID_SLACK_FILTERS = [
"answerable_prefilter", "answerable_prefilter",
"well_answered_postfilter", "well_answered_postfilter",
@ -17,18 +19,16 @@ def get_slack_channel_config_for_bot_and_channel(
db_session: Session, db_session: Session,
slack_bot_id: int, slack_bot_id: int,
channel_name: str | None, channel_name: str | None,
) -> SlackChannelConfig | None: ) -> SlackChannelConfig:
if not channel_name: slack_bot_config = fetch_slack_channel_config_for_channel_or_default(
return None db_session=db_session, slack_bot_id=slack_bot_id, channel_name=channel_name
slack_bot_configs = fetch_slack_channel_configs(
db_session=db_session, slack_bot_id=slack_bot_id
) )
for config in slack_bot_configs: if not slack_bot_config:
if channel_name in config.channel_config["channel_name"]: raise ValueError(
return config "No default configuration has been set for this Slack bot. This should not be possible."
)
return None return slack_bot_config
def validate_channel_name( def validate_channel_name(

View File

@ -106,7 +106,7 @@ def remove_scheduled_feedback_reminder(
def handle_message( def handle_message(
message_info: SlackMessageInfo, message_info: SlackMessageInfo,
slack_channel_config: SlackChannelConfig | None, slack_channel_config: SlackChannelConfig,
client: WebClient, client: WebClient,
feedback_reminder_id: str | None, feedback_reminder_id: str | None,
tenant_id: str | None, tenant_id: str | None,

View File

@ -64,7 +64,7 @@ def rate_limits(
def handle_regular_answer( def handle_regular_answer(
message_info: SlackMessageInfo, message_info: SlackMessageInfo,
slack_channel_config: SlackChannelConfig | None, slack_channel_config: SlackChannelConfig,
receiver_ids: list[str] | None, receiver_ids: list[str] | None,
client: WebClient, client: WebClient,
channel: str, channel: str,
@ -76,7 +76,7 @@ def handle_regular_answer(
should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS, should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS,
disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER, disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER,
) -> bool: ) -> bool:
channel_conf = slack_channel_config.channel_config if slack_channel_config else None channel_conf = slack_channel_config.channel_config
messages = message_info.thread_messages messages = message_info.thread_messages
@ -92,7 +92,7 @@ def handle_regular_answer(
prompt = None prompt = None
# If no persona is specified, use the default search based persona # If no persona is specified, use the default search based persona
# This way slack flow always has a persona # This way slack flow always has a persona
persona = slack_channel_config.persona if slack_channel_config else None persona = slack_channel_config.persona
if not persona: if not persona:
with get_session_with_tenant(tenant_id) as db_session: with get_session_with_tenant(tenant_id) as db_session:
persona = get_persona_by_id(DEFAULT_PERSONA_ID, user, db_session) persona = get_persona_by_id(DEFAULT_PERSONA_ID, user, db_session)
@ -134,11 +134,7 @@ def handle_regular_answer(
single_message_history = slackify_message_thread(history_messages) or None single_message_history = slackify_message_thread(history_messages) or None
bypass_acl = False bypass_acl = False
if ( if slack_channel_config.persona and slack_channel_config.persona.document_sets:
slack_channel_config
and slack_channel_config.persona
and slack_channel_config.persona.document_sets
):
# For Slack channels, use the full document set, admin will be warned when configuring it # For Slack channels, use the full document set, admin will be warned when configuring it
# with non-public document sets # with non-public document sets
bypass_acl = True bypass_acl = True
@ -190,11 +186,7 @@ def handle_regular_answer(
# auto_detect_filters = ( # auto_detect_filters = (
# persona.llm_filter_extraction if persona is not None else True # persona.llm_filter_extraction if persona is not None else True
# ) # )
auto_detect_filters = ( auto_detect_filters = slack_channel_config.enable_auto_filters
slack_channel_config.enable_auto_filters
if slack_channel_config is not None
else False
)
retrieval_details = RetrievalDetails( retrieval_details = RetrievalDetails(
run_search=OptionalSearchSetting.ALWAYS, run_search=OptionalSearchSetting.ALWAYS,
real_time=False, real_time=False,

View File

@ -14,7 +14,7 @@ logger = setup_logger()
def handle_standard_answers( def handle_standard_answers(
message_info: SlackMessageInfo, message_info: SlackMessageInfo,
receiver_ids: list[str] | None, receiver_ids: list[str] | None,
slack_channel_config: SlackChannelConfig | None, slack_channel_config: SlackChannelConfig,
prompt: Prompt | None, prompt: Prompt | None,
logger: OnyxLoggingAdapter, logger: OnyxLoggingAdapter,
client: WebClient, client: WebClient,
@ -40,7 +40,7 @@ def handle_standard_answers(
def _handle_standard_answers( def _handle_standard_answers(
message_info: SlackMessageInfo, message_info: SlackMessageInfo,
receiver_ids: list[str] | None, receiver_ids: list[str] | None,
slack_channel_config: SlackChannelConfig | None, slack_channel_config: SlackChannelConfig,
prompt: Prompt | None, prompt: Prompt | None,
logger: OnyxLoggingAdapter, logger: OnyxLoggingAdapter,
client: WebClient, client: WebClient,

View File

@ -790,8 +790,7 @@ def process_message(
# Be careful about this default, don't want to accidentally spam every channel # Be careful about this default, don't want to accidentally spam every channel
# Users should be able to DM slack bot in their private channels though # Users should be able to DM slack bot in their private channels though
if ( if (
slack_channel_config is None not respond_every_channel
and not respond_every_channel
# Can't have configs for DMs so don't toss them out # Can't have configs for DMs so don't toss them out
and not is_dm and not is_dm
# If /OnyxBot (is_bot_msg) or @OnyxBot (bypass_filters) # If /OnyxBot (is_bot_msg) or @OnyxBot (bypass_filters)
@ -801,8 +800,7 @@ def process_message(
return return
follow_up = bool( follow_up = bool(
slack_channel_config slack_channel_config.channel_config
and slack_channel_config.channel_config
and slack_channel_config.channel_config.get("follow_up_tags") and slack_channel_config.channel_config.get("follow_up_tags")
is not None is not None
) )

View File

@ -215,6 +215,7 @@ class SlackChannelConfig(BaseModel):
# XXX this is going away soon # XXX this is going away soon
standard_answer_categories: list[StandardAnswerCategory] standard_answer_categories: list[StandardAnswerCategory]
enable_auto_filters: bool enable_auto_filters: bool
is_default: bool
@classmethod @classmethod
def from_model( def from_model(
@ -237,6 +238,7 @@ class SlackChannelConfig(BaseModel):
for standard_answer_category_model in slack_channel_config_model.standard_answer_categories for standard_answer_category_model in slack_channel_config_model.standard_answer_categories
], ],
enable_auto_filters=slack_channel_config_model.enable_auto_filters, enable_auto_filters=slack_channel_config_model.enable_auto_filters,
is_default=slack_channel_config_model.is_default,
) )
@ -279,3 +281,8 @@ class AllUsersResponse(BaseModel):
accepted_pages: int accepted_pages: int
invited_pages: int invited_pages: int
slack_users_pages: int slack_users_pages: int
class SlackChannel(BaseModel):
id: str
name: str

View File

@ -1,6 +1,10 @@
from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends from fastapi import Depends
from fastapi import HTTPException from fastapi import HTTPException
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user from onyx.auth.users import current_admin_user
@ -12,6 +16,7 @@ from onyx.db.models import ChannelConfig
from onyx.db.models import User from onyx.db.models import User
from onyx.db.persona import get_persona_by_id from onyx.db.persona import get_persona_by_id
from onyx.db.slack_bot import fetch_slack_bot from onyx.db.slack_bot import fetch_slack_bot
from onyx.db.slack_bot import fetch_slack_bot_tokens
from onyx.db.slack_bot import fetch_slack_bots from onyx.db.slack_bot import fetch_slack_bots
from onyx.db.slack_bot import insert_slack_bot from onyx.db.slack_bot import insert_slack_bot
from onyx.db.slack_bot import remove_slack_bot from onyx.db.slack_bot import remove_slack_bot
@ -25,6 +30,7 @@ from onyx.db.slack_channel_config import update_slack_channel_config
from onyx.onyxbot.slack.config import validate_channel_name from onyx.onyxbot.slack.config import validate_channel_name
from onyx.server.manage.models import SlackBot from onyx.server.manage.models import SlackBot
from onyx.server.manage.models import SlackBotCreationRequest from onyx.server.manage.models import SlackBotCreationRequest
from onyx.server.manage.models import SlackChannel
from onyx.server.manage.models import SlackChannelConfig from onyx.server.manage.models import SlackChannelConfig
from onyx.server.manage.models import SlackChannelConfigCreationRequest from onyx.server.manage.models import SlackChannelConfigCreationRequest
from onyx.server.manage.validate_tokens import validate_app_token from onyx.server.manage.validate_tokens import validate_app_token
@ -48,12 +54,6 @@ def _form_channel_config(
answer_filters = slack_channel_config_creation_request.answer_filters answer_filters = slack_channel_config_creation_request.answer_filters
follow_up_tags = slack_channel_config_creation_request.follow_up_tags follow_up_tags = slack_channel_config_creation_request.follow_up_tags
if not raw_channel_name:
raise HTTPException(
status_code=400,
detail="Must provide at least one channel name",
)
try: try:
cleaned_channel_name = validate_channel_name( cleaned_channel_name = validate_channel_name(
db_session=db_session, db_session=db_session,
@ -108,6 +108,12 @@ def create_slack_channel_config(
current_slack_channel_config_id=None, current_slack_channel_config_id=None,
) )
if channel_config["channel_name"] is None:
raise HTTPException(
status_code=400,
detail="Channel name is required",
)
persona_id = None persona_id = None
if slack_channel_config_creation_request.persona_id is not None: if slack_channel_config_creation_request.persona_id is not None:
persona_id = slack_channel_config_creation_request.persona_id persona_id = slack_channel_config_creation_request.persona_id
@ -120,11 +126,11 @@ def create_slack_channel_config(
).id ).id
slack_channel_config_model = insert_slack_channel_config( slack_channel_config_model = insert_slack_channel_config(
db_session=db_session,
slack_bot_id=slack_channel_config_creation_request.slack_bot_id, slack_bot_id=slack_channel_config_creation_request.slack_bot_id,
persona_id=persona_id, persona_id=persona_id,
channel_config=channel_config, channel_config=channel_config,
standard_answer_category_ids=slack_channel_config_creation_request.standard_answer_categories, standard_answer_category_ids=slack_channel_config_creation_request.standard_answer_categories,
db_session=db_session,
enable_auto_filters=slack_channel_config_creation_request.enable_auto_filters, enable_auto_filters=slack_channel_config_creation_request.enable_auto_filters,
) )
return SlackChannelConfig.from_model(slack_channel_config_model) return SlackChannelConfig.from_model(slack_channel_config_model)
@ -235,6 +241,23 @@ def create_bot(
app_token=slack_bot_creation_request.app_token, app_token=slack_bot_creation_request.app_token,
) )
# Create a default Slack channel config
default_channel_config = ChannelConfig(
channel_name=None,
respond_member_group_list=[],
answer_filters=[],
follow_up_tags=[],
)
insert_slack_channel_config(
db_session=db_session,
slack_bot_id=slack_bot_model.id,
persona_id=None,
channel_config=default_channel_config,
standard_answer_category_ids=[],
enable_auto_filters=False,
is_default=True,
)
create_milestone_and_report( create_milestone_and_report(
user=None, user=None,
distinct_id=tenant_id or "N/A", distinct_id=tenant_id or "N/A",
@ -315,3 +338,48 @@ def list_bot_configs(
SlackChannelConfig.from_model(slack_bot_config_model) SlackChannelConfig.from_model(slack_bot_config_model)
for slack_bot_config_model in slack_bot_config_models for slack_bot_config_model in slack_bot_config_models
] ]
@router.get(
"/admin/slack-app/bots/{bot_id}/channels",
)
def get_all_channels_from_slack_api(
bot_id: int,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> list[SlackChannel]:
tokens = fetch_slack_bot_tokens(db_session, bot_id)
if not tokens or "bot_token" not in tokens:
raise HTTPException(
status_code=404, detail="Bot token not found for the given bot ID"
)
bot_token = tokens["bot_token"]
client = WebClient(token=bot_token)
try:
channels = []
cursor = None
while True:
response = client.conversations_list(
types="public_channel,private_channel",
exclude_archived=True,
limit=1000,
cursor=cursor,
)
for channel in response["channels"]:
channels.append(SlackChannel(id=channel["id"], name=channel["name"]))
response_metadata: dict[str, Any] = response.get("response_metadata", {})
if isinstance(response_metadata, dict):
cursor = response_metadata.get("next_cursor")
if not cursor:
break
else:
break
return channels
except SlackApiError as e:
raise HTTPException(
status_code=500, detail=f"Error fetching channels from Slack API: {str(e)}"
)

View File

@ -1,10 +1,9 @@
"use client"; "use client";
import { PageSelector } from "@/components/PageSelector"; import { PageSelector } from "@/components/PageSelector";
import { SlackBot } from "@/lib/types";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FiCheck, FiEdit, FiXCircle } from "react-icons/fi"; import { FiEdit } from "react-icons/fi";
import { import {
Table, Table,
TableBody, TableBody,
@ -13,6 +12,8 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { SlackBot } from "@/lib/types";
const NUM_IN_PAGE = 20; const NUM_IN_PAGE = 20;
@ -42,7 +43,7 @@ function ClickableTableRow({
); );
} }
export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) { export const SlackBotTable = ({ slackBots }: { slackBots: SlackBot[] }) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// sort by id for consistent ordering // sort by id for consistent ordering
@ -67,8 +68,9 @@ export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Default Config</TableHead>
<TableHead>Channel Count</TableHead> <TableHead>Channel Count</TableHead>
<TableHead>Enabled</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -85,21 +87,27 @@ export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) {
{slackBot.name} {slackBot.name}
</div> </div>
</TableCell> </TableCell>
<TableCell>{slackBot.configs_count}</TableCell>
<TableCell> <TableCell>
{slackBot.enabled ? ( {slackBot.enabled ? (
<FiCheck className="text-emerald-600" size="18" /> <Badge variant="success">Enabled</Badge>
) : ( ) : (
<FiXCircle className="text-red-600" size="18" /> <Badge variant="destructive">Disabled</Badge>
)} )}
</TableCell> </TableCell>
<TableCell>
<Badge variant="secondary">Default Set</Badge>
</TableCell>
<TableCell>{slackBot.configs_count}</TableCell>
<TableCell>
{/* Add any action buttons here if needed */}
</TableCell>
</ClickableTableRow> </ClickableTableRow>
); );
})} })}
{slackBots.length === 0 && ( {slackBots.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={4} colSpan={5}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
Please add a New Slack Bot to begin chatting with Danswer! Please add a New Slack Bot to begin chatting with Danswer!
@ -128,4 +136,4 @@ export function SlackBotTable({ slackBots }: { slackBots: SlackBot[] }) {
)} )}
</div> </div>
); );
} };

View File

@ -7,6 +7,7 @@ import { createSlackBot, updateSlackBot } from "./new/lib";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useEffect } from "react"; import { useEffect } from "react";
import { Switch } from "@/components/ui/switch";
export const SlackTokensForm = ({ export const SlackTokensForm = ({
isUpdate, isUpdate,
@ -33,7 +34,9 @@ export const SlackTokensForm = ({
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={{
...initialValues,
}}
validationSchema={Yup.object().shape({ validationSchema={Yup.object().shape({
bot_token: Yup.string().required(), bot_token: Yup.string().required(),
app_token: Yup.string().required(), app_token: Yup.string().required(),

View File

@ -14,8 +14,10 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { FiArrowUpRight } from "react-icons/fi";
import { deleteSlackChannelConfig, isPersonaASlackBotPersona } from "./lib"; import { deleteSlackChannelConfig, isPersonaASlackBotPersona } from "./lib";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { FiPlusSquare, FiSettings } from "react-icons/fi";
const numToDisplay = 50; const numToDisplay = 50;
@ -32,128 +34,147 @@ export function SlackChannelConfigsTable({
}) { }) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// sort by name for consistent ordering const defaultConfig = slackChannelConfigs.find((config) => config.is_default);
slackChannelConfigs.sort((a, b) => { const channelConfigs = slackChannelConfigs.filter(
if (a.id < b.id) { (config) => !config.is_default
return -1; );
} else if (a.id > b.id) {
return 1;
} else {
return 0;
}
});
return ( return (
<div> <div className="space-y-8">
<div className="rounded-md border"> <div className="flex justify-between items-center mb-6">
<Table> <Button
<TableHeader> variant="outline"
<TableRow> onClick={() => {
<TableHead>Channel</TableHead> window.location.href = `/admin/bots/${slackBotId}/channels/${defaultConfig?.id}`;
<TableHead>Assistant</TableHead> }}
<TableHead>Document Sets</TableHead> >
<TableHead>Delete</TableHead> <FiSettings />
</TableRow> Edit Default Config
</TableHeader> </Button>
<TableBody> <Link href={`/admin/bots/${slackBotId}/channels/new`}>
{slackChannelConfigs <Button variant="outline">
.slice(numToDisplay * (page - 1), numToDisplay * page) <FiPlusSquare />
.map((slackChannelConfig) => { New Channel Configuration
return ( </Button>
<TableRow </Link>
key={slackChannelConfig.id}
className="cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => {
window.location.href = `/admin/bots/${slackBotId}/channels/${slackChannelConfig.id}`;
}}
>
<TableCell>
<div className="flex gap-x-2">
<div className="my-auto">
<EditIcon />
</div>
<div className="my-auto">
{"#" + slackChannelConfig.channel_config.channel_name}
</div>
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{slackChannelConfig.persona &&
!isPersonaASlackBotPersona(slackChannelConfig.persona) ? (
<Link
href={`/admin/assistants/${slackChannelConfig.persona.id}`}
className="text-blue-500 flex hover:underline"
>
{slackChannelConfig.persona.name}
</Link>
) : (
"-"
)}
</TableCell>
<TableCell>
<div>
{slackChannelConfig.persona &&
slackChannelConfig.persona.document_sets.length > 0
? slackChannelConfig.persona.document_sets
.map((documentSet) => documentSet.name)
.join(", ")
: "-"}
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div
className="cursor-pointer hover:text-destructive"
onClick={async (e) => {
e.stopPropagation();
const response = await deleteSlackChannelConfig(
slackChannelConfig.id
);
if (response.ok) {
setPopup({
message: `Slack bot config "${slackChannelConfig.id}" deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete Slack bot config - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
</TableCell>
</TableRow>
);
})}
{/* Empty row with message when table has no data */}
{slackChannelConfigs.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
Please add a New Slack Bot Configuration to begin chatting
with Onyx!
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div> </div>
<div className="mt-3 flex"> <div>
<div className="mx-auto"> <h2 className="text-2xl font- mb-4">Channel-Specific Configurations</h2>
<PageSelector <Card>
totalPages={Math.ceil(slackChannelConfigs.length / numToDisplay)} <Table>
currentPage={page} <TableHeader>
onPageChange={(newPage) => setPage(newPage)} <TableRow>
/> <TableHead>Channel</TableHead>
</div> <TableHead>Assistant</TableHead>
<TableHead>Document Sets</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channelConfigs
.slice(numToDisplay * (page - 1), numToDisplay * page)
.map((slackChannelConfig) => {
return (
<TableRow
key={slackChannelConfig.id}
className="cursor-pointer transition-colors"
onClick={() => {
window.location.href = `/admin/bots/${slackBotId}/channels/${slackChannelConfig.id}`;
}}
>
<TableCell>
<div className="flex gap-x-2">
<div className="my-auto">
<EditIcon className="text-muted-foreground" />
</div>
<div className="my-auto">
{"#" +
slackChannelConfig.channel_config.channel_name}
</div>
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{slackChannelConfig.persona &&
!isPersonaASlackBotPersona(
slackChannelConfig.persona
) ? (
<Link
href={`/admin/assistants/${slackChannelConfig.persona.id}`}
className="text-primary hover:underline"
>
{slackChannelConfig.persona.name}
</Link>
) : (
"-"
)}
</TableCell>
<TableCell>
<div>
{slackChannelConfig.persona &&
slackChannelConfig.persona.document_sets.length > 0
? slackChannelConfig.persona.document_sets
.map((documentSet) => documentSet.name)
.join(", ")
: "-"}
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="hover:text-destructive"
onClick={async (e) => {
e.stopPropagation();
const response = await deleteSlackChannelConfig(
slackChannelConfig.id
);
if (response.ok) {
setPopup({
message: `Slack bot config "${slackChannelConfig.id}" deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete Slack bot config - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</Button>
</TableCell>
</TableRow>
);
})}
{channelConfigs.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
No channel-specific configurations. Add a new configuration
to customize behavior for specific channels.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
{channelConfigs.length > numToDisplay && (
<div className="mt-4 flex justify-center">
<PageSelector
totalPages={Math.ceil(channelConfigs.length / numToDisplay)}
currentPage={page}
onPageChange={(newPage) => setPage(newPage)}
/>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,21 +1,29 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React, { useMemo, useState, useEffect } from "react";
import { Formik } from "formik"; import { Formik, Form, Field } from "formik";
import * as Yup from "yup"; import * as Yup from "yup";
import { usePopup } from "@/components/admin/connectors/Popup"; import { usePopup } from "@/components/admin/connectors/Popup";
import { DocumentSet, SlackChannelConfig } from "@/lib/types"; import {
DocumentSet,
SlackChannelConfig,
SlackBotResponseType,
} from "@/lib/types";
import { import {
createSlackChannelConfig, createSlackChannelConfig,
isPersonaASlackBotPersona, isPersonaASlackBotPersona,
updateSlackChannelConfig, updateSlackChannelConfig,
fetchSlackChannels,
} from "../lib"; } from "../lib";
import CardSection from "@/components/admin/CardSection"; import CardSection from "@/components/admin/CardSection";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Persona } from "@/app/admin/assistants/interfaces"; import { Persona } from "@/app/admin/assistants/interfaces";
import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
import { SEARCH_TOOL_ID, SEARCH_TOOL_NAME } from "@/app/chat/tools/constants"; import { SEARCH_TOOL_ID, SEARCH_TOOL_NAME } from "@/app/chat/tools/constants";
import { SlackChannelConfigFormFields } from "./SlackChannelConfigFormFields"; import {
SlackChannelConfigFormFields,
SlackChannelConfigFormFieldsProps,
} from "./SlackChannelConfigFormFields";
export const SlackChannelConfigCreationForm = ({ export const SlackChannelConfigCreationForm = ({
slack_bot_id, slack_bot_id,
@ -33,6 +41,7 @@ export const SlackChannelConfigCreationForm = ({
const { popup, setPopup } = usePopup(); const { popup, setPopup } = usePopup();
const router = useRouter(); const router = useRouter();
const isUpdate = Boolean(existingSlackChannelConfig); const isUpdate = Boolean(existingSlackChannelConfig);
const isDefault = existingSlackChannelConfig?.is_default || false;
const existingSlackBotUsesPersona = existingSlackChannelConfig?.persona const existingSlackBotUsesPersona = existingSlackChannelConfig?.persona
? !isPersonaASlackBotPersona(existingSlackChannelConfig.persona) ? !isPersonaASlackBotPersona(existingSlackChannelConfig.persona)
: false; : false;
@ -46,13 +55,16 @@ export const SlackChannelConfigCreationForm = ({
}, [personas]); }, [personas]);
return ( return (
<CardSection className="max-w-4xl"> <CardSection className="!px-12 max-w-4xl">
{popup} {popup}
<Formik <Formik
initialValues={{ initialValues={{
slack_bot_id: slack_bot_id, slack_bot_id: slack_bot_id,
channel_name: channel_name: isDefault
existingSlackChannelConfig?.channel_config.channel_name || "", ? ""
: existingSlackChannelConfig?.channel_config.channel_name || "",
response_type: "citations" as SlackBotResponseType,
answer_validity_check_enabled: ( answer_validity_check_enabled: (
existingSlackChannelConfig?.channel_config?.answer_filters || [] existingSlackChannelConfig?.channel_config?.answer_filters || []
).includes("well_answered_postfilter"), ).includes("well_answered_postfilter"),
@ -90,8 +102,6 @@ export const SlackChannelConfigCreationForm = ({
!isPersonaASlackBotPersona(existingSlackChannelConfig.persona) !isPersonaASlackBotPersona(existingSlackChannelConfig.persona)
? existingSlackChannelConfig.persona.id ? existingSlackChannelConfig.persona.id
: null, : null,
response_type:
existingSlackChannelConfig?.response_type || "citations",
standard_answer_categories: standard_answer_categories:
existingSlackChannelConfig?.standard_answer_categories || [], existingSlackChannelConfig?.standard_answer_categories || [],
knowledge_source: existingSlackBotUsesPersona knowledge_source: existingSlackBotUsesPersona
@ -102,10 +112,12 @@ export const SlackChannelConfigCreationForm = ({
}} }}
validationSchema={Yup.object().shape({ validationSchema={Yup.object().shape({
slack_bot_id: Yup.number().required(), slack_bot_id: Yup.number().required(),
channel_name: Yup.string().required("Channel Name is required"), channel_name: isDefault
response_type: Yup.string() ? Yup.string()
: Yup.string().required("Channel Name is required"),
response_type: Yup.mixed<SlackBotResponseType>()
.oneOf(["quotes", "citations"]) .oneOf(["quotes", "citations"])
.required("Response type is required"), .required(),
answer_validity_check_enabled: Yup.boolean().required(), answer_validity_check_enabled: Yup.boolean().required(),
questionmark_prefilter_enabled: Yup.boolean().required(), questionmark_prefilter_enabled: Yup.boolean().required(),
respond_tag_only: Yup.boolean().required(), respond_tag_only: Yup.boolean().required(),
@ -159,6 +171,7 @@ export const SlackChannelConfigCreationForm = ({
standard_answer_categories: values.standard_answer_categories.map( standard_answer_categories: values.standard_answer_categories.map(
(category: any) => category.id (category: any) => category.id
), ),
response_type: values.response_type as SlackBotResponseType,
}; };
if (!cleanedValues.still_need_help_enabled) { if (!cleanedValues.still_need_help_enabled) {
@ -191,13 +204,22 @@ export const SlackChannelConfigCreationForm = ({
} }
}} }}
> >
<SlackChannelConfigFormFields {({ isSubmitting, values, setFieldValue }) => (
isUpdate={isUpdate} <Form>
documentSets={documentSets} <div className="pb-6 w-full">
searchEnabledAssistants={searchEnabledAssistants} <SlackChannelConfigFormFields
standardAnswerCategoryResponse={standardAnswerCategoryResponse} {...values}
setPopup={setPopup} isUpdate={isUpdate}
/> isDefault={isDefault}
documentSets={documentSets}
searchEnabledAssistants={searchEnabledAssistants}
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
setPopup={setPopup}
slack_bot_id={slack_bot_id}
/>
</div>
</Form>
)}
</Formik> </Formik>
</CardSection> </CardSection>
); );

View File

@ -1,7 +1,13 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { FieldArray, Form, useFormikContext, ErrorMessage } from "formik"; import {
FieldArray,
Form,
useFormikContext,
ErrorMessage,
Field,
} from "formik";
import { CCPairDescriptor, DocumentSet } from "@/lib/types"; import { CCPairDescriptor, DocumentSet } from "@/lib/types";
import { import {
BooleanFormField, BooleanFormField,
@ -31,9 +37,15 @@ import { TooltipProvider } from "@radix-ui/react-tooltip";
import { SourceIcon } from "@/components/SourceIcon"; import { SourceIcon } from "@/components/SourceIcon";
import Link from "next/link"; import Link from "next/link";
import { AssistantIcon } from "@/components/assistants/AssistantIcon"; import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
import { fetchSlackChannels } from "../lib";
import { Badge } from "@/components/ui/badge";
import useSWR from "swr";
import { ThreeDotsLoader } from "@/components/Loading";
interface SlackChannelConfigFormFieldsProps { export interface SlackChannelConfigFormFieldsProps {
isUpdate: boolean; isUpdate: boolean;
isDefault: boolean;
documentSets: DocumentSet[]; documentSets: DocumentSet[];
searchEnabledAssistants: Persona[]; searchEnabledAssistants: Persona[];
standardAnswerCategoryResponse: StandardAnswerCategoryResponse; standardAnswerCategoryResponse: StandardAnswerCategoryResponse;
@ -41,19 +53,23 @@ interface SlackChannelConfigFormFieldsProps {
message: string; message: string;
type: "error" | "success" | "warning"; type: "error" | "success" | "warning";
}) => void; }) => void;
slack_bot_id: number;
} }
export function SlackChannelConfigFormFields({ export function SlackChannelConfigFormFields({
isUpdate, isUpdate,
isDefault,
documentSets, documentSets,
searchEnabledAssistants, searchEnabledAssistants,
standardAnswerCategoryResponse, standardAnswerCategoryResponse,
setPopup, setPopup,
slack_bot_id,
}: SlackChannelConfigFormFieldsProps) { }: SlackChannelConfigFormFieldsProps) {
const router = useRouter(); const router = useRouter();
const { values, setFieldValue } = useFormikContext<any>(); const { values, setFieldValue } = useFormikContext<any>();
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [viewUnselectableSets, setViewUnselectableSets] = useState(false); const [viewUnselectableSets, setViewUnselectableSets] = useState(false);
const [currentSearchTerm, setCurrentSearchTerm] = useState("");
const [viewSyncEnabledAssistants, setViewSyncEnabledAssistants] = const [viewSyncEnabledAssistants, setViewSyncEnabledAssistants] =
useState(false); useState(false);
@ -152,11 +168,54 @@ export function SlackChannelConfigFormFields({
); );
}, [documentSets]); }, [documentSets]);
return ( const { data: channelOptions, isLoading } = useSWR(
<Form className="px-6 max-w-4xl"> `/api/manage/admin/slack-app/bots/${slack_bot_id}/channels`,
<div className="pt-4 w-full"> async (url: string) => {
<TextFormField name="channel_name" label="Slack Channel Name:" /> const channels = await fetchSlackChannels(slack_bot_id);
return channels.map((channel: any) => ({
name: channel.name,
value: channel.id,
}));
}
);
if (isLoading) {
return <ThreeDotsLoader />;
}
return (
<>
<div className="w-full">
{isDefault && (
<Badge variant="agent" className="bg-blue-100 text-blue-800">
Default Configuration
</Badge>
)}
{!isDefault && (
<>
<label
htmlFor="channel_name"
className="block font-medium text-base mb-2"
>
Select A Slack Channel:
</label>{" "}
<Field name="channel_name">
{({ field, form }: { field: any; form: any }) => (
<SearchMultiSelectDropdown
options={channelOptions || []}
onSelect={(selected) => {
form.setFieldValue("channel_name", selected.name);
setCurrentSearchTerm(selected.name);
}}
initialSearchTerm={field.value}
onSearchTermChange={(term) => {
setCurrentSearchTerm(term);
form.setFieldValue("channel_name", term);
}}
/>
)}
</Field>
</>
)}
<div className="space-y-2 mt-4"> <div className="space-y-2 mt-4">
<Label>Knowledge Source</Label> <Label>Knowledge Source</Label>
<RadioGroup <RadioGroup
@ -170,7 +229,7 @@ export function SlackChannelConfigFormFields({
value="all_public" value="all_public"
id="all_public" id="all_public"
label="All Public Knowledge" label="All Public Knowledge"
sublabel="Let OnyxBot respond based on information from all public connectors " sublabel="Let OnyxBot respond based on information from all public connectors"
/> />
{selectableSets.length + unselectableSets.length > 0 && ( {selectableSets.length + unselectableSets.length > 0 && (
<RadioGroupItemField <RadioGroupItemField
@ -188,7 +247,6 @@ export function SlackChannelConfigFormFields({
/> />
</RadioGroup> </RadioGroup>
</div> </div>
{values.knowledge_source === "document_sets" && {values.knowledge_source === "document_sets" &&
documentSets.length > 0 && ( documentSets.length > 0 && (
<div className="mt-4"> <div className="mt-4">
@ -281,7 +339,6 @@ export function SlackChannelConfigFormFields({
/> />
</div> </div>
)} )}
{values.knowledge_source === "assistant" && ( {values.knowledge_source === "assistant" && (
<div className="mt-4"> <div className="mt-4">
<SubLabel> <SubLabel>
@ -353,15 +410,15 @@ export function SlackChannelConfigFormFields({
)} )}
</div> </div>
<div className="mt-2"> <div className="mt-6">
<AdvancedOptionsToggle <AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions} showAdvancedOptions={showAdvancedOptions}
setShowAdvancedOptions={setShowAdvancedOptions} setShowAdvancedOptions={setShowAdvancedOptions}
/> />
</div> </div>
{showAdvancedOptions && ( {showAdvancedOptions && (
<div className="mt-4"> <div className="mt-2 space-y-4">
<div className="w-64 mb-4"> <div className="w-64">
<SelectorFormField <SelectorFormField
name="response_type" name="response_type"
label="Answer Type" label="Answer Type"
@ -380,83 +437,79 @@ export function SlackChannelConfigFormFields({
tooltip="If set, will show a button at the bottom of the response that allows the user to continue the conversation in the Onyx Web UI" tooltip="If set, will show a button at the bottom of the response that allows the user to continue the conversation in the Onyx Web UI"
/> />
<div className="flex flex-col space-y-3 mt-2"> <BooleanFormField
<BooleanFormField name="still_need_help_enabled"
name="still_need_help_enabled" removeIndent
removeIndent onChange={(checked: boolean) => {
onChange={(checked: boolean) => { setFieldValue("still_need_help_enabled", checked);
setFieldValue("still_need_help_enabled", checked); if (!checked) {
if (!checked) { setFieldValue("follow_up_tags", []);
setFieldValue("follow_up_tags", []); }
} }}
}} label={'Give a "Still need help?" button'}
label={'Give a "Still need help?" button'} tooltip={`OnyxBot's response will include a button at the bottom
tooltip={`OnyxBot's response will include a button at the bottom of the response that asks the user if they still need help.`}
of the response that asks the user if they still need help.`} />
/> {values.still_need_help_enabled && (
{values.still_need_help_enabled && ( <CollapsibleSection prompt="Configure Still Need Help Button">
<CollapsibleSection prompt="Configure Still Need Help Button">
<TextArrayField
name="follow_up_tags"
label="(Optional) Users / Groups to Tag"
values={values}
subtext={
<div>
The Slack users / groups we should tag if the user clicks
the &quot;Still need help?&quot; button. If no emails are
provided, we will not tag anyone and will just react with
a 🆘 emoji to the original message.
</div>
}
placeholder="User email or user group name..."
/>
</CollapsibleSection>
)}
<BooleanFormField
name="answer_validity_check_enabled"
removeIndent
label="Only respond if citations found"
tooltip="If set, will only answer questions where the model successfully produces citations"
/>
<BooleanFormField
name="questionmark_prefilter_enabled"
removeIndent
label="Only respond to questions"
tooltip="If set, OnyxBot will only respond to messages that contain a question mark"
/>
<BooleanFormField
name="respond_tag_only"
removeIndent
label="Respond to @OnyxBot Only"
tooltip="If set, OnyxBot will only respond when directly tagged"
/>
<BooleanFormField
name="respond_to_bots"
removeIndent
label="Respond to Bot messages"
tooltip="If not set, OnyxBot will always ignore messages from Bots"
/>
<BooleanFormField
name="enable_auto_filters"
removeIndent
label="Enable LLM Autofiltering"
tooltip="If set, the LLM will generate source and time filters based on the user's query"
/>
<div className="mt-12">
<TextArrayField <TextArrayField
name="respond_member_group_list" name="follow_up_tags"
label="(Optional) Respond to Certain Users / Groups" label="(Optional) Users / Groups to Tag"
subtext={
"If specified, OnyxBot responses will only " +
"be visible to the members or groups in this list."
}
values={values} values={values}
subtext={
<div>
The Slack users / groups we should tag if the user clicks
the &quot;Still need help?&quot; button. If no emails are
provided, we will not tag anyone and will just react with a
🆘 emoji to the original message.
</div>
}
placeholder="User email or user group name..." placeholder="User email or user group name..."
/> />
</div> </CollapsibleSection>
</div> )}
<BooleanFormField
name="answer_validity_check_enabled"
removeIndent
label="Only respond if citations found"
tooltip="If set, will only answer questions where the model successfully produces citations"
/>
<BooleanFormField
name="questionmark_prefilter_enabled"
removeIndent
label="Only respond to questions"
tooltip="If set, OnyxBot will only respond to messages that contain a question mark"
/>
<BooleanFormField
name="respond_tag_only"
removeIndent
label="Respond to @OnyxBot Only"
tooltip="If set, OnyxBot will only respond when directly tagged"
/>
<BooleanFormField
name="respond_to_bots"
removeIndent
label="Respond to Bot messages"
tooltip="If not set, OnyxBot will always ignore messages from Bots"
/>
<BooleanFormField
name="enable_auto_filters"
removeIndent
label="Enable LLM Autofiltering"
tooltip="If set, the LLM will generate source and time filters based on the user's query"
/>
<TextArrayField
name="respond_member_group_list"
label="(Optional) Respond to Certain Users / Groups"
subtext={
"If specified, OnyxBot responses will only " +
"be visible to the members or groups in this list."
}
values={values}
placeholder="User email or user group name..."
/>
<StandardAnswerCategoryDropdownField <StandardAnswerCategoryDropdownField
standardAnswerCategoryResponse={standardAnswerCategoryResponse} standardAnswerCategoryResponse={standardAnswerCategoryResponse}
@ -468,7 +521,7 @@ export function SlackChannelConfigFormFields({
</div> </div>
)} )}
<div className="flex mt-2 gap-x-2 w-full justify-end flex"> <div className="flex mt-8 gap-x-2 w-full justify-end">
{shouldShowPrivacyAlert && ( {shouldShowPrivacyAlert && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@ -518,13 +571,11 @@ export function SlackChannelConfigFormFields({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
<Button onClick={() => {}} type="submit"> <Button type="submit">{isUpdate ? "Update" : "Create"}</Button>
{isUpdate ? "Update" : "Create"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}> <Button type="button" variant="outline" onClick={() => router.back()}>
Cancel Cancel
</Button> </Button>
</div> </div>
</Form> </>
); );
} }

View File

@ -94,3 +94,17 @@ export const deleteSlackChannelConfig = async (id: number) => {
export function isPersonaASlackBotPersona(persona: Persona) { export function isPersonaASlackBotPersona(persona: Persona) {
return persona.name.startsWith("__slack_bot_persona__"); return persona.name.startsWith("__slack_bot_persona__");
} }
export const fetchSlackChannels = async (botId: number) => {
return fetch(`/api/manage/admin/slack-app/bots/${botId}/channels`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to fetch Slack channels");
}
return response.json();
});
};

View File

@ -78,30 +78,6 @@ function SlackBotEditPage({
/> />
<Separator /> <Separator />
<div className="my-8" />
<Link
className="
flex
py-2
px-4
mt-2
border
border-border
h-fit
cursor-pointer
hover:bg-hover
text-sm
w-80
"
href={`/admin/bots/${unwrappedParams["bot-id"]}/channels/new`}
>
<div className="mx-auto flex">
<FiPlusSquare className="my-auto mr-2" />
New Slack Channel Configuration
</div>
</Link>
<div className="mt-8"> <div className="mt-8">
<SlackChannelConfigsTable <SlackChannelConfigsTable
slackBotId={slackBot.id} slackBotId={slackBot.id}

View File

@ -52,15 +52,19 @@ export function SearchMultiSelectDropdown({
itemComponent, itemComponent,
onCreate, onCreate,
onDelete, onDelete,
onSearchTermChange,
initialSearchTerm = "",
}: { }: {
options: StringOrNumberOption[]; options: StringOrNumberOption[];
onSelect: (selected: StringOrNumberOption) => void; onSelect: (selected: StringOrNumberOption) => void;
itemComponent?: FC<{ option: StringOrNumberOption }>; itemComponent?: FC<{ option: StringOrNumberOption }>;
onCreate?: (name: string) => void; onCreate?: (name: string) => void;
onDelete?: (name: string) => void; onDelete?: (name: string) => void;
onSearchTermChange?: (term: string) => void;
initialSearchTerm?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const handleSelect = (option: StringOrNumberOption) => { const handleSelect = (option: StringOrNumberOption) => {
@ -89,6 +93,10 @@ export function SearchMultiSelectDropdown({
}; };
}, []); }, []);
useEffect(() => {
setSearchTerm(initialSearchTerm);
}, [initialSearchTerm]);
return ( return (
<div className="relative text-left w-full" ref={dropdownRef}> <div className="relative text-left w-full" ref={dropdownRef}>
<div> <div>
@ -105,21 +113,21 @@ export function SearchMultiSelectDropdown({
} }
}} }}
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
className="inline-flex justify-between w-full px-4 py-2 text-sm bg-background border border-border rounded-md shadow-sm" className="inline-flex justify-between w-full px-4 py-2 text-sm bg-white text-gray-800 border border-gray-300 rounded-md shadow-sm"
/> />
<button <button
type="button" type="button"
className="absolute top-0 right-0 text-sm h-full px-2 border-l border-border" className="absolute top-0 right-0 text-sm h-full px-2 border-l border-gray-300"
aria-expanded={isOpen} aria-expanded={isOpen}
aria-haspopup="true" aria-haspopup="true"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<ChevronDownIcon className="my-auto w-4 h-4" /> <ChevronDownIcon className="my-auto w-4 h-4 text-gray-600" />
</button> </button>
</div> </div>
{isOpen && ( {isOpen && (
<div className="absolute z-10 mt-1 w-full rounded-md shadow-lg bg-background border border-border max-h-60 overflow-y-auto"> <div className="absolute z-10 mt-1 w-full rounded-md shadow-lg bg-white border border-gray-300 max-h-60 overflow-y-auto">
<div <div
role="menu" role="menu"
aria-orientation="vertical" aria-orientation="vertical"
@ -152,9 +160,9 @@ export function SearchMultiSelectDropdown({
option.name.toLowerCase() === searchTerm.toLowerCase() option.name.toLowerCase() === searchTerm.toLowerCase()
) && ( ) && (
<> <>
<div className="border-t border-border"></div> <div className="border-t border-gray-300"></div>
<button <button
className="w-full text-left flex items-center px-4 py-2 text-sm hover:bg-hover" className="w-full text-left flex items-center px-4 py-2 text-sm text-gray-800 hover:bg-gray-100"
role="menuitem" role="menuitem"
onClick={() => { onClick={() => {
onCreate(searchTerm); onCreate(searchTerm);
@ -162,7 +170,7 @@ export function SearchMultiSelectDropdown({
setSearchTerm(""); setSearchTerm("");
}} }}
> >
<PlusIcon className="w-4 h-4 mr-2" /> <PlusIcon className="w-4 h-4 mr-2 text-gray-600" />
Create label &quot;{searchTerm}&quot; Create label &quot;{searchTerm}&quot;
</button> </button>
</> </>
@ -170,7 +178,7 @@ export function SearchMultiSelectDropdown({
{filteredOptions.length === 0 && {filteredOptions.length === 0 &&
(!onCreate || searchTerm.trim() === "") && ( (!onCreate || searchTerm.trim() === "") && (
<div className="px-4 py-2.5 text-sm text-text-muted"> <div className="px-4 py-2.5 text-sm text-gray-500">
No matches found No matches found
</div> </div>
)} )}

View File

@ -10,7 +10,9 @@ const badgeVariants = cva(
variant: { variant: {
"agent-faded": "agent-faded":
"border-neutral-200 bg-neutral-100 text-neutral-600 hover:bg-neutral-200", "border-neutral-200 bg-neutral-100 text-neutral-600 hover:bg-neutral-200",
agent: "border-agent bg-agent text-white hover:bg-agent-hover", agent:
"border-orange-200 bg-orange-50 text-orange-600 hover:bg-orange-75 dark:bg-orange-900 dark:text-neutral-50 dark:hover:bg-orange-850",
canceled: canceled:
"border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-75 dark:bg-gray-900 dark:text-neutral-50 dark:hover:bg-gray-850", "border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-75 dark:bg-gray-900 dark:text-neutral-50 dark:hover:bg-gray-850",
orange: orange:

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300", "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300",
{ {
variants: { variants: {
variant: { variant: {
@ -58,8 +58,8 @@ const buttonVariants = cva(
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 py-2",
xs: "h-8 px-3 py-1", xs: "h-8 px-3 py-1",
sm: "h-9 rounded-md px-3", sm: "h-9 px-3",
lg: "h-11 rounded-md px-8", lg: "h-11 px-8",
icon: "h-10 w-10", icon: "h-10 w-10",
}, },
reverse: { reverse: {
@ -118,7 +118,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return ( return (
<div className="relative group"> <div className="relative group">
{button} {button}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-neutral-800 text-white text-sm rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap"> <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-neutral-800 text-white text-sm rounded-sm opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{tooltip} {tooltip}
</div> </div>
</div> </div>

View File

@ -258,23 +258,34 @@ export type SlackBotResponseType = "quotes" | "citations";
export interface SlackChannelConfig { export interface SlackChannelConfig {
id: number; id: number;
slack_bot_id: number; slack_bot_id: number;
persona_id: number | null;
persona: Persona | null; persona: Persona | null;
channel_config: ChannelConfig; channel_config: ChannelConfig;
response_type: SlackBotResponseType;
standard_answer_categories: StandardAnswerCategory[];
enable_auto_filters: boolean; enable_auto_filters: boolean;
standard_answer_categories: StandardAnswerCategory[];
is_default: boolean;
} }
export interface SlackBot { export interface SlackChannelDescriptor {
id: string;
name: string;
}
export type SlackBot = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
configs_count: number; configs_count: number;
slack_channel_configs: Array<{
// tokens id: number;
is_default: boolean;
channel_config: {
channel_name: string;
};
}>;
bot_token: string; bot_token: string;
app_token: string; app_token: string;
} };
export interface SlackBotTokens { export interface SlackBotTokens {
bot_token: string; bot_token: string;